diff --git a/Framework/Frontend/css/src/bootstrap.css b/Framework/Frontend/css/src/bootstrap.css index c5415d655..a5b256474 100644 --- a/Framework/Frontend/css/src/bootstrap.css +++ b/Framework/Frontend/css/src/bootstrap.css @@ -359,8 +359,9 @@ 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; } +.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 b70ce520e..028a8eb53 100644 --- a/Framework/Frontend/js/src/Notification.js +++ b/Framework/Frontend/js/src/Notification.js @@ -15,6 +15,7 @@ import Observable from './Observable.js'; import { h } from './renderer.js'; import switchCase from './switchCase.js'; +import { iconClipboard, iconCheck, iconCircleX } from './icons.js'; /** * Container of notification with time management @@ -48,7 +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 } /** @@ -70,17 +75,22 @@ 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.hide(); + this.hideTimerId = setTimeout(() => { + if (!this.hovered) { + this.hide(); + } }, duration); } @@ -92,11 +102,54 @@ 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); + } } /** @@ -124,13 +177,36 @@ 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; + 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'}`, -}, notificationInstance.message)); +}, [ + h('div.mh2.pv2', { onclick: () => notificationInstance.hide() }, notificationInstance.message), + 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(); + notificationInstance.copy(); + }, + }, 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 3286ec25f..827501d04 100644 --- a/Framework/Frontend/test/mocha-index.js +++ b/Framework/Frontend/test/mocha-index.js @@ -220,49 +220,254 @@ 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 () => { + describe('Notification', function() { + beforeEach(async () => { await page.evaluate(() => { - window.notification = new Notification(); + window.__clipboardText = ''; + Object.defineProperty(navigator, 'clipboard', { + value: { + writeText: (value) => { + window.__clipboardText = value; + return Promise.resolve(); + }, + }, + configurable: true, + }); }); }); - 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'`); - }); + describe('Class', function() { + before('create instance', async () => { + await page.evaluate(() => { + window.notificationModel = new Notification(); + }); + }); - it('is shown on new success message', async () => { - await page.evaluate(() => { - window.notification.show('Warp Drive Mr. Scott', 'success', 4000); + 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'`); }); - await page.waitForFunction(`window.notification.state === 'shown'`); - await page.waitForFunction(`window.notification.type === 'success'`); }); - it('stays for 4 seconds', async () => { - await new Promise((resolve) => setTimeout(resolve, 4000)); - await page.waitForFunction(`window.notification.state === 'hidden'`); + describe('View', function() { + before('mount view', 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('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); + 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 restarts the hide timer', async () => { + const restartedTimer = await page.evaluate(() => { + window.notificationModel.show('Warp Drive Mr. Scott', 'success', 1500); + + // Spy on restartHideTimer() + 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('mouseenter')); + el.dispatchEvent(new MouseEvent('mouseleave')); + + window.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); + document.querySelector('.notification-content .mh2.pv2').click(); + }); + await page.waitForFunction(`window.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); + document.querySelector('.notification-copy-btn').click(); + }); + await page.waitForFunction(`window.__clipboardText === 'Secret message'`); + }); }); });