diff --git a/InfoLogger/public/Model.js b/InfoLogger/public/Model.js index fbd4d5cb3..4bd33d59d 100644 --- a/InfoLogger/public/Model.js +++ b/InfoLogger/public/Model.js @@ -390,6 +390,16 @@ export default class Model extends Observable { this.router.go(this.log.filter.queryString, true, true); } + /** + * Get the shareable URL with the current filter query string + * @returns {string} - the shareable URL + */ + get shareableURL() { + const url = this.router.getUrl(); + url.search = this.log.filter.queryString; + return url.href; + } + /** * Toggle inspector on the right */ diff --git a/InfoLogger/public/index.js b/InfoLogger/public/index.js index 7b689a2b8..ec64790e8 100644 --- a/InfoLogger/public/index.js +++ b/InfoLogger/public/index.js @@ -19,13 +19,14 @@ sessionService.loadAndHideParameters(); window.sessionService = sessionService; // Import MVC -import { mount } from '/js/src/index.js'; +import { mount, StatefulComponent } from '/js/src/index.js'; import view from './view.js'; import Model from './Model.js'; // Start application const model = new Model(); const debug = true; // shows when redraw is done +StatefulComponent.useRenderer(model); // Register the model for the stateful components mount(document.body, view, model, debug); // Expose model to interact with it the browser's console diff --git a/InfoLogger/public/log/commandLogs.js b/InfoLogger/public/log/commandLogs.js index 06ca8932f..1fa7484f7 100644 --- a/InfoLogger/public/log/commandLogs.js +++ b/InfoLogger/public/log/commandLogs.js @@ -20,6 +20,7 @@ import { h, iconMagnifyingGlass, iconPlus, iconMinus, + CopyToClipboardComponent, } from '/js/src/index.js'; import { BUTTON } from '../constants/button-states.const.js'; import { MODE } from '../constants/mode.const.js'; @@ -67,8 +68,28 @@ export const commandLogs = (model) => [ ]), h('', downloadButtonGroup(model.log)), h('', zoomButtonGroup(model.zoom)), + copyURLButton(model.shareableURL, model.notification), ]; +/** + * A button component that lets the user copy the url + * + * @param {string} url - the url string to be appended to the URL + * @returns {Component} the copy button component + */ +const copyURLButton = (url, notification) => h( + CopyToClipboardComponent, + { + // Copy the non-debounced URL with the current query string + value: url, + id: 'url', + className: '', + style: { minWidth: '100px' }, + onFailure: ({ message }) => notification.show(`Could not copy URL: ${message}`, 'danger', 3000), + }, + 'Copy URL', +); + /** * Group of buttons for switching between Query and Live modes. * @param {Model} model - root model of the application diff --git a/InfoLogger/test/mocha-index.js b/InfoLogger/test/mocha-index.js index b8e50c737..c94df8820 100644 --- a/InfoLogger/test/mocha-index.js +++ b/InfoLogger/test/mocha-index.js @@ -115,6 +115,7 @@ describe('InfoLogger', function () { require('./public/status-bar-mocha'); require('./public/zoom.mocha'); require('./public/log-context-menu-mocha'); + require('./public/copy-url-btn-mocha'); after(async () => { await browser.close(); diff --git a/InfoLogger/test/public/context-menu-test-utils.js b/InfoLogger/test/public/context-menu-test-utils.js index 26dd31042..e66d66325 100644 --- a/InfoLogger/test/public/context-menu-test-utils.js +++ b/InfoLogger/test/public/context-menu-test-utils.js @@ -12,22 +12,14 @@ * or submit itself to any jurisdiction. */ +const { waitForNextRender } = require('../utils/utils.js'); + const isContextMenuOpen = async (page) => await page.evaluate(() => window.model.log.contextMenu.isOpen); const getMenuActionLabels = async (page) => page.evaluate(() => Array.from(document.querySelectorAll('.cell-context-menu-item .ph2.w-100')) .map((el) => el.textContent.trim())); -/* - * A stale menu from a previous test can already satisfy a waitForSelector check - * before the pending redraw (reflecting the new state) has actually run. - * Waiting for two animation frames guarantees the debounced redraw has fired - * at least once since the mutation. - */ -const waitForNextRender = (page) => page.evaluate(() => new Promise((resolve) => { - requestAnimationFrame(() => requestAnimationFrame(resolve)); -})); - const openContextMenu = async (page, field, value, x, y) => { await page.evaluate((field, value, x, y) => { window.model.log.contextMenu.show(field, value, x, y); diff --git a/InfoLogger/test/public/copy-url-btn-mocha.js b/InfoLogger/test/public/copy-url-btn-mocha.js new file mode 100644 index 000000000..c69ed038c --- /dev/null +++ b/InfoLogger/test/public/copy-url-btn-mocha.js @@ -0,0 +1,82 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +const assert = require('assert'); +const test = require('../mocha-index'); + +const { waitForNextRender } = require('../utils/utils.js'); + +describe('Copy URL button test-suite', async () => { + let baseUrl = null; + let page = null; + + before(async () => { + ({ helpers: { baseUrl }, page } = test); + await page.browser().defaultBrowserContext().setPermission( + new URL(baseUrl).origin, + { permission: { name: 'clipboard-read' }, state: 'granted' }, + { permission: { name: 'clipboard-write' }, state: 'granted' }, + ); + await page.goto(baseUrl, { waitUntil: 'networkidle0' }); + }); + + after(async () => { + await page.browser().defaultBrowserContext().clearPermissionOverrides(); + await page.goto(baseUrl, { waitUntil: 'networkidle0' }); + }); + + it('should display the button with the correct label', async () => { + const button = await page.$('#copy-url'); + const label = await page.evaluate((el) => el.textContent, button); + assert.strictEqual(label, 'Copy URL'); + }); + + it('should copy a URL carrying the active filter', async () => { + await page.evaluate(() => { + model.log.filter.setCriteria('message', 'match', 'needle'); + model.notify(); + }); + await waitForNextRender(page); + await page.click('#copy-url'); + const copiedText = await page.evaluate(() => navigator.clipboard.readText()); + const expectedUrl = `${baseUrl}?q=%7B%22message%22%3A%7B%22match%22` + + '%3A%22needle%22%7D%2C%22severity%22%3A%7B%22in%22%3A%22I%20W%20E%20F%22%7D%7D'; + assert.strictEqual(copiedText, expectedUrl); + }); + + it('should display a notification on copy failure', async () => { + await page.evaluate(() => { + model.notification.hide(); + Object.defineProperty(navigator, 'clipboard', { + value: { + writeText: () => Promise.reject(new Error('Simulated copy failure')), + }, + configurable: true, + }); + }); + + await page.click('#copy-url'); + + await page.waitForFunction(() => model.notification.state === 'shown'); + const notification = await page.evaluate(() => ({ + message: model.notification.message, + type: model.notification.type, + })); + + assert.strictEqual(notification.message, 'Could not copy URL: Simulated copy failure'); + assert.strictEqual(notification.type, 'danger'); + + await page.evaluate(() => delete navigator.clipboard.writeText); + }); +}); diff --git a/InfoLogger/test/utils/utils.js b/InfoLogger/test/utils/utils.js index d4a21ae58..d2addae2b 100644 --- a/InfoLogger/test/utils/utils.js +++ b/InfoLogger/test/utils/utils.js @@ -44,7 +44,18 @@ async function waitForTextInElement(page, selector, text) { ); } +/* + * A stale element from a previous test can already satisfy a waitForSelector check + * before the pending redraw (reflecting the new state) has actually run. + * Waiting for two animation frames guarantees the redraw has fired at least once + * since the mutation. + */ +const waitForNextRender = (page) => page.evaluate(() => new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(resolve)); +})); + module.exports = { injectLogs, waitForTextInElement, + waitForNextRender, };