From e00149716d3c70ac9af9e330c7a07c9c6939f5f2 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/19] [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 d5cfe08a175f9c6e4c21708be3a590b29621694f 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/19] [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 36e2ea0871954ccc1328a954b4b01fe5da015494 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/19] [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 fb6e2ce600ddd9efa3bd1a91f376da97b9e24c61 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/19] [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 121bfb7efbe7d1ddd612cea41289f735397dce2b 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/19] [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 7dc71bd9b2f8b5c74601c73d6194be376325eb49 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/19] [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 adf2a128b1c9d3eec07e069623f00498dcf9cae2 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/19] 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 2c81eb33ffb72ecad2f7682b7d5e30f191acb94f 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/19] 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 49d678936e057550d09860b50ae5ce2ad60d54fd 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/19] 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 33a5bfaf5b54c833dfaa9376aa47ef91e1e71c9c 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/19] 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 924ed286c800fe3d529f6a36e8f07f0d4e8f9d23 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/19] 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 1baa1926c12cad927630258cb19c86e86349054f 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/19] 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 d8b977e18abfe8f693b16c05a3e37195a73552c8 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/19] 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 8ae3c976ffc3299a1bc6a85bdaed03fda8aa9994 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/19] 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 { /** From 128965f2b2c059e99a5cbc7ae3ba14a06b562fa8 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:45:12 +0100 Subject: [PATCH 15/19] Add background and title hide hint --- Framework/Frontend/css/src/bootstrap.css | 4 ++++ Framework/Frontend/js/src/Notification.js | 9 ++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/Framework/Frontend/css/src/bootstrap.css b/Framework/Frontend/css/src/bootstrap.css index 852868660f..5d31973b03 100644 --- a/Framework/Frontend/css/src/bootstrap.css +++ b/Framework/Frontend/css/src/bootstrap.css @@ -363,6 +363,10 @@ label { display: block; margin-bottom: 0.5em; } .notification-close { display: inline-flex; opacity: 0; filter: blur(5em); pointer-events: none; } .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; } +.btn.notification-copy-btn:active { background-color: rgba(0, 0, 0, .22); } +.notification-message { border-radius: .25rem 0 0 .25rem; } +.notification-message:hover { background-color: rgba(0, 0, 0, .15); } +.notification-message:active { background-color: rgba(0, 0, 0, .08); } /* Basic table */ diff --git a/Framework/Frontend/js/src/Notification.js b/Framework/Frontend/js/src/Notification.js index eb4e288316..c0ed345dee 100644 --- a/Framework/Frontend/js/src/Notification.js +++ b/Framework/Frontend/js/src/Notification.js @@ -210,7 +210,14 @@ export const notification = (instance) => { onmouseenter: () => instance.setHovered(true), onmouseleave: () => instance.setHovered(false), }, [ - h('div.mh2.pv2', { onclick: () => instance.hide() }, message), + h( + 'div.notification-message.ph2.pv2', + { + title: 'Click to dismiss', + onclick: () => instance.hide(), + }, + message, + ), h('button.btn.notification-copy-btn', { tabIndex: isShown ? 0 : -1, title, From 6ad7173f861771a54e718df81a13d291c993fa2e Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:54:07 +0100 Subject: [PATCH 16/19] Show copy error details in button --- Framework/Frontend/js/src/Notification.js | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/Framework/Frontend/js/src/Notification.js b/Framework/Frontend/js/src/Notification.js index c0ed345dee..e18f360947 100644 --- a/Framework/Frontend/js/src/Notification.js +++ b/Framework/Frontend/js/src/Notification.js @@ -77,6 +77,7 @@ export class Notification extends Observable { this.state = NOTIFICATION_STATES.HIDDEN; this.copyState = COPY_STATES.IDLE; + this.copyError = null; this.hovered = false; this.duration = DEFAULT_DURATION_MS; @@ -176,8 +177,15 @@ export class Notification extends Observable { return; } const copiedMessage = this.message; - const nextState = await copyToClipboard(copiedMessage) - .then(() => COPY_STATES.COPIED, () => COPY_STATES.FAILED); + let nextState; + let copyError = null; + try { + await copyToClipboard(copiedMessage); + nextState = COPY_STATES.COPIED; + } catch (err) { + nextState = COPY_STATES.FAILED; + copyError = err.message; + } // The notification was hidden or replaced while the write was pending if (!this.isShown || this.message !== copiedMessage) { @@ -186,10 +194,12 @@ export class Notification extends Observable { clearTimeout(this.copyTimerId); this.copyState = nextState; + this.copyError = copyError; this.notify(); this.copyTimerId = setTimeout(() => { this.copyState = COPY_STATES.IDLE; + this.copyError = null; this.notify(); }, COPY_FEEDBACK_MS); } @@ -220,7 +230,7 @@ export const notification = (instance) => { ), h('button.btn.notification-copy-btn', { tabIndex: isShown ? 0 : -1, - title, + title: instance.copyError ?? title, onclick: () => instance.copy(), }, icon()), ])); From c99728010a8f3bc86667351da388dd5896ea2d50 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:54:42 +0100 Subject: [PATCH 17/19] Use default param for duration --- Framework/Frontend/js/src/Notification.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Framework/Frontend/js/src/Notification.js b/Framework/Frontend/js/src/Notification.js index e18f360947..b27d7c3b09 100644 --- a/Framework/Frontend/js/src/Notification.js +++ b/Framework/Frontend/js/src/Notification.js @@ -99,9 +99,9 @@ export class Notification extends Observable { * `duration` for `danger` type should be set to Infinity if error is fatal for application (not working until reload) * @param {string} message - what to say * @param {string} type - how to say (danger, warning, success, primary) - * @param {number} duration - optional, how much time to show it (ms), Infinity for unlimited time. + * @param {number} [duration=DEFAULT_DURATION_MS] - optional, how much time to show it (ms), Infinity for unlimited time. */ - show(message, type, duration) { + show(message, type, duration = DEFAULT_DURATION_MS) { if (!message) { return; } @@ -114,7 +114,7 @@ export class Notification extends Observable { this.message = message; this.type = type; - this.duration = duration || DEFAULT_DURATION_MS; + this.duration = duration; this.state = NOTIFICATION_STATES.SHOWN; this.copyState = COPY_STATES.IDLE; From f0da55f791ca61ccb5ccde4a5a01e2862f2f2a33 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:54:50 +0100 Subject: [PATCH 18/19] Fix ESlint issue --- 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 b27d7c3b09..9e98d599a1 100644 --- a/Framework/Frontend/js/src/Notification.js +++ b/Framework/Frontend/js/src/Notification.js @@ -36,7 +36,7 @@ const COPY_STATES = { 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 }, + [COPY_STATES.FAILED]: { title: 'Could not copy', icon: iconCircleX }, }; /** From 4e6e64dc7ec358af1f12dc189c5b0d28323f99da Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Mon, 26 Oct 2026 15:16:04 +0100 Subject: [PATCH 19/19] Update test with new classname --- 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 149e19bfa2..7e1e1223d7 100644 --- a/Framework/Frontend/test/mocha-index.js +++ b/Framework/Frontend/test/mocha-index.js @@ -315,7 +315,7 @@ describe('Framework Frontend', function() { it('clicking the message hides the notification', async () => { await page.evaluate(() => { notificationModel.show('Click me to close', 'primary', 1000); - document.querySelector('.notification-content .mh2.pv2').click(); + document.querySelector('.notification-message').click(); }); await page.waitForFunction(() => notificationModel.state === 'hidden'); });