summaryrefslogtreecommitdiff
path: root/app/assets/javascripts/flash.js
blob: fa605f8c056a744e808a1f9b38a9a4045462f488 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
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 = {
  ALERT: 'alert',
  NOTICE: 'notice',
  SUCCESS: 'success',
  WARNING: 'warning',
};

const VARIANT_SUCCESS = 'success';
const VARIANT_WARNING = 'warning';
const VARIANT_DANGER = 'danger';
const VARIANT_INFO = 'info';
const VARIANT_TIP = 'tip';

const TYPE_TO_VARIANT = {
  [FLASH_TYPES.ALERT]: VARIANT_DANGER,
  [FLASH_TYPES.NOTICE]: VARIANT_INFO,
  [FLASH_TYPES.SUCCESS]: VARIANT_SUCCESS,
  [FLASH_TYPES.WARNING]: VARIANT_WARNING,
};

const FLASH_CLOSED_EVENT = 'flashClosed';

const getCloseEl = (flashEl) => {
  return flashEl.querySelector('.js-close-icon');
};

const hideFlash = (flashEl, fadeTransition = true) => {
  if (fadeTransition) {
    Object.assign(flashEl.style, {
      transition: 'opacity 0.15s',
      opacity: '0',
    });
  }

  flashEl.addEventListener(
    'transitionend',
    () => {
      flashEl.remove();
      window.dispatchEvent(new Event('resize'));
      flashEl.dispatchEvent(new Event(FLASH_CLOSED_EVENT));
      if (document.body.classList.contains('flash-shown'))
        document.body.classList.remove('flash-shown');
    },
    {
      once: true,
      passive: true,
    },
  );

  if (!fadeTransition) flashEl.dispatchEvent(new Event('transitionend'));
};

const createAction = (config) => `
  <a
    href="${config.href || '#'}"
    class="flash-action"
    ${config.href ? '' : 'role="button"'}
  >
    ${escape(config.title)}
  </a>
`;

const createFlashEl = (message, type) => `
  <div class="flash-${type}" data-testid="alert-${TYPE_TO_VARIANT[type]}">
    <div class="flash-text">
      ${escape(message)}
      <div class="close-icon-wrapper js-close-icon">
        ${spriteIcon('close', 'close-icon')}
      </div>
    </div>
  </div>
`;

const addDismissFlashClickListener = (flashEl, fadeTransition) => {
  // There are some flash elements which do not have a closeEl.
  // https://gitlab.com/gitlab-org/gitlab/blob/763426ef344488972eb63ea5be8744e0f8459e6b/ee/app/views/layouts/header/_read_only_banner.html.haml
  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,
          },
          attrs: {
            'data-testid': `alert-${variant}`,
          },
          on,
        },
        message,
      );
    },
  });
};

/*
 *  Flash banner supports different types of Flash configurations
 *  along with ability to provide actionConfig which can be used to show
 *  additional action or link on banner next to message
 *
 *  @param {Object} options                   Options to control the flash message
 *  @param {String} options.message           Flash message text
 *  @param {String} options.type              Type of Flash, it can be `notice`, `success`, `warning` or `alert` (default)
 *  @param {Object} options.parent            Reference to parent element under which Flash needs to appear
 *  @param {Object} options.actionConfig      Map of config to show action on banner
 *    @param {String} href                    URL to which action config should point to (default: '#')
 *    @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
 */
const createFlash = function createFlash({
  message,
  type = FLASH_TYPES.ALERT,
  parent = document,
  actionConfig = null,
  fadeTransition = true,
  addBodyClass = false,
  captureError = false,
  error = null,
}) {
  const flashContainer = parent.querySelector('.flash-container');

  if (!flashContainer) return null;

  flashContainer.innerHTML = createFlashEl(message, type);

  const flashEl = flashContainer.querySelector(`.flash-${type}`);

  if (actionConfig) {
    flashEl.insertAdjacentHTML('beforeend', createAction(actionConfig));

    if (actionConfig.clickHandler) {
      flashEl
        .querySelector('.flash-action')
        .addEventListener('click', (e) => actionConfig.clickHandler(e));
    }
  }

  addDismissFlashClickListener(flashEl, fadeTransition);

  flashContainer.classList.add('gl-display-block');

  if (addBodyClass) document.body.classList.add('flash-shown');

  if (captureError && error) Sentry.captureException(error);

  flashContainer.close = () => {
    getCloseEl(flashEl).click();
  };

  return flashContainer;
};

export {
  createFlash as default,
  hideFlash,
  addDismissFlashClickListener,
  FLASH_TYPES,
  FLASH_CLOSED_EVENT,
  createAlert,
  VARIANT_SUCCESS,
  VARIANT_WARNING,
  VARIANT_DANGER,
  VARIANT_INFO,
  VARIANT_TIP,
};