From 5e3fa90f8a924842636d0ba5cf84300eca180fd8 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 1/6] [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 c5415d655..f7adb68f5 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 b70ce520e..3ec97bcef 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 71f96db98ff6b45b23f054a4528dc59164ca389d 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 2/6] [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 3ec97bcef..fd573ec4a 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 9ec26bbfa3a9d230465825770af13cd4519d467f 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 3/6] [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 f7adb68f5..a5b256474 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 fd573ec4a..ca92d1e04 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 e9a432ea120b1dc469edffaf166ba13cbf950f58 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 4/6] [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 3286ec25f..c933419bd 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 b1b3ba6fdecf1390b33f61a2284aafe3ee4ebf7b 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 5/6] [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 ca92d1e04..028a8eb53 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 c933419bd..6ed350a2e 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 6261c0a7eca1269419dbb7d31a992d71516fd06a 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 6/6] [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 6ed350a2e..827501d04 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!'`); - }); }); });