Modal.ts 19.3 KB
Newer Older
1
2
3
4
5
6
7
8
9
10
11
12
13
14
/*
 * This file is part of the TYPO3 CMS project.
 *
 * It is free software; you can redistribute it and/or modify it under
 * the terms of the GNU General Public License, either version 2
 * of the License, or any later version.
 *
 * For the full copyright and license information, please read the
 * LICENSE.txt file that was distributed with this source code.
 *
 * The TYPO3 project - inspiring people to share!
 */

import 'bootstrap';
15
import $ from 'jquery';
16
17
import {AjaxResponse} from 'TYPO3/CMS/Core/Ajax/AjaxResponse';
import {AbstractAction} from './ActionButton/AbstractAction';
18
import {ModalResponseEvent} from 'TYPO3/CMS/Backend/ModalInterface';
19
20
21
import {SeverityEnum} from './Enum/Severity';
import AjaxRequest = require('TYPO3/CMS/Core/Ajax/AjaxRequest');
import SecurityUtility = require('TYPO3/CMS/Core/SecurityUtility');
22
23
24
25
26
27
28
29
30
31
32
import Icons = require('./Icons');
import Severity = require('./Severity');

enum Identifiers {
  modal = '.t3js-modal',
  content = '.t3js-modal-content',
  title = '.t3js-modal-title',
  close = '.t3js-modal-close',
  body = '.t3js-modal-body',
  footer = '.t3js-modal-footer',
  iframe = '.t3js-modal-iframe',
33
  iconPlaceholder = '.t3js-modal-icon-placeholder',
34
35
36
37
38
39
40
}

enum Sizes {
  small = 'small',
  default = 'default',
  medium = 'medium',
  large = 'large',
41
  full = 'full',
42
43
44
45
46
}

enum Styles {
  default = 'default',
  light = 'light',
47
  dark = 'dark',
48
49
50
51
52
}

enum Types {
  default = 'default',
  ajax = 'ajax',
53
  iframe = 'iframe',
54
55
56
57
58
59
60
61
62
63
}

interface Button {
  text: string;
  active: boolean;
  btnClass: string;
  name: string;
  trigger: (e: JQueryEventObject) => {};
  dataAttributes: { [key: string]: string };
  icon: string;
64
  action: AbstractAction;
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
}

interface Configuration {
  type: Types;
  title: string;
  content: string | JQuery;
  severity: SeverityEnum;
  buttons: Array<Button>;
  style: string;
  size: string;
  additionalCssClasses: Array<string>;
  callback: Function;
  ajaxCallback: Function;
  ajaxTarget: string;
}

/**
 * Module: TYPO3/CMS/Backend/Modal
 * API for modal windows powered by Twitter Bootstrap.
 */
class Modal {
  public readonly sizes: any = Sizes;
  public readonly styles: any = Styles;
  public readonly types: any = Types;
  public currentModal: JQuery = null;
  private instances: Array<JQuery> = [];
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
  private readonly $template: JQuery = $(`
    <div class="t3js-modal modal fade">
        <div class="modal-dialog">
            <div class="t3js-modal-content modal-content">
                <div class="modal-header">
                    <h4 class="t3js-modal-title modal-title"></h4>
                    <button class="t3js-modal-close close">
                        <span aria-hidden="true">
                            <span class="t3js-modal-icon-placeholder" data-icon="actions-close"></span>
                        </span>
                        <span class="sr-only"></span>
                    </button>
                </div>
                <div class="t3js-modal-body modal-body"></div>
                <div class="t3js-modal-footer modal-footer"></div>
            </div>
        </div>
    </div>`
109
110
111
112
113
114
115
116
117
118
119
120
121
  );

