From ead1ba6ef3b163bd3270231a11ed587ee76109ca Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Thu, 11 Jun 2026 19:16:48 +0200 Subject: [PATCH 01/14] [OGUI-1906] Add copy button and hover-to-persist behaviour Notifications now pause auto-hide while hovered, so users have time to read longer or normal message at their leisure. Added a copy-to-clipboard button inside the notification, flush with the right edge, that copies the message and displays a new "Text copied to clipboard" notification when clicked. --- Framework/Frontend/css/src/bootstrap.css | 6 ++-- Framework/Frontend/js/src/Notification.js | 34 +++++++++++++++++++++-- 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/Framework/Frontend/css/src/bootstrap.css b/Framework/Frontend/css/src/bootstrap.css index c5415d6556..f7adb68f5a 100644 --- a/Framework/Frontend/css/src/bootstrap.css +++ b/Framework/Frontend/css/src/bootstrap.css @@ -359,8 +359,10 @@ label { display: block; margin-bottom: 0.5em; } .notification { position: absolute; top: 2em; width: 100%; text-align: center; cursor: pointer; pointer-events: none; } .notification-content { transition: opacity 0.8s cubic-bezier(0.18, 0.89, 0.34, 1.11), filter 0.8s cubic-bezier(0.18, 0.89, 0.34, 1.11); } -.notification-open { display: inline; opacity: 1; filter: blur(0); pointer-events: all; } -.notification-close { display: inline; opacity: 0; filter: blur(5em); pointer-events: none; } +.notification-open { display: inline-flex; opacity: 1; filter: blur(0); pointer-events: all; } +.notification-close { display: inline-flex; opacity: 0; filter: blur(5em); pointer-events: none; } + /* border radius none on the left keep on right */ +.notification .btn { border-radius: 0 0.25em 0.25em 0; } /* Basic table */ diff --git a/Framework/Frontend/js/src/Notification.js b/Framework/Frontend/js/src/Notification.js index b70ce520eb..3ec97bcef8 100644 --- a/Framework/Frontend/js/src/Notification.js +++ b/Framework/Frontend/js/src/Notification.js @@ -15,6 +15,9 @@ import Observable from './Observable.js'; import { h } from './renderer.js'; import switchCase from './switchCase.js'; +import { iconClipboard } from './icons.js'; + +const COPY_CONFIRMATION = 'Text copied to clipboard'; /** * Container of notification with time management @@ -49,6 +52,8 @@ export class Notification extends Observable { this.type = 'primary'; this.state = 'hidden'; // Shown, hidden this.timerId = 0; // Timer to auto-hide notification + this.duration = 5000; // Original duration of the current notification + this.hovered = false; // Whether the notification is hovered } /** @@ -76,11 +81,14 @@ export class Notification extends Observable { this.message = message; this.type = type; this.state = 'shown'; + this.duration = duration; // Auto-hide after duration if (duration !== Infinity) { this.timerId = setTimeout(() => { - this.hide(); + if (!this.hovered) { + this.hide(); + } }, duration); } @@ -124,13 +132,33 @@ export class Notification extends Observable { */ export const notification = (notificationInstance) => h('.notification.text-no-select.level4.text-light', { -}, h('span.notification-content.br2.p2.shadow-level4', { +}, h('div.notification-content.br2.shadow-level4', { // ClassName: notificationInstance.message && (notificationInstance.state === 'shown' ? 'notification-open' : 'notification-close'), onclick: () => notificationInstance.hide(), + onmouseenter: () => { + notificationInstance.hovered = true; + }, + onmouseleave: () => { + notificationInstance.hovered = false; + // If this is not present then will show again when clicked close because of mouseLeave event + if (notificationInstance.state === 'shown') { + notificationInstance.show(notificationInstance.message, notificationInstance.type, notificationInstance.duration); + } + }, className: `${switchCase(notificationInstance.type, { primary: 'white bg-primary', success: 'white bg-success', warning: 'white bg-warning', danger: 'white bg-danger', })} ${notificationInstance.state === 'shown' ? 'notification-open' : 'notification-close'}`, -}, notificationInstance.message)); +}, [ + h('div.mh2.pv2', notificationInstance.message), + notificationInstance.message !== COPY_CONFIRMATION && h(`button.btn.btn-${notificationInstance.type}.br0`, { + title: 'Copy to clipboard', + onclick: (e) => { + e.stopPropagation(); + navigator.clipboard.writeText(notificationInstance.message) + .then(() => notificationInstance.show(COPY_CONFIRMATION, notificationInstance.type, 1500)); + }, + }, iconClipboard()), +])); From ee34c912d2dde86993cf5eed447ad27c54be7fc4 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Thu, 11 Jun 2026 19:22:10 +0200 Subject: [PATCH 02/14] [OGUI-1906] Move onClick hide to more specific div --- Framework/Frontend/js/src/Notification.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Framework/Frontend/js/src/Notification.js b/Framework/Frontend/js/src/Notification.js index 3ec97bcef8..fd573ec4a5 100644 --- a/Framework/Frontend/js/src/Notification.js +++ b/Framework/Frontend/js/src/Notification.js @@ -134,7 +134,6 @@ export const notification = (notificationInstance) => h('.notification.text-no-s }, h('div.notification-content.br2.shadow-level4', { // ClassName: notificationInstance.message && (notificationInstance.state === 'shown' ? 'notification-open' : 'notification-close'), - onclick: () => notificationInstance.hide(), onmouseenter: () => { notificationInstance.hovered = true; }, @@ -152,7 +151,7 @@ export const notification = (notificationInstance) => h('.notification.text-no-s danger: 'white bg-danger', })} ${notificationInstance.state === 'shown' ? 'notification-open' : 'notification-close'}`, }, [ - h('div.mh2.pv2', notificationInstance.message), + h('div.mh2.pv2', { onclick: () => notificationInstance.hide() }, notificationInstance.message), notificationInstance.message !== COPY_CONFIRMATION && h(`button.btn.btn-${notificationInstance.type}.br0`, { title: 'Copy to clipboard', onclick: (e) => { From 9d9f1c49878efda76e63203780ee8ec7bfaa0cc7 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Thu, 11 Jun 2026 19:25:39 +0200 Subject: [PATCH 03/14] [OGUI-1906] Add better CSS selector --- Framework/Frontend/css/src/bootstrap.css | 3 +-- Framework/Frontend/js/src/Notification.js | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/Framework/Frontend/css/src/bootstrap.css b/Framework/Frontend/css/src/bootstrap.css index f7adb68f5a..a5b256474d 100644 --- a/Framework/Frontend/css/src/bootstrap.css +++ b/Framework/Frontend/css/src/bootstrap.css @@ -361,8 +361,7 @@ label { display: block; margin-bottom: 0.5em; } .notification-content { transition: opacity 0.8s cubic-bezier(0.18, 0.89, 0.34, 1.11), filter 0.8s cubic-bezier(0.18, 0.89, 0.34, 1.11); } .notification-open { display: inline-flex; opacity: 1; filter: blur(0); pointer-events: all; } .notification-close { display: inline-flex; opacity: 0; filter: blur(5em); pointer-events: none; } - /* border radius none on the left keep on right */ -.notification .btn { border-radius: 0 0.25em 0.25em 0; } +.notification-copy-btn { border-radius: 0 0.25em 0.25em 0; } /* Basic table */ diff --git a/Framework/Frontend/js/src/Notification.js b/Framework/Frontend/js/src/Notification.js index fd573ec4a5..ca92d1e04c 100644 --- a/Framework/Frontend/js/src/Notification.js +++ b/Framework/Frontend/js/src/Notification.js @@ -152,7 +152,7 @@ export const notification = (notificationInstance) => h('.notification.text-no-s })} ${notificationInstance.state === 'shown' ? 'notification-open' : 'notification-close'}`, }, [ h('div.mh2.pv2', { onclick: () => notificationInstance.hide() }, notificationInstance.message), - notificationInstance.message !== COPY_CONFIRMATION && h(`button.btn.btn-${notificationInstance.type}.br0`, { + notificationInstance.message !== COPY_CONFIRMATION && h(`button.btn.btn-${notificationInstance.type}.notification-copy-btn`, { title: 'Copy to clipboard', onclick: (e) => { e.stopPropagation(); From eade478549cc26da9f8945ca8f38e474338fae06 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Wed, 17 Jun 2026 23:09:33 +0200 Subject: [PATCH 04/14] [OGUI-1906] Add tests to test added functionality of notification Adds tests for pause-on-hover and copy functionality. Adjusts queryRouter cllass test so that `window.notification` isn't being overwritten making notification class less simple to test, --- Framework/Frontend/test/mocha-index.js | 220 ++++++++++++++++++++++--- 1 file changed, 196 insertions(+), 24 deletions(-) diff --git a/Framework/Frontend/test/mocha-index.js b/Framework/Frontend/test/mocha-index.js index 3286ec25f7..c933419bda 100644 --- a/Framework/Frontend/test/mocha-index.js +++ b/Framework/Frontend/test/mocha-index.js @@ -220,49 +220,221 @@ describe('Framework Frontend', function() { describe('QueryRouter class', function() { it('notifices when route has changed', async () => { await page.evaluate(() => { - window.notification = ''; + window.routedPage = ''; const router = new QueryRouter(); router.observe(() => { - window.notification = router.params.page; + window.routedPage = router.params.page; }); router.go('?page=list', true); // replace current URL to avoid loosing Framework injection window.router = router; // save for later use in tests }); - await page.waitForFunction(`window.notification === 'list'`); + await page.waitForFunction(`window.routedPage === 'list'`); }); }); describe('Notification class', function() { - before('can be instanciated', async () => { + before('can be instantiated', async () => { await page.evaluate(() => { - window.notification = new Notification(); + window.notificationModel = new Notification(); + }); + }); + + beforeEach(async () => { + await page.evaluate(() => { + window.notificationModel.hide(); }); }); it('is hidden at first', async () => { - // await page.evaluate(() => { - // window.notification = ''; - // const router = new QueryRouter(); - // router.observe(() => { - // window.notification = router.params.page; - // }); - // router.go('?page=list', true); // replace current URL to avoid loosing Framework injection - // window.router = router; // save for later use in tests - // }); - await page.waitForFunction(`window.notification.state === 'hidden'`); - }); - - it('is shown on new success message', async () => { + await page.waitForFunction(`window.notificationModel.state === 'hidden'`); + }); + + it('show() sets the notification state to shown', async () => { + await page.evaluate(() => { + window.notificationModel.show('Warp Drive Mr. Scott', 'success', 4000); + }); + await page.waitForFunction(`window.notificationModel.state === 'shown'`); + }); + + it('hide() sets the notification state to hidden', async () => { + await page.evaluate(() => { + window.notificationModel.show('Bye', 'primary', 10000); + window.notificationModel.hide(); + }); + await page.waitForFunction(`window.notificationModel.state === 'hidden'`); + }); + + it('automatically hides after the duration is reached', async () => { + await page.evaluate(() => { + window.notificationModel.show('Warp Drive Mr. Scott', 'success', 500); + }); + + await new Promise((resolve) => setTimeout(resolve, 1000)); + await page.waitForFunction(`window.notificationModel.state === 'hidden'`); + }); + + it('falls back to a 5000ms duration when none is given', async () => { + await page.evaluate(() => { + window.notificationModel.show('hi', 'success'); + }); + await page.waitForFunction(`window.notificationModel.duration === 5000`); + }); + + it('stores Infinity as the duration when given', async () => { + await page.evaluate(() => { + window.notificationModel.show('fatal error', 'danger', Infinity); + }); + await page.waitForFunction(`window.notificationModel.duration === Infinity`); + }); + + it('does not automatically hide while the hovered attribute is true', async () => { + await page.evaluate(() => { + window.notificationModel.show('hi', 'success', 1000); + window.notificationModel.hovered = true; + }); + + await new Promise((resolve) => setTimeout(resolve, 1500)); + await page.waitForFunction(`window.notificationModel.state === 'shown'`); + }); + + it('throws on an unknown type', async () => { + await assert.rejects( + page.evaluate(() => window.notificationModel.show('bad type', 'unknown', 1000)), + (err) => err.message.includes('Notification type must be danger, warning, success or primary. "unknown" provided') + ); + }); + + it('ignores empty message notifications', async () => { + await page.evaluate(() => { + window.notificationModel.hide(); + window.notificationModel.show('', 'success', 1000); + }); + await page.waitForFunction(`window.notificationModel.state === 'hidden'`); + }); + }); + + describe('Notification View', function() { + before(async () => { + await page.evaluate(() => { + window.notificationModel = new Notification(); + mount(document.body, (model) => notification(model), window.notificationModel); + }); + }); + + beforeEach(async () => { + await page.evaluate(() => { + window.notificationModel.hide(); + }); + }); + + it('renders with notification-close class when hidden', async () => { + await page.waitForFunction(`document.querySelector('.notification-content.notification-close') !== null`); + await page.waitForFunction(`document.querySelector('.notification-content.notification-open') === null`); + }); + + it('renders with notification-open class when shown', async () => { + await page.evaluate(() => { + window.notificationModel.show('Warp Drive Mr. Scott', 'success', 4000); + }); + await page.waitForFunction(`document.querySelector('.notification-content.notification-open') !== null`); + await page.waitForFunction(`document.querySelector('.notification-content.notification-close') === null`); + }); + + it('mouseenter sets hovered to true and prevents auto-hide', async () => { + await page.evaluate(() => { + window.notificationModel.show('Warp Drive Mr. Scott', 'success', 1000); + const el = document.querySelector('.notification-content'); + el.dispatchEvent(new MouseEvent('mouseenter')); + }); + await page.waitForFunction(`window.notificationModel.hovered === true`); + + await new Promise((resolve) => setTimeout(resolve, 1500)); + await page.waitForFunction(`window.notificationModel.state === 'shown'`); + }); + + it('mouseleave sets hovered to false and re-calls show() with the original duration', async () => { + const calls = await page.evaluate(() => { + window.notificationModel.show('Warp Drive Mr. Scott', 'success', 1500); + + const captured = []; + const originalShow = window.notificationModel.show; + + // spy on show() + window.notificationModel.show = function(message, type, duration) { + captured.push({message, type, duration}); + return originalShow.call(this, message, type, duration); + }; + + const el = document.querySelector('.notification-content'); + el.dispatchEvent(new MouseEvent('mouseenter')); + el.dispatchEvent(new MouseEvent('mouseleave')); + + // Restore original show() method + window.notificationModel.show = originalShow; + + return captured; + }); + + await page.waitForFunction(`window.notificationModel.hovered === false`); + assert.deepStrictEqual(calls, [{message: 'Warp Drive Mr. Scott', type: 'success', duration: 1500}]); + }); + + it('mouseleave while hidden does not re-show the notification', async () => { + const calls = await page.evaluate(() => { + const captured = []; + const originalShow = window.notificationModel.show; + window.notificationModel.show = function(message, type, duration) { + captured.push({message, type, duration}); + return originalShow.call(this, message, type, duration); + }; + + const el = document.querySelector('.notification-content'); + el.dispatchEvent(new MouseEvent('mouseleave')); + + window.notificationModel.show = originalShow; + return captured; + }); + + await page.waitForFunction(`window.notificationModel.state === 'hidden'`); + assert.deepStrictEqual(calls, []); + }); + + it('clicking the message hides the notification', async () => { await page.evaluate(() => { - window.notification.show('Warp Drive Mr. Scott', 'success', 4000); + window.notificationModel.show('Click me to close', 'primary', 1000); + document.querySelector('.notification-content .mh2.pv2').click(); }); - await page.waitForFunction(`window.notification.state === 'shown'`); - await page.waitForFunction(`window.notification.type === 'success'`); + await page.waitForFunction(`window.notificationModel.state === 'hidden'`); }); - it('stays for 4 seconds', async () => { - await new Promise((resolve) => setTimeout(resolve, 4000)); - await page.waitForFunction(`window.notification.state === 'hidden'`); + it('copy confirmation message suppresses its own copy button', async () => { + await page.evaluate(() => { + window.notificationModel.show('Text copied to clipboard', 'success', 1000); + }); + await page.waitForFunction(`document.querySelector('.notification-copy-btn') === null`); + }); + + it('copy button copies to clipboard and shows the confirmation', async () => { + await page.evaluate(() => { + Object.defineProperty(navigator, 'clipboard', { + value: { + writeText: (value) => { + window.__copiedContextMenuValue = value; + return Promise.resolve(); + }, + }, + configurable: true, + }); + window.notificationModel.show('Secret message', 'success', 5000); + }); + await page.waitForFunction(`document.querySelector('.notification-copy-btn') !== null`); + + await page.evaluate(() => { + document.querySelector('.notification-copy-btn').click(); + }); + + await page.waitForFunction(`window.__copiedContextMenuValue === 'Secret message'`); + await page.waitForFunction(`window.notificationModel.message === 'Text copied to clipboard'`); }); }); From 4ca1cfb0979e5450a0f3c035f50f47fe47cce7de Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Thu, 18 Jun 2026 00:14:14 +0200 Subject: [PATCH 05/14] [OGUI-1906] Remove unnecessary new notifications Copy state now lives within the copy button through use of different icons and notification model attributes. Adjust tests to the functionality. Restructure tests to be one describe with 2 child blocks. --- Framework/Frontend/js/src/Notification.js | 83 ++++-- Framework/Frontend/test/mocha-index.js | 341 ++++++++++++---------- 2 files changed, 260 insertions(+), 164 deletions(-) diff --git a/Framework/Frontend/js/src/Notification.js b/Framework/Frontend/js/src/Notification.js index ca92d1e04c..028a8eb53e 100644 --- a/Framework/Frontend/js/src/Notification.js +++ b/Framework/Frontend/js/src/Notification.js @@ -15,9 +15,7 @@ import Observable from './Observable.js'; import { h } from './renderer.js'; import switchCase from './switchCase.js'; -import { iconClipboard } from './icons.js'; - -const COPY_CONFIRMATION = 'Text copied to clipboard'; +import { iconClipboard, iconCheck, iconCircleX } from './icons.js'; /** * Container of notification with time management @@ -51,9 +49,11 @@ export class Notification extends Observable { this.message = ''; this.type = 'primary'; this.state = 'hidden'; // Shown, hidden - this.timerId = 0; // Timer to auto-hide notification + this.hideTimerId = null; // Timer to auto-hide notification this.duration = 5000; // Original duration of the current notification this.hovered = false; // Whether the notification is hovered + this.copyState = 'idle'; // 'idle' | 'copied' | 'failed' — state for the copy button + this.copyTimerId = null; // Timer to reset copyState to 'idle' after copy } /** @@ -75,17 +75,19 @@ export class Notification extends Observable { duration = duration || 5000; - // Clear previous message countdown - clearTimeout(this.timerId); + // Clear previous message countdowns + clearTimeout(this.hideTimerId); + clearTimeout(this.copyTimerId); this.message = message; this.type = type; this.state = 'shown'; this.duration = duration; + this.copyState = 'idle'; // Auto-hide after duration if (duration !== Infinity) { - this.timerId = setTimeout(() => { + this.hideTimerId = setTimeout(() => { if (!this.hovered) { this.hide(); } @@ -100,10 +102,53 @@ export class Notification extends Observable { * (by a click on notification for example) */ hide() { - clearTimeout(this.timerId); + clearTimeout(this.hideTimerId); + clearTimeout(this.copyTimerId); this.state = 'hidden'; + this.copyState = 'idle'; + + this.notify(); + } + + /** + * Restart the auto-hide countdown for the currently shown notification, reusing the + * stored duration. + * No-op when the notification is hidden or duration is Infinity. + */ + restartHideTimer() { + if (this.state !== 'shown') { + return; + } + clearTimeout(this.hideTimerId); + if (this.duration !== Infinity) { + this.hideTimerId = setTimeout(() => { + if (!this.hovered) { + this.hide(); + } + }, this.duration); + } + } + /** + * Copy the current message to the clipboard and flash `copyState` to `'copied'` for 1.5s. + * If the clipboard write rejects, `copyState` flashes to `'failed'` instead. + * @return {Promise} + */ + async copy() { + let nextState; + try { + await navigator.clipboard.writeText(this.message); + nextState = 'copied'; + } catch { + nextState = 'failed'; + } + clearTimeout(this.copyTimerId); + this.copyState = nextState; this.notify(); + this.copyTimerId = setTimeout(() => { + this.copyState = 'idle'; + this.notify(); + }, 1500); } } @@ -139,10 +184,7 @@ export const notification = (notificationInstance) => h('.notification.text-no-s }, onmouseleave: () => { notificationInstance.hovered = false; - // If this is not present then will show again when clicked close because of mouseLeave event - if (notificationInstance.state === 'shown') { - notificationInstance.show(notificationInstance.message, notificationInstance.type, notificationInstance.duration); - } + notificationInstance.restartHideTimer(); }, className: `${switchCase(notificationInstance.type, { primary: 'white bg-primary', @@ -152,12 +194,19 @@ export const notification = (notificationInstance) => h('.notification.text-no-s })} ${notificationInstance.state === 'shown' ? 'notification-open' : 'notification-close'}`, }, [ h('div.mh2.pv2', { onclick: () => notificationInstance.hide() }, notificationInstance.message), - notificationInstance.message !== COPY_CONFIRMATION && h(`button.btn.btn-${notificationInstance.type}.notification-copy-btn`, { - title: 'Copy to clipboard', + h(`button.btn.btn-${notificationInstance.type}.notification-copy-btn`, { + title: switchCase(notificationInstance.copyState, { + copied: 'Copied!', + failed: "Couldn't copy", + idle: 'Copy to clipboard', + }), onclick: (e) => { e.stopPropagation(); - navigator.clipboard.writeText(notificationInstance.message) - .then(() => notificationInstance.show(COPY_CONFIRMATION, notificationInstance.type, 1500)); + notificationInstance.copy(); }, - }, iconClipboard()), + }, switchCase(notificationInstance.copyState, { + copied: iconCheck(), + failed: iconCircleX(), + idle: iconClipboard(), + })), ])); diff --git a/Framework/Frontend/test/mocha-index.js b/Framework/Frontend/test/mocha-index.js index c933419bda..6ed350a2e6 100644 --- a/Framework/Frontend/test/mocha-index.js +++ b/Framework/Frontend/test/mocha-index.js @@ -232,209 +232,256 @@ describe('Framework Frontend', function() { }); }); - describe('Notification class', function() { - before('can be instantiated', async () => { + describe('Notification', function() { + beforeEach(async () => { await page.evaluate(() => { - window.notificationModel = new Notification(); + window.__clipboardText = ''; + Object.defineProperty(navigator, 'clipboard', { + value: { + writeText: (value) => { + window.__clipboardText = value; + return Promise.resolve(); + }, + }, + configurable: true, + }); }); }); - beforeEach(async () => { - await page.evaluate(() => { - window.notificationModel.hide(); + describe('Class', function() { + before('create instance', async () => { + await page.evaluate(() => { + window.notificationModel = new Notification(); + }); }); - }); - it('is hidden at first', async () => { - await page.waitForFunction(`window.notificationModel.state === 'hidden'`); - }); + beforeEach(async () => { + await page.evaluate(() => { + window.notificationModel.hide(); + }); + }); - it('show() sets the notification state to shown', async () => { - await page.evaluate(() => { - window.notificationModel.show('Warp Drive Mr. Scott', 'success', 4000); + it('is hidden at first', async () => { + await page.waitForFunction(`window.notificationModel.state === 'hidden'`); }); - await page.waitForFunction(`window.notificationModel.state === 'shown'`); - }); - it('hide() sets the notification state to hidden', async () => { - await page.evaluate(() => { - window.notificationModel.show('Bye', 'primary', 10000); - window.notificationModel.hide(); + it('show() sets the notification state to shown', async () => { + await page.evaluate(() => { + window.notificationModel.show('Warp Drive Mr. Scott', 'success', 4000); + }); + await page.waitForFunction(`window.notificationModel.state === 'shown'`); }); - await page.waitForFunction(`window.notificationModel.state === 'hidden'`); - }); - it('automatically hides after the duration is reached', async () => { - await page.evaluate(() => { - window.notificationModel.show('Warp Drive Mr. Scott', 'success', 500); + it('hide() sets the notification state to hidden', async () => { + await page.evaluate(() => { + window.notificationModel.show('Bye', 'primary', 10000); + window.notificationModel.hide(); + }); + await page.waitForFunction(`window.notificationModel.state === 'hidden'`); }); - await new Promise((resolve) => setTimeout(resolve, 1000)); - await page.waitForFunction(`window.notificationModel.state === 'hidden'`); - }); + it('automatically hides after the duration is reached', async () => { + await page.evaluate(() => { + window.notificationModel.show('Warp Drive Mr. Scott', 'success', 500); + }); - it('falls back to a 5000ms duration when none is given', async () => { - await page.evaluate(() => { - window.notificationModel.show('hi', 'success'); + await new Promise((resolve) => setTimeout(resolve, 1000)); + await page.waitForFunction(`window.notificationModel.state === 'hidden'`); }); - await page.waitForFunction(`window.notificationModel.duration === 5000`); - }); - it('stores Infinity as the duration when given', async () => { - await page.evaluate(() => { - window.notificationModel.show('fatal error', 'danger', Infinity); + it('falls back to a 5000ms duration when none is given', async () => { + await page.evaluate(() => { + window.notificationModel.show('hi', 'success'); + }); + await page.waitForFunction(`window.notificationModel.duration === 5000`); }); - await page.waitForFunction(`window.notificationModel.duration === Infinity`); - }); - it('does not automatically hide while the hovered attribute is true', async () => { - await page.evaluate(() => { - window.notificationModel.show('hi', 'success', 1000); - window.notificationModel.hovered = true; + it('stores Infinity as the duration when given', async () => { + await page.evaluate(() => { + window.notificationModel.show('fatal error', 'danger', Infinity); + }); + await page.waitForFunction(`window.notificationModel.duration === Infinity`); }); - await new Promise((resolve) => setTimeout(resolve, 1500)); - await page.waitForFunction(`window.notificationModel.state === 'shown'`); - }); + it('does not automatically hide while the hovered attribute is true', async () => { + await page.evaluate(() => { + window.notificationModel.show('hi', 'success', 1000); + window.notificationModel.hovered = true; + }); - it('throws on an unknown type', async () => { - await assert.rejects( - page.evaluate(() => window.notificationModel.show('bad type', 'unknown', 1000)), - (err) => err.message.includes('Notification type must be danger, warning, success or primary. "unknown" provided') - ); - }); + await new Promise((resolve) => setTimeout(resolve, 1500)); + await page.waitForFunction(`window.notificationModel.state === 'shown'`); + }); - it('ignores empty message notifications', async () => { - await page.evaluate(() => { - window.notificationModel.hide(); - window.notificationModel.show('', 'success', 1000); + it('throws on an unknown type', async () => { + await assert.rejects( + page.evaluate(() => window.notificationModel.show('bad type', 'unknown', 1000)), + (err) => err.message.includes('Notification type must be danger, warning, success or primary. "unknown" provided') + ); }); - await page.waitForFunction(`window.notificationModel.state === 'hidden'`); - }); - }); - describe('Notification View', function() { - before(async () => { - await page.evaluate(() => { - window.notificationModel = new Notification(); - mount(document.body, (model) => notification(model), window.notificationModel); + it('ignores empty message notifications', async () => { + await page.evaluate(() => { + window.notificationModel.hide(); + window.notificationModel.show('', 'success', 1000); + }); + await page.waitForFunction(`window.notificationModel.state === 'hidden'`); }); - }); - beforeEach(async () => { - await page.evaluate(() => { - window.notificationModel.hide(); + it('copy() writes the current message to clipboard and flashes copyState to copied', async () => { + await page.evaluate(async () => { + window.notificationModel.show('Secret message', 'success', 5000); + await window.notificationModel.copy(); + }); + await page.waitForFunction(`window.__clipboardText === 'Secret message'`); + await page.waitForFunction(`window.notificationModel.copyState === 'copied'`); + + await page.waitForFunction(`window.notificationModel.message === 'Secret message'`); + + + await new Promise((resolve) => setTimeout(resolve, 1600)); + await page.waitForFunction(`window.notificationModel.copyState === 'idle'`); }); - }); - it('renders with notification-close class when hidden', async () => { - await page.waitForFunction(`document.querySelector('.notification-content.notification-close') !== null`); - await page.waitForFunction(`document.querySelector('.notification-content.notification-open') === null`); - }); + it('copy() flashes copyState to failed when clipboard rejects, then resets to idle', async () => { + await page.evaluate(async () => { + // Override the mock to simulate a failure + Object.defineProperty(navigator, 'clipboard', { + value: { writeText: () => Promise.reject(new Error('denied')) }, + configurable: true, + }); + window.notificationModel.show('Secret', 'success', 5000); + await window.notificationModel.copy(); + }); + await page.waitForFunction(`window.notificationModel.copyState === 'failed'`); - it('renders with notification-open class when shown', async () => { - await page.evaluate(() => { - window.notificationModel.show('Warp Drive Mr. Scott', 'success', 4000); + await new Promise((resolve) => setTimeout(resolve, 1600)); + await page.waitForFunction(`window.notificationModel.copyState === 'idle'`); }); - await page.waitForFunction(`document.querySelector('.notification-content.notification-open') !== null`); - await page.waitForFunction(`document.querySelector('.notification-content.notification-close') === null`); }); - it('mouseenter sets hovered to true and prevents auto-hide', async () => { - await page.evaluate(() => { - window.notificationModel.show('Warp Drive Mr. Scott', 'success', 1000); - const el = document.querySelector('.notification-content'); - el.dispatchEvent(new MouseEvent('mouseenter')); + describe('View', function() { + before('mount view', async () => { + await page.evaluate(() => { + window.notificationModel = new Notification(); + mount(document.body, (model) => notification(model), window.notificationModel); + }); }); - await page.waitForFunction(`window.notificationModel.hovered === true`); - await new Promise((resolve) => setTimeout(resolve, 1500)); - await page.waitForFunction(`window.notificationModel.state === 'shown'`); - }); + beforeEach(async () => { + await page.evaluate(() => { + window.notificationModel.hide(); + }); + }); - it('mouseleave sets hovered to false and re-calls show() with the original duration', async () => { - const calls = await page.evaluate(() => { - window.notificationModel.show('Warp Drive Mr. Scott', 'success', 1500); + it('renders with notification-close class when hidden', async () => { + await page.waitForFunction(`document.querySelector('.notification-content.notification-close') !== null`); + await page.waitForFunction(`document.querySelector('.notification-content.notification-open') === null`); + }); - const captured = []; - const originalShow = window.notificationModel.show; + it('renders with notification-open class when shown', async () => { + await page.evaluate(() => { + window.notificationModel.show('Warp Drive Mr. Scott', 'success', 4000); + }); + await page.waitForFunction(`document.querySelector('.notification-content.notification-open') !== null`); + await page.waitForFunction(`document.querySelector('.notification-content.notification-close') === null`); + }); - // spy on show() - window.notificationModel.show = function(message, type, duration) { - captured.push({message, type, duration}); - return originalShow.call(this, message, type, duration); - }; + it('mouseenter sets hovered to true and prevents auto-hide', async () => { + await page.evaluate(() => { + window.notificationModel.show('Warp Drive Mr. Scott', 'success', 1000); + const el = document.querySelector('.notification-content'); + el.dispatchEvent(new MouseEvent('mouseenter')); + }); + await page.waitForFunction(`window.notificationModel.hovered === true`); - const el = document.querySelector('.notification-content'); - el.dispatchEvent(new MouseEvent('mouseenter')); - el.dispatchEvent(new MouseEvent('mouseleave')); + await new Promise((resolve) => setTimeout(resolve, 1500)); + await page.waitForFunction(`window.notificationModel.state === 'shown'`); + }); - // Restore original show() method - window.notificationModel.show = originalShow; + it('mouseleave sets hovered to false and restarts the hide timer', async () => { + const callCount = await page.evaluate(() => { + window.notificationModel.show('Warp Drive Mr. Scott', 'success', 1500); - return captured; - }); + // Spy on restartHideTimer() + let restartCalls = 0; + const originalRestart = window.notificationModel.restartHideTimer; + window.notificationModel.restartHideTimer = function() { + restartCalls++; + const before = this.hideTimerId; + const out = originalRestart.call(this); + restartScheduledTimer = this.hideTimerId !== before; + return out; + }; - await page.waitForFunction(`window.notificationModel.hovered === false`); - assert.deepStrictEqual(calls, [{message: 'Warp Drive Mr. Scott', type: 'success', duration: 1500}]); - }); + const el = document.querySelector('.notification-content'); + el.dispatchEvent(new MouseEvent('mouseenter')); + el.dispatchEvent(new MouseEvent('mouseleave')); - it('mouseleave while hidden does not re-show the notification', async () => { - const calls = await page.evaluate(() => { - const captured = []; - const originalShow = window.notificationModel.show; - window.notificationModel.show = function(message, type, duration) { - captured.push({message, type, duration}); - return originalShow.call(this, message, type, duration); - }; + window.notificationModel.restartHideTimer = originalRestart; - const el = document.querySelector('.notification-content'); - el.dispatchEvent(new MouseEvent('mouseleave')); + return {restartCalls, restartScheduledTimer}; + }); - window.notificationModel.show = originalShow; - return captured; + await page.waitForFunction(`window.notificationModel.hovered === false`); + assert.deepStrictEqual(callCount, {restartCalls: 1, restartScheduledTimer: true}); }); - await page.waitForFunction(`window.notificationModel.state === 'hidden'`); - assert.deepStrictEqual(calls, []); - }); + it('mouseleave while hidden does not restart the hide timer', async () => { + const callCount = await page.evaluate(() => { + let restartCalls = 0; + let restartScheduledTimer = false; + const originalRestart = window.notificationModel.restartHideTimer; + window.notificationModel.restartHideTimer = function() { + restartCalls++; + const before = this.hideTimerId; + const out = originalRestart.call(this); + restartScheduledTimer = this.hideTimerId !== before; + return out; + }; - it('clicking the message hides the notification', async () => { - await page.evaluate(() => { - window.notificationModel.show('Click me to close', 'primary', 1000); - document.querySelector('.notification-content .mh2.pv2').click(); + const el = document.querySelector('.notification-content'); + el.dispatchEvent(new MouseEvent('mouseleave')); + + window.notificationModel.restartHideTimer = originalRestart; + return {restartCalls, restartScheduledTimer}; + }); + + await page.waitForFunction(`window.notificationModel.state === 'hidden'`); + // The view still called restartHideTimer, but the model guarded against the hidden state + assert.deepStrictEqual(callCount, {restartCalls: 1, restartScheduledTimer: false}); }); - await page.waitForFunction(`window.notificationModel.state === 'hidden'`); - }); - it('copy confirmation message suppresses its own copy button', async () => { - await page.evaluate(() => { - window.notificationModel.show('Text copied to clipboard', 'success', 1000); + it('clicking the message hides the notification', async () => { + await page.evaluate(() => { + window.notificationModel.show('Click me to close', 'primary', 1000); + document.querySelector('.notification-content .mh2.pv2').click(); + }); + await page.waitForFunction(`window.notificationModel.state === 'hidden'`); }); - await page.waitForFunction(`document.querySelector('.notification-copy-btn') === null`); - }); - it('copy button copies to clipboard and shows the confirmation', async () => { - await page.evaluate(() => { - Object.defineProperty(navigator, 'clipboard', { - value: { - writeText: (value) => { - window.__copiedContextMenuValue = value; - return Promise.resolve(); - }, - }, - configurable: true, + it('renders the copy button while shown', async () => { + await page.evaluate(() => { + window.notificationModel.show('hi', 'success', 5000); }); - window.notificationModel.show('Secret message', 'success', 5000); + await page.waitForFunction(`document.querySelector('.notification-copy-btn') !== null`); }); - await page.waitForFunction(`document.querySelector('.notification-copy-btn') !== null`); - await page.evaluate(() => { - document.querySelector('.notification-copy-btn').click(); + it('clicking the copy button writes the current message to clipboard', async () => { + await page.evaluate(() => { + window.notificationModel.show('Secret message', 'success', 5000); + document.querySelector('.notification-copy-btn').click(); + }); + await page.waitForFunction(`window.__clipboardText === 'Secret message'`); }); - await page.waitForFunction(`window.__copiedContextMenuValue === 'Secret message'`); - await page.waitForFunction(`window.notificationModel.message === 'Text copied to clipboard'`); + it('copy button title flips to "Copied!" while in the copied state', async () => { + await page.evaluate(() => { + window.notificationModel.show('hi', 'success', 5000); + document.querySelector('.notification-copy-btn').click(); + }); + await page.waitForFunction(`document.querySelector('.notification-copy-btn').title === 'Copied!'`); + }); }); }); From 735eb9d4beb643d3fb2f9fa6e0004f0c35d20ccf Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Thu, 18 Jun 2026 09:49:14 +0200 Subject: [PATCH 06/14] [OGUI-1906] Refactor of notification tests --- Framework/Frontend/test/mocha-index.js | 52 ++++++++++---------------- 1 file changed, 19 insertions(+), 33 deletions(-) diff --git a/Framework/Frontend/test/mocha-index.js b/Framework/Frontend/test/mocha-index.js index 6ed350a2e6..827501d04b 100644 --- a/Framework/Frontend/test/mocha-index.js +++ b/Framework/Frontend/test/mocha-index.js @@ -330,15 +330,12 @@ describe('Framework Frontend', function() { it('copy() writes the current message to clipboard and flashes copyState to copied', async () => { await page.evaluate(async () => { - window.notificationModel.show('Secret message', 'success', 5000); + window.notificationModel.show('Secret message', 'success', 3000); await window.notificationModel.copy(); }); await page.waitForFunction(`window.__clipboardText === 'Secret message'`); await page.waitForFunction(`window.notificationModel.copyState === 'copied'`); - await page.waitForFunction(`window.notificationModel.message === 'Secret message'`); - - await new Promise((resolve) => setTimeout(resolve, 1600)); await page.waitForFunction(`window.notificationModel.copyState === 'idle'`); }); @@ -350,7 +347,7 @@ describe('Framework Frontend', function() { value: { writeText: () => Promise.reject(new Error('denied')) }, configurable: true, }); - window.notificationModel.show('Secret', 'success', 5000); + window.notificationModel.show('Secret', 'success', 3000); await window.notificationModel.copy(); }); await page.waitForFunction(`window.notificationModel.copyState === 'failed'`); @@ -387,6 +384,13 @@ describe('Framework Frontend', function() { await page.waitForFunction(`document.querySelector('.notification-content.notification-close') === null`); }); + it('renders the copy button while shown', async () => { + await page.evaluate(() => { + window.notificationModel.show('hi', 'success', 1000); + }); + await page.waitForFunction(`document.querySelector('.notification-copy-btn') !== null`); + }); + it('mouseenter sets hovered to true and prevents auto-hide', async () => { await page.evaluate(() => { window.notificationModel.show('Warp Drive Mr. Scott', 'success', 1000); @@ -400,17 +404,16 @@ describe('Framework Frontend', function() { }); it('mouseleave sets hovered to false and restarts the hide timer', async () => { - const callCount = await page.evaluate(() => { + const restartedTimer = await page.evaluate(() => { window.notificationModel.show('Warp Drive Mr. Scott', 'success', 1500); // Spy on restartHideTimer() - let restartCalls = 0; + let timerChanged = false; const originalRestart = window.notificationModel.restartHideTimer; window.notificationModel.restartHideTimer = function() { - restartCalls++; const before = this.hideTimerId; const out = originalRestart.call(this); - restartScheduledTimer = this.hideTimerId !== before; + timerChanged = this.hideTimerId !== before; return out; }; @@ -420,23 +423,21 @@ describe('Framework Frontend', function() { window.notificationModel.restartHideTimer = originalRestart; - return {restartCalls, restartScheduledTimer}; + return timerChanged; }); await page.waitForFunction(`window.notificationModel.hovered === false`); - assert.deepStrictEqual(callCount, {restartCalls: 1, restartScheduledTimer: true}); + assert.deepStrictEqual(restartedTimer, true); }); it('mouseleave while hidden does not restart the hide timer', async () => { - const callCount = await page.evaluate(() => { - let restartCalls = 0; - let restartScheduledTimer = false; + const restartedTimer = await page.evaluate(() => { + let timerChanged = false; const originalRestart = window.notificationModel.restartHideTimer; window.notificationModel.restartHideTimer = function() { - restartCalls++; const before = this.hideTimerId; const out = originalRestart.call(this); - restartScheduledTimer = this.hideTimerId !== before; + timerChanged = this.hideTimerId !== before; return out; }; @@ -444,12 +445,12 @@ describe('Framework Frontend', function() { el.dispatchEvent(new MouseEvent('mouseleave')); window.notificationModel.restartHideTimer = originalRestart; - return {restartCalls, restartScheduledTimer}; + return timerChanged; }); await page.waitForFunction(`window.notificationModel.state === 'hidden'`); // The view still called restartHideTimer, but the model guarded against the hidden state - assert.deepStrictEqual(callCount, {restartCalls: 1, restartScheduledTimer: false}); + assert.deepStrictEqual(restartedTimer, false); }); it('clicking the message hides the notification', async () => { @@ -460,13 +461,6 @@ describe('Framework Frontend', function() { await page.waitForFunction(`window.notificationModel.state === 'hidden'`); }); - it('renders the copy button while shown', async () => { - await page.evaluate(() => { - window.notificationModel.show('hi', 'success', 5000); - }); - await page.waitForFunction(`document.querySelector('.notification-copy-btn') !== null`); - }); - it('clicking the copy button writes the current message to clipboard', async () => { await page.evaluate(() => { window.notificationModel.show('Secret message', 'success', 5000); @@ -474,14 +468,6 @@ describe('Framework Frontend', function() { }); await page.waitForFunction(`window.__clipboardText === 'Secret message'`); }); - - it('copy button title flips to "Copied!" while in the copied state', async () => { - await page.evaluate(() => { - window.notificationModel.show('hi', 'success', 5000); - document.querySelector('.notification-copy-btn').click(); - }); - await page.waitForFunction(`document.querySelector('.notification-copy-btn').title === 'Copied!'`); - }); }); }); From 4a9e3995dbaf999964dfe764b79ed1effaef156f Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:25:59 +0200 Subject: [PATCH 07/14] Refactor Notification timers and copy handling Extract clearTimeouts() and startHideTimer() helpers to remove duplicated timer logic. hide() now also resets the hovered flag. Copy now uses the shared copyToClipboard utility, so it falls back to 'failed' when the clipboard is unavailable. The copy-state reset delay is configurable via copyDuration. --- Framework/Frontend/css/src/bootstrap.css | 2 +- Framework/Frontend/js/src/Notification.js | 62 ++++++++++++++--------- 2 files changed, 40 insertions(+), 24 deletions(-) diff --git a/Framework/Frontend/css/src/bootstrap.css b/Framework/Frontend/css/src/bootstrap.css index a5b256474d..0c769eeac6 100644 --- a/Framework/Frontend/css/src/bootstrap.css +++ b/Framework/Frontend/css/src/bootstrap.css @@ -361,7 +361,7 @@ label { display: block; margin-bottom: 0.5em; } .notification-content { transition: opacity 0.8s cubic-bezier(0.18, 0.89, 0.34, 1.11), filter 0.8s cubic-bezier(0.18, 0.89, 0.34, 1.11); } .notification-open { display: inline-flex; opacity: 1; filter: blur(0); pointer-events: all; } .notification-close { display: inline-flex; opacity: 0; filter: blur(5em); pointer-events: none; } -.notification-copy-btn { border-radius: 0 0.25em 0.25em 0; } +.notification-copy-btn { border-radius: 0 .25rem .25rem 0; } /* Basic table */ diff --git a/Framework/Frontend/js/src/Notification.js b/Framework/Frontend/js/src/Notification.js index 028a8eb53e..4a067da2d3 100644 --- a/Framework/Frontend/js/src/Notification.js +++ b/Framework/Frontend/js/src/Notification.js @@ -14,6 +14,7 @@ import Observable from './Observable.js'; import { h } from './renderer.js'; +import { copyToClipboard } from './utilities/clipboard.js'; import switchCase from './switchCase.js'; import { iconClipboard, iconCheck, iconCircleX } from './icons.js'; @@ -49,11 +50,14 @@ export class Notification extends Observable { this.message = ''; this.type = 'primary'; this.state = 'hidden'; // Shown, hidden - this.hideTimerId = null; // Timer to auto-hide notification - this.duration = 5000; // Original duration of the current notification this.hovered = false; // Whether the notification is hovered - this.copyState = 'idle'; // 'idle' | 'copied' | 'failed' — state for the copy button + + this.hideTimerId = null; // Timer to auto-hide notification + this.duration = 5000; // Default duration of the current notification + this.copyTimerId = null; // Timer to reset copyState to 'idle' after copy + this.copyDuration = 1500; // How long copyState shows 'copied'/'failed' before resetting to 'idle' + this.copyState = 'idle'; // 'idle' | 'copied' | 'failed' - state for the copy button } /** @@ -76,8 +80,7 @@ export class Notification extends Observable { duration = duration || 5000; // Clear previous message countdowns - clearTimeout(this.hideTimerId); - clearTimeout(this.copyTimerId); + this.clearTimeouts(); this.message = message; this.type = type; @@ -86,26 +89,28 @@ export class Notification extends Observable { this.copyState = 'idle'; // Auto-hide after duration - if (duration !== Infinity) { - this.hideTimerId = setTimeout(() => { - if (!this.hovered) { - this.hide(); - } - }, duration); - } + this.startHideTimer(); this.notify(); } /** - * Set notification as hidden before countdown ends - * (by a click on notification for example) + * Clear any active timeout for the currently shown notification. */ - hide() { + clearTimeouts() { clearTimeout(this.hideTimerId); clearTimeout(this.copyTimerId); + } + + /** + * Set notification state to `'hidden'`, copy state to `'idle'` and clear `hovered` before countdown ends + * (by a click on notification for example) + */ + hide() { + this.clearTimeouts(); this.state = 'hidden'; this.copyState = 'idle'; + this.hovered = false; this.notify(); } @@ -120,6 +125,15 @@ export class Notification extends Observable { return; } clearTimeout(this.hideTimerId); + this.startHideTimer(); + } + + /** + * Start the auto-hide countdown using the stored duration. The notification is only hidden + * when the countdown ends if it is not hovered. + * No-op when duration is Infinity. + */ + startHideTimer() { if (this.duration !== Infinity) { this.hideTimerId = setTimeout(() => { if (!this.hovered) { @@ -130,25 +144,30 @@ export class Notification extends Observable { } /** - * Copy the current message to the clipboard and flash `copyState` to `'copied'` for 1.5s. - * If the clipboard write rejects, `copyState` flashes to `'failed'` instead. + * Copy the current message to the clipboard and flash `copyState` to `'copied'` for `copyDuration` ms. + * If the clipboard is unavailable or the write rejects, `copyState` flashes to `'failed'` instead. * @return {Promise} */ async copy() { + const copiedMessage = this.message; let nextState; try { - await navigator.clipboard.writeText(this.message); + await copyToClipboard(copiedMessage); nextState = 'copied'; } catch { nextState = 'failed'; } + // The notification was hidden or replaced while the write was pending + if (this.state !== 'shown' || this.message !== copiedMessage) { + return; + } clearTimeout(this.copyTimerId); this.copyState = nextState; this.notify(); this.copyTimerId = setTimeout(() => { this.copyState = 'idle'; this.notify(); - }, 1500); + }, this.copyDuration); } } @@ -200,10 +219,7 @@ export const notification = (notificationInstance) => h('.notification.text-no-s failed: "Couldn't copy", idle: 'Copy to clipboard', }), - onclick: (e) => { - e.stopPropagation(); - notificationInstance.copy(); - }, + onclick: () => notificationInstance.copy(), }, switchCase(notificationInstance.copyState, { copied: iconCheck(), failed: iconCircleX(), From 30f323f4bd981ad44dcc0f284c2cb96a3dede107 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:57:34 +0200 Subject: [PATCH 08/14] Refactor tests --- Framework/Frontend/js/src/Notification.js | 4 + Framework/Frontend/test/mocha-index.js | 187 ++++------------------ 2 files changed, 31 insertions(+), 160 deletions(-) diff --git a/Framework/Frontend/js/src/Notification.js b/Framework/Frontend/js/src/Notification.js index 4a067da2d3..29331f327e 100644 --- a/Framework/Frontend/js/src/Notification.js +++ b/Framework/Frontend/js/src/Notification.js @@ -149,6 +149,9 @@ export class Notification extends Observable { * @return {Promise} */ async copy() { + if (this.state !== 'shown') { + return; + } const copiedMessage = this.message; let nextState; try { @@ -214,6 +217,7 @@ export const notification = (notificationInstance) => h('.notification.text-no-s }, [ h('div.mh2.pv2', { onclick: () => notificationInstance.hide() }, notificationInstance.message), h(`button.btn.btn-${notificationInstance.type}.notification-copy-btn`, { + tabIndex: notificationInstance.state === 'shown' ? 0 : -1, title: switchCase(notificationInstance.copyState, { copied: 'Copied!', failed: "Couldn't copy", diff --git a/Framework/Frontend/test/mocha-index.js b/Framework/Frontend/test/mocha-index.js index 827501d04b..963480874d 100644 --- a/Framework/Frontend/test/mocha-index.js +++ b/Framework/Frontend/test/mocha-index.js @@ -248,169 +248,52 @@ describe('Framework Frontend', function() { }); }); - describe('Class', function() { - before('create instance', async () => { - await page.evaluate(() => { - window.notificationModel = new Notification(); - }); - }); - - beforeEach(async () => { - await page.evaluate(() => { - window.notificationModel.hide(); - }); - }); - - it('is hidden at first', async () => { - await page.waitForFunction(`window.notificationModel.state === 'hidden'`); - }); - - it('show() sets the notification state to shown', async () => { - await page.evaluate(() => { - window.notificationModel.show('Warp Drive Mr. Scott', 'success', 4000); - }); - await page.waitForFunction(`window.notificationModel.state === 'shown'`); - }); - - it('hide() sets the notification state to hidden', async () => { - await page.evaluate(() => { - window.notificationModel.show('Bye', 'primary', 10000); - window.notificationModel.hide(); - }); - await page.waitForFunction(`window.notificationModel.state === 'hidden'`); - }); - - it('automatically hides after the duration is reached', async () => { - await page.evaluate(() => { - window.notificationModel.show('Warp Drive Mr. Scott', 'success', 500); - }); - - await new Promise((resolve) => setTimeout(resolve, 1000)); - await page.waitForFunction(`window.notificationModel.state === 'hidden'`); - }); - - it('falls back to a 5000ms duration when none is given', async () => { - await page.evaluate(() => { - window.notificationModel.show('hi', 'success'); - }); - await page.waitForFunction(`window.notificationModel.duration === 5000`); - }); - - it('stores Infinity as the duration when given', async () => { - await page.evaluate(() => { - window.notificationModel.show('fatal error', 'danger', Infinity); - }); - await page.waitForFunction(`window.notificationModel.duration === Infinity`); - }); - - it('does not automatically hide while the hovered attribute is true', async () => { - await page.evaluate(() => { - window.notificationModel.show('hi', 'success', 1000); - window.notificationModel.hovered = true; - }); - - await new Promise((resolve) => setTimeout(resolve, 1500)); - await page.waitForFunction(`window.notificationModel.state === 'shown'`); - }); - - it('throws on an unknown type', async () => { - await assert.rejects( - page.evaluate(() => window.notificationModel.show('bad type', 'unknown', 1000)), - (err) => err.message.includes('Notification type must be danger, warning, success or primary. "unknown" provided') - ); - }); - - it('ignores empty message notifications', async () => { - await page.evaluate(() => { - window.notificationModel.hide(); - window.notificationModel.show('', 'success', 1000); - }); - await page.waitForFunction(`window.notificationModel.state === 'hidden'`); - }); - - it('copy() writes the current message to clipboard and flashes copyState to copied', async () => { - await page.evaluate(async () => { - window.notificationModel.show('Secret message', 'success', 3000); - await window.notificationModel.copy(); - }); - await page.waitForFunction(`window.__clipboardText === 'Secret message'`); - await page.waitForFunction(`window.notificationModel.copyState === 'copied'`); - - await new Promise((resolve) => setTimeout(resolve, 1600)); - await page.waitForFunction(`window.notificationModel.copyState === 'idle'`); - }); - - it('copy() flashes copyState to failed when clipboard rejects, then resets to idle', async () => { - await page.evaluate(async () => { - // Override the mock to simulate a failure - Object.defineProperty(navigator, 'clipboard', { - value: { writeText: () => Promise.reject(new Error('denied')) }, - configurable: true, - }); - window.notificationModel.show('Secret', 'success', 3000); - await window.notificationModel.copy(); - }); - await page.waitForFunction(`window.notificationModel.copyState === 'failed'`); - - await new Promise((resolve) => setTimeout(resolve, 1600)); - await page.waitForFunction(`window.notificationModel.copyState === 'idle'`); - }); - }); - describe('View', function() { before('mount view', async () => { await page.evaluate(() => { - window.notificationModel = new Notification(); - mount(document.body, (model) => notification(model), window.notificationModel); + notificationModel = new Notification(); + mount(document.body, (model) => notification(model), notificationModel); }); }); beforeEach(async () => { await page.evaluate(() => { - window.notificationModel.hide(); + notificationModel.hide(); }); }); it('renders with notification-close class when hidden', async () => { - await page.waitForFunction(`document.querySelector('.notification-content.notification-close') !== null`); - await page.waitForFunction(`document.querySelector('.notification-content.notification-open') === null`); + await page.waitForFunction(() => document.querySelector('.notification-content.notification-close') !== null); + await page.waitForFunction(() => document.querySelector('.notification-content.notification-open') === null); }); it('renders with notification-open class when shown', async () => { await page.evaluate(() => { - window.notificationModel.show('Warp Drive Mr. Scott', 'success', 4000); + notificationModel.show('Notification Text', 'success', 4000); }); - await page.waitForFunction(`document.querySelector('.notification-content.notification-open') !== null`); - await page.waitForFunction(`document.querySelector('.notification-content.notification-close') === null`); - }); - - it('renders the copy button while shown', async () => { - await page.evaluate(() => { - window.notificationModel.show('hi', 'success', 1000); - }); - await page.waitForFunction(`document.querySelector('.notification-copy-btn') !== null`); + await page.waitForFunction(() => document.querySelector('.notification-content.notification-open') !== null); + await page.waitForFunction(() => document.querySelector('.notification-content.notification-close') === null); }); it('mouseenter sets hovered to true and prevents auto-hide', async () => { await page.evaluate(() => { - window.notificationModel.show('Warp Drive Mr. Scott', 'success', 1000); + notificationModel.show('Notification Text', 'success', 1000); const el = document.querySelector('.notification-content'); el.dispatchEvent(new MouseEvent('mouseenter')); }); - await page.waitForFunction(`window.notificationModel.hovered === true`); + await page.waitForFunction(() => notificationModel.hovered === true); await new Promise((resolve) => setTimeout(resolve, 1500)); - await page.waitForFunction(`window.notificationModel.state === 'shown'`); + await page.waitForFunction(() => notificationModel.state === 'shown'); }); it('mouseleave sets hovered to false and restarts the hide timer', async () => { const restartedTimer = await page.evaluate(() => { - window.notificationModel.show('Warp Drive Mr. Scott', 'success', 1500); + notificationModel.show('Notification Text', 'success', 1500); - // Spy on restartHideTimer() let timerChanged = false; - const originalRestart = window.notificationModel.restartHideTimer; - window.notificationModel.restartHideTimer = function() { + const originalRestart = notificationModel.restartHideTimer; + notificationModel.restartHideTimer = function() { const before = this.hideTimerId; const out = originalRestart.call(this); timerChanged = this.hideTimerId !== before; @@ -421,52 +304,36 @@ describe('Framework Frontend', function() { el.dispatchEvent(new MouseEvent('mouseenter')); el.dispatchEvent(new MouseEvent('mouseleave')); - window.notificationModel.restartHideTimer = originalRestart; + notificationModel.restartHideTimer = originalRestart; return timerChanged; }); - await page.waitForFunction(`window.notificationModel.hovered === false`); assert.deepStrictEqual(restartedTimer, true); }); - it('mouseleave while hidden does not restart the hide timer', async () => { - const restartedTimer = await page.evaluate(() => { - let timerChanged = false; - const originalRestart = window.notificationModel.restartHideTimer; - window.notificationModel.restartHideTimer = function() { - const before = this.hideTimerId; - const out = originalRestart.call(this); - timerChanged = this.hideTimerId !== before; - return out; - }; - - const el = document.querySelector('.notification-content'); - el.dispatchEvent(new MouseEvent('mouseleave')); - - window.notificationModel.restartHideTimer = originalRestart; - return timerChanged; - }); - - await page.waitForFunction(`window.notificationModel.state === 'hidden'`); - // The view still called restartHideTimer, but the model guarded against the hidden state - assert.deepStrictEqual(restartedTimer, false); - }); - it('clicking the message hides the notification', async () => { await page.evaluate(() => { - window.notificationModel.show('Click me to close', 'primary', 1000); + notificationModel.show('Click me to close', 'primary', 1000); document.querySelector('.notification-content .mh2.pv2').click(); }); - await page.waitForFunction(`window.notificationModel.state === 'hidden'`); + await page.waitForFunction(() => notificationModel.state === 'hidden'); }); it('clicking the copy button writes the current message to clipboard', async () => { await page.evaluate(() => { - window.notificationModel.show('Secret message', 'success', 5000); + notificationModel.show('Secret message', 'success', 5000); document.querySelector('.notification-copy-btn').click(); }); - await page.waitForFunction(`window.__clipboardText === 'Secret message'`); + await page.waitForFunction(() => window.__clipboardText === 'Secret message'); + }); + + it('does allow focusing the copy button while hidden', async () => { + await page.evaluate(async () => { + notificationModel.show('Secret message', 'success', 5000); + notificationModel.hide(); + }); + await page.waitForFunction(() => document.querySelector('.notification-copy-btn').tabIndex === -1); }); }); }); From 26ef9c692def97ba041b94523d19ddcc267369a5 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Thu, 22 Oct 2026 15:57:43 +0200 Subject: [PATCH 09/14] Refactor notification to be more readable --- Framework/Frontend/js/src/Notification.js | 191 ++++++++++------------ 1 file changed, 88 insertions(+), 103 deletions(-) diff --git a/Framework/Frontend/js/src/Notification.js b/Framework/Frontend/js/src/Notification.js index 29331f327e..ebfd868932 100644 --- a/Framework/Frontend/js/src/Notification.js +++ b/Framework/Frontend/js/src/Notification.js @@ -15,11 +15,32 @@ import Observable from './Observable.js'; import { h } from './renderer.js'; import { copyToClipboard } from './utilities/clipboard.js'; -import switchCase from './switchCase.js'; import { iconClipboard, iconCheck, iconCircleX } from './icons.js'; +const DEFAULT_DURATION_MS = 5000; +const COPY_FEEDBACK_MS = 1500; + +const NOTIFICATION_TYPES = ['danger', 'warning', 'success', 'primary']; + +const NOTIFICATION_STATES = { + SHOWN: 'shown', + HIDDEN: 'hidden', +}; + +const COPY_STATES = { + IDLE: 'idle', + COPIED: 'copied', + FAILED: 'failed', +}; + +const COPY_STATE_VIEW = { + [COPY_STATES.IDLE]: { title: 'Copy to clipboard', icon: iconClipboard }, + [COPY_STATES.COPIED]: { title: 'Copied!', icon: iconCheck }, + [COPY_STATES.FAILED]: { title: "Couldn't copy", icon: iconCircleX }, +}; + /** - * Container of notification with time management + * Container of notification with time management and copy-to-clipboard. * Only 1 notification is handled at once. * @extends Observable * @example @@ -49,15 +70,24 @@ export class Notification extends Observable { this.message = ''; this.type = 'primary'; - this.state = 'hidden'; // Shown, hidden - this.hovered = false; // Whether the notification is hovered - this.hideTimerId = null; // Timer to auto-hide notification - this.duration = 5000; // Default duration of the current notification + this.state = NOTIFICATION_STATES.HIDDEN; + + this.copyState = COPY_STATES.IDLE; + this.hovered = false; + this.duration = DEFAULT_DURATION_MS; + + this.hideTimerId = null; this.copyTimerId = null; // Timer to reset copyState to 'idle' after copy - this.copyDuration = 1500; // How long copyState shows 'copied'/'failed' before resetting to 'idle' - this.copyState = 'idle'; // 'idle' | 'copied' | 'failed' - state for the copy button + } + + /** + * Whether a notification is currently displayed. + * @return {boolean} + */ + get isShown() { + return this.state === NOTIFICATION_STATES.SHOWN; } /** @@ -72,25 +102,19 @@ export class Notification extends Observable { return; } - if (type !== 'danger' && type !== 'warning' && - type !== 'success' && type !== 'primary') { + if (!NOTIFICATION_TYPES.includes(type)) { throw new Error(`Notification type must be danger, warning, success or primary. "${type}" provided`); } - duration = duration || 5000; - - // Clear previous message countdowns this.clearTimeouts(); this.message = message; this.type = type; - this.state = 'shown'; - this.duration = duration; - this.copyState = 'idle'; - - // Auto-hide after duration - this.startHideTimer(); + this.duration = duration || DEFAULT_DURATION_MS; + this.state = NOTIFICATION_STATES.SHOWN; + this.copyState = COPY_STATES.IDLE; + this.restartHideTimer(); this.notify(); } @@ -108,125 +132,86 @@ export class Notification extends Observable { */ hide() { this.clearTimeouts(); - this.state = 'hidden'; - this.copyState = 'idle'; + this.state = NOTIFICATION_STATES.HIDDEN; + this.copyState = COPY_STATES.IDLE; this.hovered = false; this.notify(); } /** - * Restart the auto-hide countdown for the currently shown notification, reusing the - * stored duration. - * No-op when the notification is hidden or duration is Infinity. + * Track whether the notification is hovered. Leaving it restarts the auto-hide countdown. + * @param {boolean} hovered - whether the pointer is over the notification */ - restartHideTimer() { - if (this.state !== 'shown') { - return; + setHovered(hovered) { + this.hovered = hovered; + if (!hovered) { + this.restartHideTimer(); } - clearTimeout(this.hideTimerId); - this.startHideTimer(); } /** - * Start the auto-hide countdown using the stored duration. The notification is only hidden - * when the countdown ends if it is not hovered. - * No-op when duration is Infinity. + * (Re)start the auto-hide countdown using the stored duration. The notification is only hidden + * when the countdown ends AND it is not hovered. + * No-op when the notification is hidden or duration is Infinity. */ - startHideTimer() { - if (this.duration !== Infinity) { - this.hideTimerId = setTimeout(() => { - if (!this.hovered) { - this.hide(); - } - }, this.duration); + restartHideTimer() { + clearTimeout(this.hideTimerId); + if (!this.isShown || this.duration === Infinity) { + return; } + this.hideTimerId = setTimeout(() => !this.hovered && this.hide(), this.duration); } /** - * Copy the current message to the clipboard and flash `copyState` to `'copied'` for `copyDuration` ms. + * Copy the current message to the clipboard and flash `copyState` to `'copied'` for `COPY_FEEDBACK_MS` ms. * If the clipboard is unavailable or the write rejects, `copyState` flashes to `'failed'` instead. * @return {Promise} */ async copy() { - if (this.state !== 'shown') { + if (!this.isShown) { return; } const copiedMessage = this.message; - let nextState; - try { - await copyToClipboard(copiedMessage); - nextState = 'copied'; - } catch { - nextState = 'failed'; - } + const nextState = await copyToClipboard(copiedMessage) + .then(() => COPY_STATES.COPIED, () => COPY_STATES.FAILED); + // The notification was hidden or replaced while the write was pending - if (this.state !== 'shown' || this.message !== copiedMessage) { + if (!this.isShown || this.message !== copiedMessage) { return; } + clearTimeout(this.copyTimerId); this.copyState = nextState; this.notify(); + this.copyTimerId = setTimeout(() => { - this.copyState = 'idle'; + this.copyState = COPY_STATES.IDLE; this.notify(); - }, this.copyDuration); + }, COPY_FEEDBACK_MS); } } /** - * Shows notification according to `notificationInstance`. Because of its absolute position it should + * Shows notification according to `model`. Because of its absolute position it should * be placed as first element inside body. - * @param {Notification} notificationInstance - instance of Notification + * @param {Notification} model - instance of Notification * @return {vnode} - virtual node to render notification - * @example - * import {mount, h, Notification, notification} from '../../Frontend/js/src/index.js'; - * - * const view = (model) => [ - * notification(model), - * h('div.m4', [ - * h('button', {onclick: () => model.show('An admin has taken lock form you.', 'primary')}, 'Show primary'), - * h('button', {onclick: () => model.show('Environment has been created.', 'success')}, 'Show success'), - * h('button', {onclick: () => model.show('Unable to create, please check inputs and retry.', 'warning')}, 'Show warning'), - * h('button', {onclick: () => model.show('Server connection has been lost.', 'danger')}, 'Show danger'), - * ]), - * ]; - * - * // Create some basic model - * const model = new Notification(); - * - * mount(document.body, view, model, true); */ -export const notification = (notificationInstance) => h('.notification.text-no-select.level4.text-light', { - -}, h('div.notification-content.br2.shadow-level4', { - // ClassName: notificationInstance.message && (notificationInstance.state === 'shown' ? 'notification-open' : 'notification-close'), - onmouseenter: () => { - notificationInstance.hovered = true; - }, - onmouseleave: () => { - notificationInstance.hovered = false; - notificationInstance.restartHideTimer(); - }, - className: `${switchCase(notificationInstance.type, { - primary: 'white bg-primary', - success: 'white bg-success', - warning: 'white bg-warning', - danger: 'white bg-danger', - })} ${notificationInstance.state === 'shown' ? 'notification-open' : 'notification-close'}`, -}, [ - h('div.mh2.pv2', { onclick: () => notificationInstance.hide() }, notificationInstance.message), - h(`button.btn.btn-${notificationInstance.type}.notification-copy-btn`, { - tabIndex: notificationInstance.state === 'shown' ? 0 : -1, - title: switchCase(notificationInstance.copyState, { - copied: 'Copied!', - failed: "Couldn't copy", - idle: 'Copy to clipboard', - }), - onclick: () => notificationInstance.copy(), - }, switchCase(notificationInstance.copyState, { - copied: iconCheck(), - failed: iconCircleX(), - idle: iconClipboard(), - })), -])); +export const notification = (model) => { + const { type, message, copyState, isShown } = model; + const { title, icon } = COPY_STATE_VIEW[copyState]; + + return h('.notification.text-no-select.level4.text-light', h('div.notification-content.br2.shadow-level4', { + className: `white bg-${type} ${isShown ? 'notification-open' : 'notification-close'}`, + onmouseenter: () => model.setHovered(true), + onmouseleave: () => model.setHovered(false), + }, [ + h('div.mh2.pv2', { onclick: () => model.hide() }, message), + h(`button.btn.btn-${type}.notification-copy-btn`, { + tabIndex: isShown ? 0 : -1, + title, + onclick: () => model.copy(), + }, icon()), + ])); +}; From 5609d2b828b0e50104abba668a8dc17263f88a50 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Thu, 22 Oct 2026 16:02:37 +0200 Subject: [PATCH 10/14] Fix typo --- Framework/Frontend/test/mocha-index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Framework/Frontend/test/mocha-index.js b/Framework/Frontend/test/mocha-index.js index 963480874d..149e19bfa2 100644 --- a/Framework/Frontend/test/mocha-index.js +++ b/Framework/Frontend/test/mocha-index.js @@ -328,7 +328,7 @@ describe('Framework Frontend', function() { await page.waitForFunction(() => window.__clipboardText === 'Secret message'); }); - it('does allow focusing the copy button while hidden', async () => { + it('doesn\'t allow focusing the copy button while hidden', async () => { await page.evaluate(async () => { notificationModel.show('Secret message', 'success', 5000); notificationModel.hide(); From 8f3dd3a63742559f59354b23a9b21a6cb2e371ef Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Thu, 22 Oct 2026 16:36:51 +0200 Subject: [PATCH 11/14] Don't do any fancy colour changes, just darken the inherited --- Framework/Frontend/css/src/bootstrap.css | 3 ++- Framework/Frontend/js/src/Notification.js | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Framework/Frontend/css/src/bootstrap.css b/Framework/Frontend/css/src/bootstrap.css index 0c769eeac6..852868660f 100644 --- a/Framework/Frontend/css/src/bootstrap.css +++ b/Framework/Frontend/css/src/bootstrap.css @@ -361,7 +361,8 @@ label { display: block; margin-bottom: 0.5em; } .notification-content { transition: opacity 0.8s cubic-bezier(0.18, 0.89, 0.34, 1.11), filter 0.8s cubic-bezier(0.18, 0.89, 0.34, 1.11); } .notification-open { display: inline-flex; opacity: 1; filter: blur(0); pointer-events: all; } .notification-close { display: inline-flex; opacity: 0; filter: blur(5em); pointer-events: none; } -.notification-copy-btn { border-radius: 0 .25rem .25rem 0; } +.btn.notification-copy-btn { border-radius: 0 .25rem .25rem 0; background-color: rgba(0, 0, 0, .15); color: inherit; } +.btn.notification-copy-btn:hover { background-color: rgba(0, 0, 0, .3); color: inherit; } /* Basic table */ diff --git a/Framework/Frontend/js/src/Notification.js b/Framework/Frontend/js/src/Notification.js index ebfd868932..58b3c517ba 100644 --- a/Framework/Frontend/js/src/Notification.js +++ b/Framework/Frontend/js/src/Notification.js @@ -208,7 +208,7 @@ export const notification = (model) => { onmouseleave: () => model.setHovered(false), }, [ h('div.mh2.pv2', { onclick: () => model.hide() }, message), - h(`button.btn.btn-${type}.notification-copy-btn`, { + h('button.btn.notification-copy-btn', { tabIndex: isShown ? 0 : -1, title, onclick: () => model.copy(), From df4baafaf4ab7f87e0fb03f047f62e7df80e7c7c Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Thu, 22 Oct 2026 16:54:33 +0200 Subject: [PATCH 12/14] Add to JSDoc --- Framework/Frontend/js/src/Notification.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Framework/Frontend/js/src/Notification.js b/Framework/Frontend/js/src/Notification.js index 58b3c517ba..39d8712599 100644 --- a/Framework/Frontend/js/src/Notification.js +++ b/Framework/Frontend/js/src/Notification.js @@ -84,7 +84,7 @@ export class Notification extends Observable { /** * Whether a notification is currently displayed. - * @return {boolean} + * @return {boolean} - whether the notification is currently shown */ get isShown() { return this.state === NOTIFICATION_STATES.SHOWN; From 3bc1c86554133cbd24c7c13f5b6a106f39c1158b Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:11:29 +0200 Subject: [PATCH 13/14] Improve JSDoc --- Framework/Frontend/js/src/Notification.js | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Framework/Frontend/js/src/Notification.js b/Framework/Frontend/js/src/Notification.js index 39d8712599..f355c9dafd 100644 --- a/Framework/Frontend/js/src/Notification.js +++ b/Framework/Frontend/js/src/Notification.js @@ -193,25 +193,25 @@ export class Notification extends Observable { } /** - * Shows notification according to `model`. Because of its absolute position it should + * Shows notification according to a notification instance. Because of its absolute position it should * be placed as first element inside body. - * @param {Notification} model - instance of Notification + * @param {Notification} instance - instance of Notification * @return {vnode} - virtual node to render notification */ -export const notification = (model) => { - const { type, message, copyState, isShown } = model; +export const notification = (instance) => { + const { type, message, copyState, isShown } = instance; const { title, icon } = COPY_STATE_VIEW[copyState]; return h('.notification.text-no-select.level4.text-light', h('div.notification-content.br2.shadow-level4', { className: `white bg-${type} ${isShown ? 'notification-open' : 'notification-close'}`, - onmouseenter: () => model.setHovered(true), - onmouseleave: () => model.setHovered(false), + onmouseenter: () => instance.setHovered(true), + onmouseleave: () => instance.setHovered(false), }, [ - h('div.mh2.pv2', { onclick: () => model.hide() }, message), + h('div.mh2.pv2', { onclick: () => instance.hide() }, message), h('button.btn.notification-copy-btn', { tabIndex: isShown ? 0 : -1, title, - onclick: () => model.copy(), + onclick: () => instance.copy(), }, icon()), ])); }; From 0e9a0151f2380cce5e220f1200676884e7de3c4b Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:18:34 +0200 Subject: [PATCH 14/14] Improve aexample in JSDoc --- Framework/Frontend/js/src/Notification.js | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/Framework/Frontend/js/src/Notification.js b/Framework/Frontend/js/src/Notification.js index f355c9dafd..eb4e288316 100644 --- a/Framework/Frontend/js/src/Notification.js +++ b/Framework/Frontend/js/src/Notification.js @@ -44,22 +44,25 @@ const COPY_STATE_VIEW = { * Only 1 notification is handled at once. * @extends Observable * @example - * import {mount, h, Notification, notification} from '../../Frontend/js/src/index.js'; + * import { mount, h, Observable, Notification, notification } from '@aliceo2/web-ui'; * + * class Model extends Observable { + * constructor() { + * super(); + * this.notification = new Notification(); + * this.notification.bubbleTo(this); + * } + * } + * + * // Render first inside body * const view = (model) => [ - * notification(model), + * notification(model.notification), * h('div.m4', [ - * h('button', {onclick: () => model.show('An admin has taken lock form you.', 'primary')}, 'Show primary'), - * h('button', {onclick: () => model.show('Environment has been created.', 'success')}, 'Show success'), - * h('button', {onclick: () => model.show('Unable to create, please check inputs and retry.', 'warning')}, 'Show warning'), - * h('button', {onclick: () => model.show('Server connection has been lost.', 'danger')}, 'Show danger'), + * h('button', { onclick: () => model.notification.show('Environment has been created.', 'success') }, 'Start Environment'), * ]), * ]; * - * // Create some basic model - * const model = new Notification(); - * - * mount(document.body, view, model, true); + * mount(document.body, view, new Model(), true); */ export class Notification extends Observable { /**