diff --git a/README.md b/README.md index 2ed77beb5b..c0c6ab3b95 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.156`. It retains the existing Node 24, Ember, Sass, +The current source compatibility target is `1.6.157`. 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,11 +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.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.157` mounts authenticated-page notices when their component enters +the DOM. This covers the fresh direct-URL denial missed by `1.6.156`'s route +render callback while keeping login/MFA notices on the body. The focused source +tests pass; packaged Server/browser acceptance is still pending. See the +[release note](docs/releases/web-console-1.6.157.md). + +Release `1.6.156` introduced the in-flow authenticated notice layout, but +Server v1.6.490 QA found that a fresh readonly direct create URL still left +the notice fixed on the body and overlapping header actions. An in-app +transition did mount it correctly. See the +[1.6.156 release note](docs/releases/web-console-1.6.156.md). Release `1.6.155` moved global notices below the navigation bar and bounded their width. QA 8080 browser acceptance subsequently found that the fixed diff --git a/app/authenticated/route.js b/app/authenticated/route.js index 746f79e0bb..9c9c860f4f 100644 --- a/app/authenticated/route.js +++ b/app/authenticated/route.js @@ -38,7 +38,6 @@ export function projectIdFromTransition(transition) { export default Route.extend(Subscribe, PromiseToCb, { catalog : service(), - growl : service(), prefs : service(), projects : service(), settings : service(), @@ -159,12 +158,6 @@ 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(); @@ -184,9 +177,6 @@ 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 4006c97478..ccb92af65a 100644 --- a/app/authenticated/template.hbs +++ b/app/authenticated/template.hbs @@ -7,7 +7,7 @@ goToPrevious="goToPrevious" }} {{/unless}} -
+{{growl-mount}}
{{#if (and this.forceUpgrade this.projects.current.needsUpgrade)}} {{project-upgrade model=this.projects.current}} diff --git a/app/components/growl-mount/component.js b/app/components/growl-mount/component.js new file mode 100644 index 0000000000..e900464471 --- /dev/null +++ b/app/components/growl-mount/component.js @@ -0,0 +1,20 @@ +import Component from '@ember/component'; +import { service } from '@ember/service'; + +export default Component.extend({ + elementId: 'growl-mount', + growl: service(), + + didInsertElement() { + this._super(...arguments); + this.get('growl').placeContainer(this.element); + }, + + willDestroyElement() { + let container = document.getElementById('jGrowl'); + if (container && container.parentNode === this.element) { + this.get('growl').placeContainer(document.body); + } + this._super(...arguments); + }, +}); 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 1ea9515f9c..f2c7fde1ee 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.156", + "version": "1.6.157", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@pasturestack/web-console", - "version": "1.6.156", + "version": "1.6.157", "license": "Apache-2.0", "dependencies": { "sass": "1.103.1" diff --git a/docs/releases/web-console-1.6.156.md b/docs/releases/web-console-1.6.156.md index 60855d28c8..7f2a8b5443 100644 --- a/docs/releases/web-console-1.6.156.md +++ b/docs/releases/web-console-1.6.156.md @@ -23,3 +23,12 @@ 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. + +The subsequent isolated Server v1.6.490 / Web Console 1.6.156 QA run did not +accept the layout. On a fresh readonly direct Stack create URL at 1440px, the +denial notice remained fixed under `BODY` despite an empty `#growl-mount` and +overlapped the right-side header actions. A same-page client-side transition +did move the notice into the mount and clear the actions. The route's deferred +mount callback therefore missed the fresh-load case; `1.6.157` moves the +placement to the mount component's insertion lifecycle. This note records the +packaged result separately from the earlier source and local-browser checks. diff --git a/docs/releases/web-console-1.6.157.md b/docs/releases/web-console-1.6.157.md new file mode 100644 index 0000000000..f0f92ed74b --- /dev/null +++ b/docs/releases/web-console-1.6.157.md @@ -0,0 +1,25 @@ +# Web Console 1.6.157 + +Server v1.6.490 packaged Web Console 1.6.156. In isolated 8080 browser QA, a +readonly user opening `/apps/stacks/add` directly on a fresh page received +the intended permission denial, but the notice remained fixed under `BODY`. +The authenticated template contained an empty `#growl-mount`, and the notice +overlapped the right-side header actions at 1440px (notice y55–136; actions +y65–97). A client-side transition on the same page moved the notice into the +mount and cleared the actions. +Thus `1.6.156` did not pass packaged browser acceptance for this path. + +The route's deferred render callback could miss the mount on a fresh load. +The authenticated template now renders a `growl-mount` component that moves +the existing jGrowl container when its element enters the DOM. On teardown it +returns the container to `BODY` only if that component still owns it. This +preserves the running plugin instance and keeps login/MFA notices on their +existing body host. The change does not alter route permission checks, API +authorization, notice copy, or request payloads. + +`npm test -- --filter='growl'` passed 7/7 Chrome 153 QUnit tests, covering +notice placement, component insertion and teardown, delete notices, and layout. +The layout test covers light and dark themes, LTR and RTL, English, Traditional +Chinese, and Japanese, at 1440px, 375px, and 280px. These checks use the source +build. A newly packaged Server image and fresh direct-URL QA 8080 acceptance +remain pending. diff --git a/package-lock.json b/package-lock.json index 1ea9515f9c..f2c7fde1ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@pasturestack/web-console", - "version": "1.6.156", + "version": "1.6.157", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@pasturestack/web-console", - "version": "1.6.156", + "version": "1.6.157", "license": "Apache-2.0", "dependencies": { "sass": "1.103.1" diff --git a/package.json b/package.json index 84d5510bfe..8046cc08c8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@pasturestack/web-console", - "version": "1.6.156", + "version": "1.6.157", "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 7eca9d616c..16f66c2a69 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.156" ]]; then - echo "UNEXPECTED_UI_ARTIFACT_VERSION version=$version expected=1.6.156" +if [[ "$version" != "1.6.157" ]]; then + echo "UNEXPECTED_UI_ARTIFACT_VERSION version=$version expected=1.6.157" failures=$((failures + 1)) fi diff --git a/scripts/check-ui-console-workspace b/scripts/check-ui-console-workspace index d5051eb217..de1c0b2a1f 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.156 browser-session broker-broadcast + 1.6.157 browser-session broker-broadcast diff --git a/scripts/check-ui-critical-high-dependencies b/scripts/check-ui-critical-high-dependencies index 6c5be3fa5f..e7bc6be960 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.156": +if package.get("version") != "1.6.157": 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/integration/components/growl-mount-test.js b/tests/integration/components/growl-mount-test.js new file mode 100644 index 0000000000..1a3795a547 --- /dev/null +++ b/tests/integration/components/growl-mount-test.js @@ -0,0 +1,73 @@ +import { module, test } from 'qunit'; +import $ from 'jquery'; +import { clearRender, render, setupContext, setupRenderingContext, teardownContext } from '@ember/test-helpers'; +import { precompileTemplate } from '@ember/template-compilation'; +import resolver from '../../helpers/resolver'; +import { initialize as initializePodLayouts } from 'ui/initializers/pod-component-layouts'; + +module('Integration | Component | growl mount', function(hooks) { + hooks.beforeEach(async function() { + this.testRoot = document.createElement('div'); + this.testRoot.id = 'ember-testing'; + document.body.appendChild(this.testRoot); + await setupContext(this, {resolver}); + initializePodLayouts(); + await setupRenderingContext(this); + }); + + hooks.afterEach(async function() { + await teardownContext(this); + let container = document.getElementById('jGrowl'); + if (container) { + $(container).jGrowl('shutdown'); + container.remove(); + } + this.testRoot.remove(); + }); + + test('moves an early denial into the rendered mount and back for login or MFA', async function(assert) { + let growl = this.owner.lookup('service:growl'); + growl.error('Action unavailable', 'No create permission'); + let container = document.getElementById('jGrowl'); + let instance = $(container).data('jGrowl.instance'); + assert.strictEqual(container.parentNode, document.body, + 'a denial raised before the authenticated template renders starts on the body'); + + await render(precompileTemplate('{{growl-mount}}')); + let mount = document.getElementById('growl-mount'); + assert.strictEqual(container.parentNode, mount, + 'the component moves the existing notice when the mount actually enters the DOM'); + assert.strictEqual(getComputedStyle(container).position, 'static', + 'authenticated notices reserve space above main content'); + assert.strictEqual($(container).data('jGrowl.instance'), instance, + 'the running plugin instance is preserved'); + + await clearRender(); + assert.strictEqual(container.parentNode, document.body, + 'leaving the authenticated view returns the notice to the body'); + assert.strictEqual(getComputedStyle(container).position, 'fixed', + 'login and MFA retain the global notice position'); + growl.raw('Login error', 'Try again'); + assert.strictEqual(container.parentNode, document.body, + 'login and MFA notices remain outside the authenticated mount'); + assert.strictEqual($(container).data('jGrowl.instance'), instance, + 'the same plugin instance remains active'); + }); + + test('destroying an old mount does not reclaim a container moved elsewhere', async function(assert) { + let growl = this.owner.lookup('service:growl'); + growl.error('Action unavailable', 'No create permission'); + await render(precompileTemplate('{{growl-mount}}')); + let container = document.getElementById('jGrowl'); + let nextMount = document.createElement('div'); + document.body.appendChild(nextMount); + + growl.placeContainer(nextMount); + await clearRender(); + assert.strictEqual(container.parentNode, nextMount, + 'the old mount leaves another destination in control of the plugin'); + + growl.placeContainer(document.body); + nextMount.remove(); + }); +}); diff --git a/tests/unit/vendor/growl-viewport-layout-test.js b/tests/unit/vendor/growl-viewport-layout-test.js index d746a774a7..d437819291 100644 --- a/tests/unit/vendor/growl-viewport-layout-test.js +++ b/tests/unit/vendor/growl-viewport-layout-test.js @@ -2,6 +2,17 @@ import { module, test } from 'qunit'; module('Unit | Vendor | Growl viewport layout'); +const copy = { + 'zh-tw': {title: '使用者堆疊', action: '狀態', + header: '無法執行此動作', message: '您沒有權限在此環境中建立此資源。'}, + 'en-us': {title: 'User Stacks', action: 'State', + header: 'Action unavailable', + message: 'You do not have permission to create this resource in this environment.'}, + 'ja-jp': {title: 'ユーザースタック', action: '状態', + header: '操作できません', + message: 'この環境でこのリソースを作成する権限がありません。'}, +}; + function growlFrame(asset, direction, headingLayout) { return new Promise((resolve, reject) => { let frame = document.createElement('iframe'); @@ -47,37 +58,51 @@ test('global notices clear the navbar and page titles and fit the viewport', asy for (let width of [1440, 375, 280]) { frame.style.width = `${width}px`; await new Promise((resolve) => frame.contentWindow.requestAnimationFrame(resolve)); + for (let locale of Object.keys(copy)) { + page.documentElement.lang = locale; + page.querySelector('.jGrowl-header').textContent = copy[locale].header; + page.querySelector('.jGrowl-message').textContent = copy[locale].message; + page.querySelector('main h1').textContent = copy[locale].title; + page.querySelector('main .pull-right button').textContent = copy[locale].action; - 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`; + let navbar = page.querySelector('nav.navbar').getBoundingClientRect(); + let mount = page.querySelector('#growl-mount').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} ${locale} ${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.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.strictEqual(notice.parentElement.parentElement.id, 'growl-mount', + `${context}: notice is inside the authenticated mount`); + assert.strictEqual(frame.contentWindow.getComputedStyle(notice.parentElement).position, + 'static', `${context}: the notice container participates in page layout`); + assert.ok(mount.bottom >= bounds.bottom, + `${context}: the mount reserves the notice height`); + 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`); + 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();