  private defaultConfiguration: Configuration = {
    type: Types.default,
    title: 'Information',
    content: 'No content provided, please check your <code>Modal</code> configuration.',
    severity: SeverityEnum.notice,
    buttons: [],
    style: Styles.default,
    size: Sizes.default,
    additionalCssClasses: [],
    callback: $.noop(),
    ajaxCallback: $.noop(),
122
    ajaxTarget: null,
123
124
  };

125
126
  private readonly securityUtility: SecurityUtility;

127
128
129
130
131
132
133
134
135
136
137
  private static resolveEventNameTargetElement(evt: Event): HTMLElement | null {
    const target = evt.target as HTMLElement;
    const currentTarget = evt.currentTarget as HTMLElement;
    if (target.dataset && target.dataset.eventName) {
      return target;
    } else if (currentTarget.dataset && currentTarget.dataset.eventName) {
      return currentTarget;
    }
    return null;
  }

138
  private static createModalResponseEventFromElement(element: HTMLElement, result: boolean): ModalResponseEvent | null {
139
    if (!element || !element.dataset.eventName) {
140
141
142
143
      return null;
    }
    return new CustomEvent(
      element.dataset.eventName, {
144
        bubbles: true,
145
146
147
148
        detail: { result, payload: element.dataset.eventPayload || null }
      });
  }

149
150
  constructor(securityUtility: SecurityUtility) {
    this.securityUtility = securityUtility;
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
    $(document).on('modal-dismiss', this.dismiss);
    this.initializeMarkupTrigger(document);
  }

  /**
   * Close the current open modal
   */
  public dismiss(): void {
    if (this.currentModal) {
      this.currentModal.modal('hide');
    }
  }

  /**
   * Shows a confirmation dialog
   * Events:
   * - button.clicked
   * - confirm.button.cancel
   * - confirm.button.ok
   *
   * @param {string} title The title for the confirm modal
   * @param {string | JQuery} content The content for the conform modal, e.g. the main question
   * @param {SeverityEnum} severity Default SeverityEnum.warning
   * @param {Array<Button>} buttons An array with buttons, default no buttons
   * @param {Array<string>} additionalCssClasses Additional css classes to add to the modal
   * @returns {JQuery}
   */
178
179
180
181
182
183
184
  public confirm(
    title: string,
    content: string | JQuery,
    severity: SeverityEnum = SeverityEnum.warning,
    buttons: Array<Object> = [],
    additionalCssClasses?: Array<string>,
  ): JQuery {
185
186
187
188
189
190
    if (buttons.length === 0) {
      buttons.push(
        {
          text: $(this).data('button-close-text') || TYPO3.lang['button.cancel'] || 'Cancel',
          active: true,
          btnClass: 'btn-default',
191
          name: 'cancel',
192
193
194
195
        },
        {
          text: $(this).data('button-ok-text') || TYPO3.lang['button.ok'] || 'OK',
          btnClass: 'btn-' + Severity.getCssClass(severity),
196
197
          name: 'ok',
        },
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
      );
    }

    return this.advanced({
      title,
      content,
      severity,
      buttons,
      additionalCssClasses,
      callback: (currentModal: JQuery): void => {
        currentModal.on('button.clicked', (e: JQueryEventObject): void => {
          if (e.target.getAttribute('name') === 'cancel') {
            $(e.currentTarget).trigger('confirm.button.cancel');
          } else if (e.target.getAttribute('name') === 'ok') {
            $(e.currentTarget).trigger('confirm.button.ok');
          }
        });
215
      },
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
    });
  }

  /**
   * Load URL with AJAX, append the content to the modal-body
   * and trigger the callback
   *
   * @param {string} title
   * @param {SeverityEnum} severity
   * @param {Array<Button>} buttons
   * @param {string} url
   * @param {Function} callback
   * @param {string} target
   * @returns {JQuery}
   */
231
232
233
234
235
236
237
  public loadUrl(
    title: string,
    severity: SeverityEnum = SeverityEnum.info,
    buttons: Array<Object>,
    url: string,
    callback?: Function,
    target?: string,
238
239
240
241
242
243
244
  ): JQuery {
    return this.advanced({
      type: Types.ajax,
      title,
      severity,
      buttons,
      ajaxCallback: callback,
245
      ajaxTarget: target,
246
      content: url,
247
248
249
250
251
252
253
254
255
256
257
258
259
    });
  }

  /**
   * Shows a dialog
   *
   * @param {string} title
   * @param {string | JQuery} content
   * @param {number} severity
   * @param {Array<Object>} buttons
   * @param {Array<string>} additionalCssClasses
   * @returns {JQuery}
   */
260
261
262
263
264
265
266
  public show(
    title: string,
    content: string | JQuery,
    severity: SeverityEnum = SeverityEnum.info,
    buttons?: Array<Object>,
    additionalCssClasses?: Array<string>,
  ): JQuery {
267
268
269
270
    return this.advanced({
      type: Types.default,
      title,
      content,
271
      severity,
272
      buttons,
273
      additionalCssClasses,
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
    });
  }

