Commit a205f845 authored by Miguel Rincon's avatar Miguel Rincon

Add createAlert as alternative to createFlash

This change adds a new "createAlert" function to render alerts directly
on the top of the page. This new function serves as a Pajamas-compliant
alternative to "createFlash".

It adds the new "createAlert" in the admin runners page.

Changelog: changed
parent f3b505ae
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,
};
<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);
},
......
......@@ -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) {
......
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('&lt;script&gt;alert("a");&lt;/script&gt;');
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('&lt;script&gt;alert("a")&lt;/script&gt;');
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('&lt;script&gt;alert("a")&lt;/script&gt;');
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('&lt;script&gt;alert("a")&lt;/script&gt;');
expect(html).not.toContain('<script>alert("a")</script>');
});
it('calls actionConfig clickHandler on click', () => {
......
......@@ -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 () => {
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment