diff --git a/Framework/Frontend/css/src/bootstrap.css b/Framework/Frontend/css/src/bootstrap.css index c5415d655..852868660 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; } +.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 b70ce520e..eb4e28831 100644 --- a/Framework/Frontend/js/src/Notification.js +++ b/Framework/Frontend/js/src/Notification.js @@ -14,29 +14,55 @@ import Observable from './Observable.js'; import { h } from './renderer.js'; -import switchCase from './switchCase.js'; +import { copyToClipboard } from './utilities/clipboard.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 - * 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 { /** @@ -47,8 +73,24 @@ export class Notification extends Observable { this.message = ''; this.type = 'primary'; - this.state = 'hidden'; // Shown, hidden - this.timerId = 0; // Timer to auto-hide 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 + } + + /** + * Whether a notification is currently displayed. + * @return {boolean} - whether the notification is currently shown + */ + get isShown() { + return this.state === NOTIFICATION_STATES.SHOWN; } /** @@ -63,74 +105,116 @@ 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 countdown - clearTimeout(this.timerId); + this.clearTimeouts(); this.message = message; this.type = type; - this.state = 'shown'; - - // Auto-hide after duration - if (duration !== Infinity) { - this.timerId = setTimeout(() => { - this.hide(); - }, duration); - } + this.duration = duration || DEFAULT_DURATION_MS; + this.state = NOTIFICATION_STATES.SHOWN; + this.copyState = COPY_STATES.IDLE; + this.restartHideTimer(); this.notify(); } /** - * Set notification as hidden before countdown ends + * Clear any active timeout for the currently shown notification. + */ + 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() { - clearTimeout(this.timerId); - this.state = 'hidden'; + this.clearTimeouts(); + this.state = NOTIFICATION_STATES.HIDDEN; + this.copyState = COPY_STATES.IDLE; + this.hovered = false; + + this.notify(); + } + + /** + * Track whether the notification is hovered. Leaving it restarts the auto-hide countdown. + * @param {boolean} hovered - whether the pointer is over the notification + */ + setHovered(hovered) { + this.hovered = hovered; + if (!hovered) { + this.restartHideTimer(); + } + } + + /** + * (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. + */ + 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 `COPY_FEEDBACK_MS` ms. + * If the clipboard is unavailable or the write rejects, `copyState` flashes to `'failed'` instead. + * @return {Promise} + */ + async copy() { + if (!this.isShown) { + return; + } + const copiedMessage = this.message; + 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.isShown || this.message !== copiedMessage) { + return; + } + + clearTimeout(this.copyTimerId); + this.copyState = nextState; this.notify(); + + this.copyTimerId = setTimeout(() => { + this.copyState = COPY_STATES.IDLE; + this.notify(); + }, COPY_FEEDBACK_MS); } } /** - * Shows notification according to `notificationInstance`. 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} notificationInstance - instance of Notification + * @param {Notification} instance - 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('span.notification-content.br2.p2.shadow-level4', { - // ClassName: notificationInstance.message && (notificationInstance.state === 'shown' ? 'notification-open' : 'notification-close'), - onclick: () => notificationInstance.hide(), - 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)); +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: () => instance.setHovered(true), + onmouseleave: () => instance.setHovered(false), + }, [ + h('div.mh2.pv2', { onclick: () => instance.hide() }, message), + h('button.btn.notification-copy-btn', { + tabIndex: isShown ? 0 : -1, + title, + onclick: () => instance.copy(), + }, icon()), + ])); +}; diff --git a/Framework/Frontend/test/mocha-index.js b/Framework/Frontend/test/mocha-index.js index 3286ec25f..149e19bfa 100644 --- a/Framework/Frontend/test/mocha-index.js +++ b/Framework/Frontend/test/mocha-index.js @@ -220,49 +220,121 @@ 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('View', function() { + before('mount view', async () => { + await page.evaluate(() => { + notificationModel = new Notification(); + mount(document.body, (model) => notification(model), notificationModel); + }); + }); - 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(() => { + 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(() => { + 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('mouseenter sets hovered to true and prevents auto-hide', async () => { + await page.evaluate(() => { + notificationModel.show('Notification Text', 'success', 1000); + const el = document.querySelector('.notification-content'); + el.dispatchEvent(new MouseEvent('mouseenter')); + }); + await page.waitForFunction(() => notificationModel.hovered === true); + + await new Promise((resolve) => setTimeout(resolve, 1500)); + await page.waitForFunction(() => notificationModel.state === 'shown'); }); - 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'`); + it('mouseleave sets hovered to false and restarts the hide timer', async () => { + const restartedTimer = await page.evaluate(() => { + notificationModel.show('Notification Text', 'success', 1500); + + let timerChanged = false; + const originalRestart = notificationModel.restartHideTimer; + 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')); + + notificationModel.restartHideTimer = originalRestart; + + return timerChanged; + }); + + assert.deepStrictEqual(restartedTimer, true); + }); + + 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(); + }); + await page.waitForFunction(() => notificationModel.state === 'hidden'); + }); + + it('clicking the copy button writes the current message to clipboard', async () => { + await page.evaluate(() => { + notificationModel.show('Secret message', 'success', 5000); + document.querySelector('.notification-copy-btn').click(); + }); + await page.waitForFunction(() => window.__clipboardText === 'Secret message'); + }); + + it('doesn\'t 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); + }); }); });