  /**
   * Loads modal by configuration
   *
   * @param {object} configuration configuration for the modal
   */
  public advanced(configuration: { [key: string]: any }): JQuery {
    // Validation of configuration
    configuration.type = typeof configuration.type === 'string' && configuration.type in Types
      ? configuration.type
      : this.defaultConfiguration.type;
    configuration.title = typeof configuration.title === 'string'
      ? configuration.title
      : this.defaultConfiguration.title;
290
291
292
    configuration.content = typeof configuration.content === 'string' || typeof configuration.content === 'object'
      ? configuration.content
      : this.defaultConfiguration.content;
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
    configuration.severity = typeof configuration.severity !== 'undefined'
      ? configuration.severity
      : this.defaultConfiguration.severity;
    configuration.buttons = <Array<Button>>configuration.buttons || this.defaultConfiguration.buttons;
    configuration.size = typeof configuration.size === 'string' && configuration.size in Sizes
      ? configuration.size
      : this.defaultConfiguration.size;
    configuration.style = typeof configuration.style === 'string' && configuration.style in Styles
      ? configuration.style
      : this.defaultConfiguration.style;
    configuration.additionalCssClasses = configuration.additionalCssClasses || this.defaultConfiguration.additionalCssClasses;
    configuration.callback = typeof configuration.callback === 'function' ? configuration.callback : this.defaultConfiguration.callback;
    configuration.ajaxCallback = typeof configuration.ajaxCallback === 'function'
      ? configuration.ajaxCallback
      : this.defaultConfiguration.ajaxCallback;
    configuration.ajaxTarget = typeof configuration.ajaxTarget === 'string'
      ? configuration.ajaxTarget
      : this.defaultConfiguration.ajaxTarget;

    return this.generate(<Configuration>configuration);
  }

315
316
317
318
319
320
  /**
   * Sets action buttons for the modal window or removed the footer, if no buttons are given.
   *
   * @param {Array<Button>} buttons
   */
  public setButtons(buttons: Array<Button>): JQuery {
321
    const modalFooter = this.currentModal.find(Identifiers.footer);
322
    if (buttons.length > 0) {
323
      modalFooter.empty();
324
325
326
327
328
329
330
331
332
333
334
335
336
337

      for (let i = 0; i < buttons.length; i++) {
        const button = buttons[i];
        const $button = $('<button />', {'class': 'btn'});
        $button.html('<span>' + this.securityUtility.encodeHtml(button.text, false) + '</span>');
        if (button.active) {
          $button.addClass('t3js-active');
        }
        if (button.btnClass !== '') {
          $button.addClass(button.btnClass);
        }
        if (button.name !== '') {
          $button.attr('name', button.name);
        }
338
339
340
341
342
343
344
345
        if (button.action) {
          $button.on('click', (): void => {
            modalFooter.find('button').not($button).addClass('disabled');
            button.action.execute($button.get(0)).then((): void => {
              this.currentModal.modal('hide');
            });
          });
        } else if (button.trigger) {
346
347
348
349
350
351
352
353
354
355
356
357
          $button.on('click', button.trigger);
        }
        if (button.dataAttributes) {
          if (Object.keys(button.dataAttributes).length > 0) {
            Object.keys(button.dataAttributes).map((value: string): any => {
              $button.attr('data-' + value, button.dataAttributes[value]);
            });
          }
        }
        if (button.icon) {
          $button.prepend('<span class="t3js-modal-icon-placeholder" data-icon="' + button.icon + '"></span>');
        }
358
        modalFooter.append($button);
359
      }
360
361
      modalFooter.show();
      modalFooter.find('button')
362
363
364
365
        .on('click', (e: JQueryEventObject): void => {
          $(e.currentTarget).trigger('button.clicked');
        });
    } else {
366
      modalFooter.hide();
367
368
369
370
371
    }

    return this.currentModal;
  }

372
373
374
375
376
377
378
379
380
  /**
   * Initialize markup with data attributes
   *
   * @param {HTMLDocument} theDocument
   */
  private initializeMarkupTrigger(theDocument: HTMLDocument): void {
    $(theDocument).on('click', '.t3js-modal-trigger', (evt: JQueryEventObject): void => {
      evt.preventDefault();
      const $element = $(evt.currentTarget);
381
      const content = $element.data('bs-content') || 'Are you sure?';
382
383
384
385
386
      const severity = typeof SeverityEnum[$element.data('severity')] !== 'undefined'
        ? SeverityEnum[$element.data('severity')]
        : SeverityEnum.info;
      let url = $element.data('url') || null;
      if (url !== null) {
387
        const separator = url.includes('?') ? '&' : '?';
388
389
390
391
392
393
394
395
396
397
        const params = $.param({data: $element.data()});
        url = url + separator + params;
      }
      this.advanced({
        type: url !== null ? Types.ajax : Types.default,
        title: $element.data('title') || 'Alert',
        content: url !== null ? url : content,
        severity,
        buttons: [
          {
398
            text: $element.data('button-close-text') || TYPO3.lang['button.close'] || 'Close',
399
400
401
402
            active: true,
            btnClass: 'btn-default',
            trigger: (): void => {
              this.currentModal.trigger('modal-dismiss');
403
404
              const eventNameTarget = Modal.resolveEventNameTargetElement(evt);
              const event = Modal.createModalResponseEventFromElement(eventNameTarget, false);
405
              if (event !== null) {
406
407
                // dispatch event at the element having `data-event-name` declared
                eventNameTarget.dispatchEvent(event);
408
              }
409
            },
410
411
          },
          {
412
            text: $element.data('button-ok-text') || TYPO3.lang['button.ok'] || 'OK',
413
414
415
            btnClass: 'btn-' + Severity.getCssClass(severity),
            trigger: (): void => {
              this.currentModal.trigger('modal-dismiss');
416
417
              const eventNameTarget = Modal.resolveEventNameTargetElement(evt);
              const event = Modal.createModalResponseEventFromElement(eventNameTarget, true);
418
              if (event !== null) {
419
420
                // dispatch event at the element having `data-event-name` declared
                eventNameTarget.dispatchEvent(event);
421
422
423
424
425
              }
              const href = $element.data('href') || $element.attr('href');
              if (href && href !== '#') {
                evt.target.ownerDocument.location.href = href;
              }
426
427
            },
          },
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
        ],
      });
    });
  }

