diff --git a/app/assets/javascripts/flash.js b/app/assets/javascripts/flash.js index f0ef55f73ebee192ccb0eed844b54aff9f276772..d9c2e55cffe2cbe3d37b2e6d3b9defcef692f7e8 100644 --- a/app/assets/javascripts/flash.js +++ b/app/assets/javascripts/flash.js @@ -1,5 +1,8 @@ import * as Sentry from '@sentry/browser'; import { escape } from 'lodash'; +import Vue from 'vue'; +import { GlAlert } from '@gitlab/ui'; +import { __ } from '~/locale'; import { spriteIcon } from './lib/utils/common_utils'; const FLASH_TYPES = { @@ -9,6 +12,12 @@ const FLASH_TYPES = { WARNING: 'warning', }; +const VARIANT_SUCCESS = 'success'; +const VARIANT_WARNING = 'warning'; +const VARIANT_DANGER = 'danger'; +const VARIANT_INFO = 'info'; +const VARIANT_TIP = 'tip'; + const FLASH_CLOSED_EVENT = 'flashClosed'; const getCloseEl = (flashEl) => { @@ -68,6 +77,126 @@ const addDismissFlashClickListener = (flashEl, fadeTransition) => { getCloseEl(flashEl)?.addEventListener('click', () => hideFlash(flashEl, fadeTransition)); }; +/** + * Render an alert at the top of the page, or, optionally an + * arbitrary existing container. + * + * This alert is always dismissible. + * + * Usage: + * + * 1. Render a new alert + * + * import { createAlert, ALERT_VARIANTS } from '~/flash'; + * + * createAlert({ message: 'My error message' }); + * createAlert({ message: 'My warning message', variant: ALERT_VARIANTS.WARNING }); + * + * 2. Dismiss this alert programmatically + * + * const alert = createAlert({ message: 'Message' }); + * + * // ... + * + * alert.dismiss(); + * + * 3. Respond to the alert being dismissed + * + * createAlert({ message: 'Message', onDismiss: () => { ... }}); + * + * @param {Object} options Options to control the flash message + * @param {String} options.message Alert message text + * @param {String?} options.variant Which GlAlert variant to use, should be VARIANT_SUCCESS, VARIANT_WARNING, VARIANT_DANGER, VARIANT_INFO or VARIANT_TIP. Defaults to VARIANT_DANGER. + * @param {Object?} options.parent Reference to parent element under which alert needs to appear. Defaults to `document`. + * @param {Function?} options.onDismiss Handler to call when this alert is dismissed. + * @param {Object?} options.containerSelector Selector for the container of the alert + * @param {Object?} options.primaryButton Object describing primary button of alert + * @param {String?} link Href of primary button + * @param {String?} text Text of primary button + * @param {Function?} clickHandler Handler to call when primary button is clicked on. The click event is sent as an argument. + * @param {Object?} options.secondaryButton Object describing secondary button of alert + * @param {String?} link Href of secondary button + * @param {String?} text Text of secondary button + * @param {Function?} clickHandler Handler to call when secondary button is clicked on. The click event is sent as an argument. + * @param {Boolean?} options.captureError Whether to send error to Sentry + * @param {Object} options.error Error to be captured in Sentry + * @returns + */ +const createAlert = function createAlert({ + message, + variant = VARIANT_DANGER, + parent = document, + containerSelector = '.flash-container', + primaryButton = null, + secondaryButton = null, + onDismiss = null, + captureError = false, + error = null, +}) { + if (captureError && error) Sentry.captureException(error); + + const alertContainer = parent.querySelector(containerSelector); + if (!alertContainer) return null; + + const el = document.createElement('div'); + alertContainer.appendChild(el); + + return new Vue({ + el, + components: { + GlAlert, + }, + methods: { + /** + * Public method to dismiss this alert and removes + * this Vue instance. + */ + dismiss() { + if (onDismiss) { + onDismiss(); + } + this.$destroy(); + this.$el.parentNode.removeChild(this.$el); + }, + }, + render(h) { + const on = {}; + + on.dismiss = () => { + this.dismiss(); + }; + + if (primaryButton?.clickHandler) { + on.primaryAction = (e) => { + primaryButton.clickHandler(e); + }; + } + if (secondaryButton?.clickHandler) { + on.secondaryAction = (e) => { + secondaryButton.clickHandler(e); + }; + } + + return h( + GlAlert, + { + props: { + dismissible: true, + dismissLabel: __('Dismiss'), + variant, + primaryButtonLink: primaryButton?.link, + primaryButtonText: primaryButton?.text, + secondaryButtonLink: secondaryButton?.link, + secondaryButtonText: secondaryButton?.text, + }, + on, + }, + message, + ); + }, + }); +}; + /* * Flash banner supports different types of Flash configurations * along with ability to provide actionConfig which can be used to show @@ -82,8 +211,8 @@ const addDismissFlashClickListener = (flashEl, fadeTransition) => { * @param {String} title Title of action * @param {Function} clickHandler Method to call when action is clicked on * @param {Boolean} options.fadeTransition Boolean to determine whether to fade the alert out - * @param {Boolean} options.captureError Boolean to determine whether to send error to sentry - * @param {Object} options.error Error to be captured in sentry + * @param {Boolean} options.captureError Boolean to determine whether to send error to Sentry + * @param {Object} options.error Error to be captured in Sentry */ const createFlash = function createFlash({ message, @@ -134,4 +263,10 @@ export { addDismissFlashClickListener, FLASH_TYPES, FLASH_CLOSED_EVENT, + createAlert, + VARIANT_SUCCESS, + VARIANT_WARNING, + VARIANT_DANGER, + VARIANT_INFO, + VARIANT_TIP, }; diff --git a/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue b/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue index f8220553db60df35efb42d70a4d356da69e3b1e8..2bb4ae7d34ebbce0981c6485dd8b15b8e7bf507d 100644 --- a/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue +++ b/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue @@ -1,6 +1,6 @@ <script> import { GlBadge, GlLink } from '@gitlab/ui'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { fetchPolicies } from '~/lib/graphql'; import { updateHistory } from '~/lib/utils/url_utility'; @@ -95,7 +95,7 @@ export default { }; }, error(error) { - createFlash({ message: I18N_FETCH_ERROR }); + createAlert({ message: I18N_FETCH_ERROR }); this.reportToSentry(error); }, diff --git a/app/assets/stylesheets/framework/flash.scss b/app/assets/stylesheets/framework/flash.scss index 30a1c8af4142aad2173567a921566a687f71bca9..b51daf0e4dc1742da6bdd48671ce81167a529b7b 100644 --- a/app/assets/stylesheets/framework/flash.scss +++ b/app/assets/stylesheets/framework/flash.scss @@ -75,6 +75,10 @@ $notification-box-shadow-color: rgba(0, 0, 0, 0.25); .flash-action { display: inline-block; } + + .gl-alert { + @include gl-my-4; + } } @include media-breakpoint-down(sm) { diff --git a/spec/frontend/flash_spec.js b/spec/frontend/flash_spec.js index fc736f2d15505313ecdc282baf701c5f0f2177f5..d5451ec20648a9d93eda5202af8b25dd0003e7f1 100644 --- a/spec/frontend/flash_spec.js +++ b/spec/frontend/flash_spec.js @@ -1,9 +1,12 @@ import * as Sentry from '@sentry/browser'; +import { setHTMLFixture } from 'helpers/fixtures'; import createFlash, { hideFlash, addDismissFlashClickListener, FLASH_TYPES, FLASH_CLOSED_EVENT, + createAlert, + VARIANT_WARNING, } from '~/flash'; jest.mock('@sentry/browser'); @@ -68,6 +71,236 @@ describe('Flash', () => { }); }); + describe('createAlert', () => { + const mockMessage = 'a message'; + let alert; + + describe('no flash-container', () => { + it('does not add to the DOM', () => { + alert = createAlert({ message: mockMessage }); + + expect(alert).toBeNull(); + expect(document.querySelector('.gl-alert')).toBeNull(); + }); + }); + + describe('with flash-container', () => { + beforeEach(() => { + setHTMLFixture('<div class="flash-container"></div>'); + }); + + afterEach(() => { + if (alert) { + alert.$destroy(); + } + document.querySelector('.flash-container')?.remove(); + }); + + it('adds alert element into the document by default', () => { + alert = createAlert({ message: mockMessage }); + + expect(document.querySelector('.flash-container').textContent.trim()).toBe(mockMessage); + expect(document.querySelector('.flash-container .gl-alert')).not.toBeNull(); + }); + + it('adds flash of a warning type', () => { + alert = createAlert({ message: mockMessage, variant: VARIANT_WARNING }); + + expect( + document.querySelector('.flash-container .gl-alert.gl-alert-warning'), + ).not.toBeNull(); + }); + + it('escapes text', () => { + alert = createAlert({ message: '<script>alert("a");</script>' }); + + const html = document.querySelector('.flash-container').innerHTML; + + expect(html).toContain('<script>alert("a");</script>'); + expect(html).not.toContain('<script>alert("a");</script>'); + }); + + it('adds alert into specified container', () => { + setHTMLFixture(` + <div class="my-alert-container"></div> + <div class="my-other-container"></div> + `); + + alert = createAlert({ message: mockMessage, containerSelector: '.my-alert-container' }); + + expect(document.querySelector('.my-alert-container .gl-alert')).not.toBeNull(); + expect(document.querySelector('.my-alert-container').innerText.trim()).toBe(mockMessage); + + expect(document.querySelector('.my-other-container .gl-alert')).toBeNull(); + expect(document.querySelector('.my-other-container').innerText.trim()).toBe(''); + }); + + it('adds alert into specified parent', () => { + setHTMLFixture(` + <div id="my-parent"> + <div class="flash-container"></div> + </div> + <div id="my-other-parent"> + <div class="flash-container"></div> + </div> + `); + + alert = createAlert({ message: mockMessage, parent: document.getElementById('my-parent') }); + + expect(document.querySelector('#my-parent .flash-container .gl-alert')).not.toBeNull(); + expect(document.querySelector('#my-parent .flash-container').innerText.trim()).toBe( + mockMessage, + ); + + expect(document.querySelector('#my-other-parent .flash-container .gl-alert')).toBeNull(); + expect(document.querySelector('#my-other-parent .flash-container').innerText.trim()).toBe( + '', + ); + }); + + it('removes element after clicking', () => { + alert = createAlert({ message: mockMessage }); + + expect(document.querySelector('.flash-container .gl-alert')).not.toBeNull(); + + document.querySelector('.gl-dismiss-btn').click(); + + expect(document.querySelector('.flash-container .gl-alert')).toBeNull(); + }); + + it('does not capture error using Sentry', () => { + alert = createAlert({ + message: mockMessage, + captureError: false, + error: new Error('Error!'), + }); + + expect(Sentry.captureException).not.toHaveBeenCalled(); + }); + + it('captures error using Sentry', () => { + alert = createAlert({ + message: mockMessage, + captureError: true, + error: new Error('Error!'), + }); + + expect(Sentry.captureException).toHaveBeenCalledWith(expect.any(Error)); + expect(Sentry.captureException).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Error!', + }), + ); + }); + + describe('with buttons', () => { + const findAlertAction = () => document.querySelector('.flash-container .gl-alert-action'); + + it('adds primary button', () => { + alert = createAlert({ + message: mockMessage, + primaryButton: { + text: 'Ok', + }, + }); + + expect(findAlertAction().textContent.trim()).toBe('Ok'); + }); + + it('creates link with href', () => { + alert = createAlert({ + message: mockMessage, + primaryButton: { + link: '/url', + text: 'Ok', + }, + }); + + const action = findAlertAction(); + + expect(action.textContent.trim()).toBe('Ok'); + expect(action.nodeName).toBe('A'); + expect(action.getAttribute('href')).toBe('/url'); + }); + + it('create button as href when no href is present', () => { + alert = createAlert({ + message: mockMessage, + primaryButton: { + text: 'Ok', + }, + }); + + const action = findAlertAction(); + + expect(action.nodeName).toBe('BUTTON'); + expect(action.getAttribute('href')).toBe(null); + }); + + it('escapes the title text', () => { + alert = createAlert({ + message: mockMessage, + primaryButton: { + text: '<script>alert("a")</script>', + }, + }); + + const html = findAlertAction().innerHTML; + + expect(html).toContain('<script>alert("a")</script>'); + expect(html).not.toContain('<script>alert("a")</script>'); + }); + + it('calls actionConfig clickHandler on click', () => { + const clickHandler = jest.fn(); + + alert = createAlert({ + message: mockMessage, + primaryButton: { + text: 'Ok', + clickHandler, + }, + }); + + expect(clickHandler).toHaveBeenCalledTimes(0); + + findAlertAction().click(); + + expect(clickHandler).toHaveBeenCalledTimes(1); + expect(clickHandler).toHaveBeenCalledWith(expect.any(MouseEvent)); + }); + }); + + describe('Alert API', () => { + describe('dismiss', () => { + it('dismiss programmatically with .dismiss()', () => { + expect(document.querySelector('.gl-alert')).toBeNull(); + + alert = createAlert({ message: mockMessage }); + + expect(document.querySelector('.gl-alert')).not.toBeNull(); + + alert.dismiss(); + + expect(document.querySelector('.gl-alert')).toBeNull(); + }); + + it('calls onDismiss when dismissed', () => { + const dismissHandler = jest.fn(); + + alert = createAlert({ message: mockMessage, onDismiss: dismissHandler }); + + expect(dismissHandler).toHaveBeenCalledTimes(0); + + alert.dismiss(); + + expect(dismissHandler).toHaveBeenCalledTimes(1); + }); + }); + }); + }); + }); + describe('createFlash', () => { const message = 'test'; const fadeTransition = false; @@ -91,7 +324,7 @@ describe('Flash', () => { describe('with flash-container', () => { beforeEach(() => { - setFixtures( + setHTMLFixture( '<div class="content-wrapper js-content-wrapper"><div class="flash-container"></div></div>', ); }); @@ -115,11 +348,12 @@ describe('Flash', () => { }); it('escapes text', () => { - createFlash({ ...defaultParams, message: '<script>alert("a");</script>' }); + createFlash({ ...defaultParams, message: '<script>alert("a")</script>' }); - expect(document.querySelector('.flash-text').textContent.trim()).toBe( - '<script>alert("a");</script>', - ); + const html = document.querySelector('.flash-text').innerHTML; + + expect(html).toContain('<script>alert("a")</script>'); + expect(html).not.toContain('<script>alert("a")</script>'); }); it('adds flash into specified parent', () => { @@ -193,8 +427,10 @@ describe('Flash', () => { }, }); - expect(findFlashAction().href).toBe(`${window.location}testing`); - expect(findFlashAction().textContent.trim()).toBe('test'); + const action = findFlashAction(); + + expect(action.href).toBe(`${window.location}testing`); + expect(action.textContent.trim()).toBe('test'); }); it('uses hash as href when no href is present', () => { @@ -227,7 +463,10 @@ describe('Flash', () => { }, }); - expect(findFlashAction().textContent.trim()).toBe('<script>alert("a")</script>'); + const html = findFlashAction().innerHTML; + + expect(html).toContain('<script>alert("a")</script>'); + expect(html).not.toContain('<script>alert("a")</script>'); }); it('calls actionConfig clickHandler on click', () => { diff --git a/spec/frontend/runner/admin_runners/admin_runners_app_spec.js b/spec/frontend/runner/admin_runners/admin_runners_app_spec.js index 7015fe809b048d0b0d742625285e9cd695e4bfea..19c3a4b8e3cc75ff7e729e71c570f1121cb7c5b2 100644 --- a/spec/frontend/runner/admin_runners/admin_runners_app_spec.js +++ b/spec/frontend/runner/admin_runners/admin_runners_app_spec.js @@ -5,7 +5,7 @@ import createMockApollo from 'helpers/mock_apollo_helper'; import setWindowLocation from 'helpers/set_window_location_helper'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { updateHistory } from '~/lib/utils/url_utility'; @@ -241,7 +241,7 @@ describe('AdminRunnersApp', () => { }); it('error is shown to the user', async () => { - expect(createFlash).toHaveBeenCalledTimes(1); + expect(createAlert).toHaveBeenCalledTimes(1); }); it('error is reported to sentry', async () => {