diff --git a/README.md b/README.md index 22285578c8..2ed77beb5b 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ PastureStack is an independent community effort to preserve, audit, and moderniz ## Project status -The current source compatibility target is `1.6.155`. It retains the existing Node 24, Ember, Sass, +The current source compatibility target is `1.6.156`. It retains the existing Node 24, Ember, Sass, dependency, browser-smoke, terminal, console, and test-harness modernization. It adds a provider-neutral OpenID Connect administration and sign-in flow with PKCE S256, staged configuration validation, a real test login before @@ -16,12 +16,17 @@ activation, and local-authentication recovery. Product-owned names, logos, icons, package metadata, and visible text use PastureStack branding. API models and protocol fields remain compatible. -Release `1.6.155` positions global notices below the navigation bar using the -existing navbar height and keeps them inside narrow viewports. This corrects -the mobile layout overlap found during `1.6.154` acceptance without changing -authorization or API behavior. See the -[release note](docs/releases/web-console-1.6.155.md) for focused visual tests; -packaged Server/browser acceptance must be checked against a new image. +Release `1.6.156` places authenticated-page notices in the normal layout flow +between the navigation and main content, so an error cannot cover the page +title or right-side actions. Login/MFA notices keep their existing body host. +See the [release note](docs/releases/web-console-1.6.156.md) for focused tests; +packaged Server/browser acceptance remains a separate gate. + +Release `1.6.155` moved global notices below the navigation bar and bounded +their width. QA 8080 browser acceptance subsequently found that the fixed +notice still covered the mobile page title and desktop header actions. It is +not the accepted layout; see the +[1.6.155 release note](docs/releases/web-console-1.6.155.md). Release `1.6.154` shows a localized, persistent permission error when a direct Stack or Service creation URL is denied, then returns to the Stacks list. diff --git a/app/authenticated/route.js b/app/authenticated/route.js index 9c9c860f4f..746f79e0bb 100644 --- a/app/authenticated/route.js +++ b/app/authenticated/route.js @@ -38,6 +38,7 @@ export function projectIdFromTransition(transition) { export default Route.extend(Subscribe, PromiseToCb, { catalog : service(), + growl : service(), prefs : service(), projects : service(), settings : service(), @@ -158,6 +159,12 @@ export default Route.extend(Subscribe, PromiseToCb, { let app = this.controllerFor('application'); this._super(); + scheduleOnce('afterRender', this, function() { + let mount = document.getElementById('growl-mount'); + if (mount && mount.isConnected) { + this.get('growl').placeContainer(mount); + } + }); if ( !this.controllerFor('application').get('isPopup') && this.get('projects.current') ) { this.connectSubscribe(); @@ -177,6 +184,9 @@ export default Route.extend(Subscribe, PromiseToCb, { }, deactivate() { + // Keep the plugin's container alive when Ember removes this route's mount. + this.get('growl').placeContainer(document.body); + this._super(); this.disconnectSubscribe(); cancel(this.get('testTimer')); diff --git a/app/authenticated/template.hbs b/app/authenticated/template.hbs index 52862e75c2..4006c97478 100644 --- a/app/authenticated/template.hbs +++ b/app/authenticated/template.hbs @@ -7,6 +7,7 @@ goToPrevious="goToPrevious" }} {{/unless}} +
{{#if (and this.forceUpgrade this.projects.current.needsUpgrade)}} {{project-upgrade model=this.projects.current}} diff --git a/app/services/growl.js b/app/services/growl.js index 043a705253..07d68be4d7 100644 --- a/app/services/growl.js +++ b/app/services/growl.js @@ -12,8 +12,20 @@ export default Service.extend({ $.jGrowl.defaults.closerTemplate = '
'; }, + placeContainer(target) { + let destination = target || document.getElementById('growl-mount') || document.body; + let container = document.getElementById('jGrowl'); + + if (container && container.parentNode !== destination) { + destination.appendChild(container); + } + + return destination; + }, + raw: function(title, body, opt) { opt = opt || {}; + opt.appendTo = this.placeContainer(); if ( title ) { diff --git a/app/styles/components/_growl.scss b/app/styles/components/_growl.scss index 5dbfbff5bf..c2d8505219 100644 --- a/app/styles/components/_growl.scss +++ b/app/styles/components/_growl.scss @@ -7,6 +7,15 @@ $navbar-height: null !default; z-index: 10; } +#growl-mount .jGrowl.top-right { + // The authenticated layout places this between the navbar and main. + // Keep notices in flow so page titles and actions start below them. + position: static; + display: flex; + flex-direction: column; + align-items: flex-end; +} + .jGrowl { .jGrowl-close { position: absolute; @@ -23,6 +32,12 @@ $navbar-height: null !default; color: white; } + .jGrowl-notification { + // Anchor each dismiss control to its own notice, including in the + // in-flow mobile layout and when several notices are visible. + position: relative; + } + .jGrowl-notification .jGrowl-header { font-size: 1em; } diff --git a/docs/baselines/npm-package-lock.sass-replacement.node24-ignore-scripts.json b/docs/baselines/npm-package-lock.sass-replacement.node24-ignore-scripts.json index b106cb37d7..1ea9515f9c 100644 --- a/docs/baselines/npm-package-lock.sass-replacement.node24-ignore-scripts.json +++ b/docs/baselines/npm-package-lock.sass-replacement.node24-ignore-scripts.json @@ -1,12 +1,12 @@ { "name": "@pasturestack/web-console", - "version": "1.6.155", + "version": "1.6.156", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@pasturestack/web-console", - "version": "1.6.155", + "version": "1.6.156", "license": "Apache-2.0", "dependencies": { "sass": "1.103.1" diff --git a/docs/releases/web-console-1.6.155.md b/docs/releases/web-console-1.6.155.md index 3f9fa783c0..876021032f 100644 --- a/docs/releases/web-console-1.6.155.md +++ b/docs/releases/web-console-1.6.155.md @@ -17,3 +17,10 @@ The focused Chrome QUnit layout test covers both themes, both directions and fit, dismiss-control placement and opacity. Local production/development build and the focused test passed. Official release CI and packaged 8080 browser acceptance are separate gates and must be recorded after publication. + +The subsequent isolated v1.6.489 / 1.6.155 packaged-browser run confirmed +navigation clearance but found that the fixed notice still obscured the +375px page title and 1440px right-side sort controls. Thus the layout was not +accepted despite the original focused runner reporting visibility PASS. +Web Console 1.6.156 moves authenticated notices into document flow and adds +content-overlap checks; this note retains the actual 1.6.155 outcome. diff --git a/docs/releases/web-console-1.6.156.md b/docs/releases/web-console-1.6.156.md new file mode 100644 index 0000000000..60855d28c8 --- /dev/null +++ b/docs/releases/web-console-1.6.156.md @@ -0,0 +1,25 @@ +# Web Console 1.6.156 + +Server v1.6.489 packaged Web Console 1.6.155. Its isolated QA 8080 browser +run confirmed that the permission notice was visible and no longer covered +the 45px navigation bar, but screenshots revealed a remaining defect: at +375px the fixed notice covered the page title; at 1440px it covered the +right-side sort controls. The earlier viewport-only test had incorrectly +reported those cases as passing. This release addresses that shared layout +cause, not the Stack or Service authorization rules. + +On authenticated pages the existing jGrowl container is moved into a mount +between the navigation and `
`. It stays in the normal document flow and +is right-aligned, so visible notices push page titles and header actions down +at desktop and mobile widths. The same container and plugin instance move +back to the body when leaving the authenticated route; login and MFA notices +retain their existing behavior. A deferred mount operation checks that the +destination still belongs to the document before moving the container. + +Focused Chrome QUnit tests cover the shared notification service and layout +at 1440px, 375px, and 280px, including both themes and text directions. +Local Playwright screenshots with compiled CSS verify that the notice clears +the navigation, title, and header actions at those widths. These are source +and local-browser checks; official CI, the immutable Server image, and a new +QA 8080 packaged-browser receipt must be verified separately. No API, +Engine, permission, or request-payload contract changes in this release. diff --git a/package-lock.json b/package-lock.json index b106cb37d7..1ea9515f9c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@pasturestack/web-console", - "version": "1.6.155", + "version": "1.6.156", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@pasturestack/web-console", - "version": "1.6.155", + "version": "1.6.156", "license": "Apache-2.0", "dependencies": { "sass": "1.103.1" diff --git a/package.json b/package.json index d1493b989f..84d5510bfe 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@pasturestack/web-console", - "version": "1.6.155", + "version": "1.6.156", "private": true, "description": "PastureStack browser console for the compatible control platform.", "repository": { diff --git a/scripts/check-modernization-blockers b/scripts/check-modernization-blockers index f6d8b9531e..7eca9d616c 100755 --- a/scripts/check-modernization-blockers +++ b/scripts/check-modernization-blockers @@ -41,8 +41,8 @@ with open('package.json', encoding='utf-8') as f: print(json.load(f).get('version', '')) PY ) -if [[ "$version" != "1.6.155" ]]; then - echo "UNEXPECTED_UI_ARTIFACT_VERSION version=$version expected=1.6.155" +if [[ "$version" != "1.6.156" ]]; then + echo "UNEXPECTED_UI_ARTIFACT_VERSION version=$version expected=1.6.156" failures=$((failures + 1)) fi diff --git a/scripts/check-ui-console-workspace b/scripts/check-ui-console-workspace index ea9c19448e..d5051eb217 100755 --- a/scripts/check-ui-console-workspace +++ b/scripts/check-ui-console-workspace @@ -141,4 +141,4 @@ if [[ -n ${PASTURESTACK_PRIVATE_MARKER:-} ]] && grep -RInF -- "$PASTURESTACK_PRI fi printf 'UI_CONSOLE_WORKSPACE_OK version=%s persistence=%s cross_tab=%s\n' \ - 1.6.155 browser-session broker-broadcast + 1.6.156 browser-session broker-broadcast diff --git a/scripts/check-ui-critical-high-dependencies b/scripts/check-ui-critical-high-dependencies index 60ddcc8683..6c5be3fa5f 100755 --- a/scripts/check-ui-critical-high-dependencies +++ b/scripts/check-ui-critical-high-dependencies @@ -66,7 +66,7 @@ if lock_bytes != baseline_bytes: lock = json.loads(lock_bytes) packages = lock.get("packages", {}) root = packages.get("", {}) -if package.get("version") != "1.6.155": +if package.get("version") != "1.6.156": fail(f"unexpected Web Console version: {package.get('version')}") if root.get("version") != package.get("version"): fail(f"lock root version differs: {root.get('version')}") diff --git a/tests/unit/services/growl-test.js b/tests/unit/services/growl-test.js index 2ce40f5fce..3087b1cadb 100644 --- a/tests/unit/services/growl-test.js +++ b/tests/unit/services/growl-test.js @@ -1,8 +1,49 @@ import { module, test } from 'qunit'; +import $ from 'jquery'; import Growl from 'ui/services/growl'; module('Unit | Service | growl'); +test('notices use the body outside authenticated pages and move with its mount', function(assert) { + let growl = Growl.create(); + growl.raw('Login error', 'Try again'); + + let container = document.getElementById('jGrowl'); + assert.ok(container && container.classList.contains('jGrowl'), 'the global plugin starts'); + assert.strictEqual(container.parentNode, document.body, 'login notices use the body'); + let instance = $(container).data('jGrowl.instance'); + assert.ok(instance && instance.interval, 'the plugin timer is running'); + + let mount = document.createElement('div'); + mount.id = 'growl-mount'; + document.getElementById('qunit-fixture').appendChild(mount); + growl.placeContainer(); + assert.strictEqual(container.parentNode, mount, 'authenticated notices use the content flow'); + + growl.placeContainer(document.body); + assert.strictEqual(container.parentNode, document.body, 'logout keeps the plugin available'); + assert.strictEqual($(container).data('jGrowl.instance'), instance, + 'moving the container preserves its running plugin instance'); + mount.remove(); + assert.strictEqual(growl.placeContainer(), document.body, 'later login notices still target the body'); + growl.raw('Login error', 'Try again'); + assert.strictEqual(container.parentNode, document.body, 'later login errors still use the body'); + + $(container).jGrowl('shutdown'); + container.remove(); + + document.getElementById('qunit-fixture').appendChild(mount); + growl.raw('Denied', 'No create permission'); + let newContainer = document.getElementById('jGrowl'); + assert.strictEqual(newContainer.parentNode, mount, + 'the first authenticated notice is created inside the mount'); + + $(newContainer).jGrowl('shutdown'); + newContainer.remove(); + mount.remove(); + growl.destroy(); +}); + test('denied deletes and actions use neutral, private-safe copy in all supported write locales', async function(assert) { for (let locale of ['en-us', 'zh-tw', 'ja-jp']) { let response = await fetch(`/translations/${locale}.json`); diff --git a/tests/unit/vendor/growl-viewport-layout-test.js b/tests/unit/vendor/growl-viewport-layout-test.js index 403d9485d2..d746a774a7 100644 --- a/tests/unit/vendor/growl-viewport-layout-test.js +++ b/tests/unit/vendor/growl-viewport-layout-test.js @@ -2,7 +2,7 @@ import { module, test } from 'qunit'; module('Unit | Vendor | Growl viewport layout'); -function growlFrame(asset, direction) { +function growlFrame(asset, direction, headingLayout) { return new Promise((resolve, reject) => { let frame = document.createElement('iframe'); let timer = setTimeout(() => reject(new Error(`Timed out loading ${asset}`)), 10000); @@ -15,47 +15,73 @@ function growlFrame(asset, direction) { frame.srcdoc = ` - +
-
-
-
×
-
沒有權限建立服務
-
您沒有權限在此專案建立服務。
+
+
+
+
×
+
沒有權限建立服務
+
您沒有權限在此專案建立服務。
+
-
`; +
+

應用程式堆疊

+ 新增堆疊 +
+
+ `; document.getElementById('qunit-fixture').appendChild(frame); }); } -test('global notices clear the navbar and fit desktop and mobile viewports', async function(assert) { +test('global notices clear the navbar and page titles and fit the viewport', async function(assert) { for (let theme of ['light', 'dark']) { for (let direction of ['ltr', 'rtl']) { let asset = `ui-${theme}${direction === 'rtl' ? '.rtl' : ''}.css`; - let frame = await growlFrame(asset, direction); - let page = frame.contentDocument; - let notice = page.querySelector('.jGrowl-notification'); - - for (let width of [1440, 375, 280]) { - frame.style.width = `${width}px`; - await new Promise((resolve) => frame.contentWindow.requestAnimationFrame(resolve)); - - let navbar = page.querySelector('nav.navbar').getBoundingClientRect(); - let bounds = notice.getBoundingClientRect(); - let close = page.querySelector('.jGrowl-close').getBoundingClientRect(); - let context = `${asset} ${width}px`; - - assert.ok(bounds.top >= navbar.bottom, `${context}: notice starts below the navbar`); - assert.ok(bounds.left >= -1 && bounds.right <= width + 1, - `${context}: notice stays within the viewport`); - assert.ok(bounds.width > 0 && bounds.height > 0, `${context}: notice remains visible`); - assert.ok(close.left >= bounds.left && close.right <= bounds.right, - `${context}: dismiss control remains inside the notice`); - assert.strictEqual(frame.contentWindow.getComputedStyle(notice).opacity, '1', - `${context}: notice remains opaque and readable`); - } + for (let headingLayout of ['header clearfix', 'clearfix']) { + let frame = await growlFrame(asset, direction, headingLayout); + let page = frame.contentDocument; + let notice = page.querySelector('.jGrowl-notification'); + + for (let width of [1440, 375, 280]) { + frame.style.width = `${width}px`; + await new Promise((resolve) => frame.contentWindow.requestAnimationFrame(resolve)); + + let navbar = page.querySelector('nav.navbar').getBoundingClientRect(); + let bounds = notice.getBoundingClientRect(); + let close = page.querySelector('.jGrowl-close').getBoundingClientRect(); + let main = page.querySelector('main').getBoundingClientRect(); + let title = page.querySelector('main h1').getBoundingClientRect(); + let action = page.querySelector('main .btn').getBoundingClientRect(); + let headerActions = page.querySelector('main .pull-right').getBoundingClientRect(); + let context = `${asset} ${headingLayout} ${width}px`; - frame.remove(); + assert.ok(bounds.top >= navbar.bottom, `${context}: notice starts below the navbar`); + assert.ok(bounds.left >= -1 && bounds.right <= width + 1, + `${context}: notice stays within the viewport`); + assert.ok(bounds.bottom <= frame.contentWindow.innerHeight + 1, + `${context}: notice stays above the viewport bottom`); + assert.ok(bounds.width > 0 && bounds.height > 0, `${context}: notice remains visible`); + assert.ok(close.left >= bounds.left && close.right <= bounds.right, + `${context}: dismiss control remains inside the notice`); + assert.ok(close.top >= bounds.top && close.bottom <= bounds.bottom, + `${context}: dismiss control stays within the notice vertically`); + assert.strictEqual(frame.contentWindow.getComputedStyle(notice).opacity, '1', + `${context}: notice remains opaque and readable`); + + assert.ok(main.top >= bounds.bottom, + `${context}: main content starts below the notice`); + assert.ok(title.top >= bounds.bottom, + `${context}: page title starts below the notice`); + assert.ok(action.top >= bounds.bottom, + `${context}: page action starts below the notice`); + assert.ok(headerActions.top >= bounds.bottom && headerActions.right <= width + 1, + `${context}: header actions clear the notice and fit the viewport`); + } + + frame.remove(); + } } } });