  /**
   * @param {Configuration} configuration
   */
  private generate(configuration: Configuration): JQuery {
    const currentModal = this.$template.clone();
    if (configuration.additionalCssClasses.length > 0) {
      for (let additionalClass of configuration.additionalCssClasses) {
        currentModal.addClass(additionalClass);
      }
    }
    currentModal.addClass('modal-type-' + configuration.type);
    currentModal.addClass('modal-severity-' + Severity.getCssClass(configuration.severity));
    currentModal.addClass('modal-style-' + configuration.style);
    currentModal.addClass('modal-size-' + configuration.size);
    currentModal.attr('tabindex', '-1');
    currentModal.find(Identifiers.title).text(configuration.title);
    currentModal.find(Identifiers.close).on('click', (): void => {
      currentModal.modal('hide');
    });

    if (configuration.type === 'ajax') {
454
455
      const contentTarget = configuration.ajaxTarget ? configuration.ajaxTarget : Identifiers.body;
      const $loaderTarget = currentModal.find(contentTarget);
456
      Icons.getIcon('spinner-circle', Icons.sizes.default, null, null, Icons.markupIdentifiers.inline).then((icon: string): void => {
457
        $loaderTarget.html('<div class="modal-loading">' + icon + '</div>');
458
        new AjaxRequest(configuration.content as string).get().then(async (response: AjaxResponse): Promise<void> => {
459
460
461
462
463
          const html = await response.raw().text();
          if (!this.currentModal.parent().length) {
            // attach modal to DOM, otherwise embedded scripts are not executed by jquery append()
            this.currentModal.appendTo('body');
          }
464
465
          this.currentModal.find(contentTarget)
            .empty()
466
467
            .append(html);

468
469
470
471
472
          if (configuration.ajaxCallback) {
            configuration.ajaxCallback();
          }
          this.currentModal.trigger('modal-loaded');
        });
473
      });
474
475
476
477
478
    } else if (configuration.type === 'iframe') {
      currentModal.find(Identifiers.body).append(
        $('<iframe />', {
          src: configuration.content,
          'name': 'modal_frame',
479
480
          'class': 'modal-iframe t3js-modal-iframe',
        }),
481
482
483
      );
      currentModal.find(Identifiers.iframe).on('load', (): void => {
        currentModal.find(Identifiers.title).text(
484
          (<HTMLIFrameElement>currentModal.find(Identifiers.iframe).get(0)).contentDocument.title,
485
486
487
488
        );
      });
    } else {
      if (typeof configuration.content === 'string') {
489
        configuration.content = $('<p />').html(
490
          this.securityUtility.encodeHtml(configuration.content),
491
        );
492
493
494
495
496
497
498
499
500
501
      }
      currentModal.find(Identifiers.body).append(configuration.content);
    }

    currentModal.on('shown.bs.modal', (e: JQueryEventObject): void => {
      const $me = $(e.currentTarget);
      // focus the button which was configured as active button
      $me.find(Identifiers.footer).find('.t3js-active').first().focus();
      // Get Icons
      $me.find(Identifiers.iconPlaceholder).each((index: number, elem: Element): void => {
502
        Icons.getIcon($(elem).data('icon'), Icons.sizes.small, null, null, Icons.markupIdentifiers.inline).then((icon: string): void => {
503
504
505
506
507
508
          this.currentModal.find(Identifiers.iconPlaceholder + '[data-icon=' + $(icon).data('identifier') + ']').replaceWith(icon);
        });
      });
    });

    // Remove modal from Modal.instances when hidden
509
    currentModal.on('hidden.bs.modal', (e: JQueryEventObject): void => {
510
511
512
513
514
515
      if (this.instances.length > 0) {
        const lastIndex = this.instances.length - 1;
        this.instances.splice(lastIndex, 1);
        this.currentModal = this.instances[lastIndex - 1];
      }
      currentModal.trigger('modal-destroyed');
516
      $(e.currentTarget).remove();
517
518
519
520
521
522
523
524
525
      // Keep class modal-open on body tag as long as open modals exist
      if (this.instances.length > 0) {
        $('body').addClass('modal-open');
      }
    });

    // When modal is opened/shown add it to Modal.instances and make it Modal.currentModal
    currentModal.on('show.bs.modal', (e: JQueryEventObject): void => {
      this.currentModal = $(e.currentTarget);
526
527
      // Add buttons
      this.setButtons(configuration.buttons);
528
529
530
531
532
533
534
535
536
537
538
      this.instances.push(this.currentModal);
    });
    currentModal.on('modal-dismiss', (e: JQueryEventObject): void => {
      // Hide modal, the bs.modal events will clean up Modal.instances
      $(e.currentTarget).modal('hide');
    });

    if (configuration.callback) {
      configuration.callback(currentModal);
    }

539
    return currentModal.modal('show');
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
  }
}

let modalObject: Modal = null;
try {
  if (parent && parent.window.TYPO3 && parent.window.TYPO3.Modal) {
    // fetch from parent
    // we need to trigger the event capturing again, in order to make sure this works inside iframes
    parent.window.TYPO3.Modal.initializeMarkupTrigger(document);
    modalObject = parent.window.TYPO3.Modal;
  } else if (top && top.TYPO3.Modal) {
    // fetch object from outer frame
    // we need to trigger the event capturing again, in order to make sure this works inside iframes
    top.TYPO3.Modal.initializeMarkupTrigger(document);
    modalObject = top.TYPO3.Modal;
  }
556
} catch {
557
558
559
560
561
562
563
  // This only happens if the opener, parent or top is some other url (eg a local file)
  // which loaded the current window. Then the browser's cross domain policy jumps in
  // and raises an exception.
  // For this case we are safe and we can create our global object below.
}

if (!modalObject) {
564
  modalObject = new Modal(new SecurityUtility());
565
566
567
568
569
570

  // expose as global object
  TYPO3.Modal = modalObject;
}

export = modalObject;