76d53ab9e1eebdbec78a2f08e8351767cced9d71
[Packages/TYPO3.CMS.git] / typo3 / sysext / backend / Resources / Private / TypeScript / Modal.ts
1 /*
2  * This file is part of the TYPO3 CMS project.
3  *
4  * It is free software; you can redistribute it and/or modify it under
5  * the terms of the GNU General Public License, either version 2
6  * of the License, or any later version.
7  *
8  * For the full copyright and license information, please read the
9  * LICENSE.txt file that was distributed with this source code.
10  *
11  * The TYPO3 project - inspiring people to share!
12  */
13
14 import {SeverityEnum} from './Enum/Severity';
15 import 'bootstrap';
16 import * as $ from 'jquery';
17 import Icons = require('./Icons');
18 import Severity = require('./Severity');
19 import SecurityUtility = require('TYPO3/CMS/Core/SecurityUtility');
20
21 enum Identifiers {
22   modal = '.t3js-modal',
23   content = '.t3js-modal-content',
24   title = '.t3js-modal-title',
25   close = '.t3js-modal-close',
26   body = '.t3js-modal-body',
27   footer = '.t3js-modal-footer',
28   iframe = '.t3js-modal-iframe',
29   iconPlaceholder = '.t3js-modal-icon-placeholder'
30 }
31
32 enum Sizes {
33   small = 'small',
34   default = 'default',
35   medium = 'medium',
36   large = 'large',
37   full = 'full'
38 }
39
40 enum Styles {
41   default = 'default',
42   light = 'light',
43   dark = 'dark'
44 }
45
46 enum Types {
47   default = 'default',
48   ajax = 'ajax',
49   iframe = 'iframe'
50 }
51
52 interface Button {
53   text: string;
54   active: boolean;
55   btnClass: string;
56   name: string;
57   trigger: (e: JQueryEventObject) => {};
58   dataAttributes: { [key: string]: string };
59   icon: string;
60 }
61
62 interface Configuration {
63   type: Types;
64   title: string;
65   content: string | JQuery;
66   severity: SeverityEnum;
67   buttons: Array<Button>;
68   style: string;
69   size: string;
70   additionalCssClasses: Array<string>;
71   callback: Function;
72   ajaxCallback: Function;
73   ajaxTarget: string;
74 }
75
76 /**
77  * Module: TYPO3/CMS/Backend/Modal
78  * API for modal windows powered by Twitter Bootstrap.
79  */
80 class Modal {
81   public readonly sizes: any = Sizes;
82   public readonly styles: any = Styles;
83   public readonly types: any = Types;
84   public currentModal: JQuery = null;
85   private instances: Array<JQuery> = [];
86   private readonly $template: JQuery = $(
87     '<div class="t3js-modal modal fade">' +
88     '<div class="modal-dialog">' +
89     '<div class="t3js-modal-content modal-content">' +
90     '<div class="modal-header">' +
91     '<button class="t3js-modal-close close">' +
92     '<span aria-hidden="true">' +
93     '<span class="t3js-modal-icon-placeholder" data-icon="actions-close"></span>' +
94     '</span>' +
95     '<span class="sr-only"></span>' +
96     '</button>' +
97     '<h4 class="t3js-modal-title modal-title"></h4>' +
98     '</div>' +
99     '<div class="t3js-modal-body modal-body"></div>' +
100     '<div class="t3js-modal-footer modal-footer"></div>' +
101     '</div>' +
102     '</div>' +
103     '</div>'
104   );
105
106   private defaultConfiguration: Configuration = {
107     type: Types.default,
108     title: 'Information',
109     content: 'No content provided, please check your <code>Modal</code> configuration.',
110     severity: SeverityEnum.notice,
111     buttons: [],
112     style: Styles.default,
113     size: Sizes.default,
114     additionalCssClasses: [],
115     callback: $.noop(),
116     ajaxCallback: $.noop(),
117     ajaxTarget: null
118   };
119
120   private readonly securityUtility: SecurityUtility;
121
122   constructor(securityUtility: SecurityUtility) {
123     this.securityUtility = securityUtility;
124     $(document).on('modal-dismiss', this.dismiss);
125     this.initializeMarkupTrigger(document);
126   }
127
128   /**
129    * Close the current open modal
130    */
131   public dismiss(): void {
132     if (this.currentModal) {
133       this.currentModal.modal('hide');
134     }
135   }
136
137   /**
138    * Shows a confirmation dialog
139    * Events:
140    * - button.clicked
141    * - confirm.button.cancel
142    * - confirm.button.ok
143    *
144    * @param {string} title The title for the confirm modal
145    * @param {string | JQuery} content The content for the conform modal, e.g. the main question
146    * @param {SeverityEnum} severity Default SeverityEnum.warning
147    * @param {Array<Button>} buttons An array with buttons, default no buttons
148    * @param {Array<string>} additionalCssClasses Additional css classes to add to the modal
149    * @returns {JQuery}
150    */
151   public confirm(title: string,
152                  content: string | JQuery,
153                  severity: SeverityEnum = SeverityEnum.warning,
154                  buttons: Array<Object> = [],
155                  additionalCssClasses?: Array<string>): JQuery {
156     if (buttons.length === 0) {
157       buttons.push(
158         {
159           text: $(this).data('button-close-text') || TYPO3.lang['button.cancel'] || 'Cancel',
160           active: true,
161           btnClass: 'btn-default',
162           name: 'cancel'
163         },
164         {
165           text: $(this).data('button-ok-text') || TYPO3.lang['button.ok'] || 'OK',
166           btnClass: 'btn-' + Severity.getCssClass(severity),
167           name: 'ok'
168         }
169       );
170     }
171
172     return this.advanced({
173       title,
174       content,
175       severity,
176       buttons,
177       additionalCssClasses,
178       callback: (currentModal: JQuery): void => {
179         currentModal.on('button.clicked', (e: JQueryEventObject): void => {
180           if (e.target.getAttribute('name') === 'cancel') {
181             $(e.currentTarget).trigger('confirm.button.cancel');
182           } else if (e.target.getAttribute('name') === 'ok') {
183             $(e.currentTarget).trigger('confirm.button.ok');
184           }
185         });
186       }
187     });
188   }
189
190   /**
191    * Load URL with AJAX, append the content to the modal-body
192    * and trigger the callback
193    *
194    * @param {string} title
195    * @param {SeverityEnum} severity
196    * @param {Array<Button>} buttons
197    * @param {string} url
198    * @param {Function} callback
199    * @param {string} target
200    * @returns {JQuery}
201    */
202   public loadUrl(title: string,
203                  severity: SeverityEnum = SeverityEnum.info,
204                  buttons: Array<Object>,
205                  url: string,
206                  callback?: Function,
207                  target?: string
208   ): JQuery {
209     return this.advanced({
210       type: Types.ajax,
211       title,
212       severity,
213       buttons,
214       ajaxCallback: callback,
215       ajaxTarget: target
216     });
217   }
218
219   /**
220    * Shows a dialog
221    *
222    * @param {string} title
223    * @param {string | JQuery} content
224    * @param {number} severity
225    * @param {Array<Object>} buttons
226    * @param {Array<string>} additionalCssClasses
227    * @returns {JQuery}
228    */
229   public show(title: string,
230               content: string | JQuery,
231               severity: SeverityEnum = SeverityEnum.info,
232               buttons?: Array<Object>,
233               additionalCssClasses?: Array<string>): JQuery {
234     return this.advanced({
235       type: Types.default,
236       title,
237       content,
238       severity,
239       buttons,
240       additionalCssClasses
241     });
242   }
243
244   /**
245    * Loads modal by configuration
246    *
247    * @param {object} configuration configuration for the modal
248    */
249   public advanced(configuration: { [key: string]: any }): JQuery {
250     // Validation of configuration
251     configuration.type = typeof configuration.type === 'string' && configuration.type in Types
252       ? configuration.type
253       : this.defaultConfiguration.type;
254     configuration.title = typeof configuration.title === 'string'
255       ? configuration.title
256       : this.defaultConfiguration.title;
257     if (typeof configuration.content === 'string') {
258       // A string means, no markup allowed, let's ensure this
259       configuration.content = this.securityUtility.encodeHtml(configuration.content);
260     } else if (typeof configuration.content === 'object') {
261       // An object means, a valid jQuery object with markup, let's get the markup
262       configuration.content = configuration.content.html();
263     } else {
264       configuration.content = this.defaultConfiguration.content;
265     }
266     configuration.severity = typeof configuration.severity !== 'undefined'
267       ? configuration.severity
268       : this.defaultConfiguration.severity;
269     configuration.buttons = <Array<Button>>configuration.buttons || this.defaultConfiguration.buttons;
270     configuration.size = typeof configuration.size === 'string' && configuration.size in Sizes
271       ? configuration.size
272       : this.defaultConfiguration.size;
273     configuration.style = typeof configuration.style === 'string' && configuration.style in Styles
274       ? configuration.style
275       : this.defaultConfiguration.style;
276     configuration.additionalCssClasses = configuration.additionalCssClasses || this.defaultConfiguration.additionalCssClasses;
277     configuration.callback = typeof configuration.callback === 'function' ? configuration.callback : this.defaultConfiguration.callback;
278     configuration.ajaxCallback = typeof configuration.ajaxCallback === 'function'
279       ? configuration.ajaxCallback
280       : this.defaultConfiguration.ajaxCallback;
281     configuration.ajaxTarget = typeof configuration.ajaxTarget === 'string'
282       ? configuration.ajaxTarget
283       : this.defaultConfiguration.ajaxTarget;
284
285     return this.generate(<Configuration>configuration);
286   }
287
288   /**
289    * Initialize markup with data attributes
290    *
291    * @param {HTMLDocument} theDocument
292    */
293   private initializeMarkupTrigger(theDocument: HTMLDocument): void {
294     $(theDocument).on('click', '.t3js-modal-trigger', (evt: JQueryEventObject): void => {
295       evt.preventDefault();
296       const $element = $(evt.currentTarget);
297       const content = $element.data('content') || 'Are you sure?';
298       const severity = typeof SeverityEnum[$element.data('severity')] !== 'undefined'
299         ? SeverityEnum[$element.data('severity')]
300         : SeverityEnum.info;
301       let url = $element.data('url') || null;
302       if (url !== null) {
303         const separator = (url.indexOf('?') > -1) ? '&' : '?';
304         const params = $.param({data: $element.data()});
305         url = url + separator + params;
306       }
307       this.advanced({
308         type: url !== null ? Types.ajax : Types.default,
309         title: $element.data('title') || 'Alert',
310         content: url !== null ? url : content,
311         severity,
312         buttons: [
313           {
314             text: $element.data('button-close-text') || TYPO3.lang['button.close'] || 'Close',
315             active: true,
316             btnClass: 'btn-default',
317             trigger: (): void => {
318               this.currentModal.trigger('modal-dismiss');
319             }
320           },
321           {
322             text: $element.data('button-ok-text') || TYPO3.lang['button.ok'] || 'OK',
323             btnClass: 'btn-' + Severity.getCssClass(severity),
324             trigger: (): void => {
325               this.currentModal.trigger('modal-dismiss');
326               evt.target.ownerDocument.location.href = $element.data('href') || $element.attr('href');
327             }
328           }
329         ],
330       });
331     });
332   }
333
334   /**
335    * @param {Configuration} configuration
336    */
337   private generate(configuration: Configuration): JQuery {
338     const currentModal = this.$template.clone();
339     if (configuration.additionalCssClasses.length > 0) {
340       for (let additionalClass of configuration.additionalCssClasses) {
341         currentModal.addClass(additionalClass);
342       }
343     }
344     currentModal.addClass('modal-type-' + configuration.type);
345     currentModal.addClass('modal-severity-' + Severity.getCssClass(configuration.severity));
346     currentModal.addClass('modal-style-' + configuration.style);
347     currentModal.addClass('modal-size-' + configuration.size);
348     currentModal.attr('tabindex', '-1');
349     currentModal.find(Identifiers.title).text(configuration.title);
350     currentModal.find(Identifiers.close).on('click', (): void => {
351       currentModal.modal('hide');
352     });
353
354     if (configuration.type === 'ajax') {
355       const contentTarget = configuration.ajaxTarget ? configuration.ajaxTarget : Identifiers.body;
356       const $loaderTarget = currentModal.find(contentTarget);
357       Icons.getIcon('spinner-circle', Icons.sizes.default, null, null, Icons.markupIdentifiers.inline).done((icon: string): void => {
358         $loaderTarget.html('<div class="modal-loading">' + icon + '</div>');
359         $.get(
360           <string>configuration.content,
361           (response: string): void => {
362             this.currentModal.find(contentTarget)
363               .empty()
364               .append(response);
365             if (configuration.ajaxCallback) {
366               configuration.ajaxCallback();
367             }
368             this.currentModal.trigger('modal-loaded');
369           },
370           'html'
371         );
372       });
373     } else if (configuration.type === 'iframe') {
374       currentModal.find(Identifiers.body).append(
375         $('<iframe />', {
376           src: configuration.content,
377           'name': 'modal_frame',
378           'class': 'modal-iframe t3js-modal-iframe'
379         })
380       );
381       currentModal.find(Identifiers.iframe).on('load', (): void => {
382         currentModal.find(Identifiers.title).text(
383           (<HTMLIFrameElement>currentModal.find(Identifiers.iframe).get(0)).contentDocument.title
384         );
385       });
386     } else {
387       if (typeof configuration.content === 'string') {
388         configuration.content = $('<p />').html(configuration.content);
389       }
390       currentModal.find(Identifiers.body).append(configuration.content);
391     }
392
393     // Add buttons
394     if (configuration.buttons.length > 0) {
395       for (let i = 0; i < configuration.buttons.length; i++) {
396         const button = configuration.buttons[i];
397         const $button = $('<button />', {'class': 'btn'});
398         $button.html('<span>' + button.text + '</span>');
399         if (button.active) {
400           $button.addClass('t3js-active');
401         }
402         if (button.btnClass !== '') {
403           $button.addClass(button.btnClass);
404         }
405         if (button.name !== '') {
406           $button.attr('name', button.name);
407         }
408         if (button.trigger) {
409           $button.on('click', button.trigger);
410         }
411         if (button.dataAttributes) {
412           if (Object.keys(button.dataAttributes).length > 0) {
413             Object.keys(button.dataAttributes).map((value: string): any => {
414               $button.attr('data-' + value, button.dataAttributes[value]);
415             });
416           }
417         }
418         if (button.icon) {
419           $button.prepend('<span class="t3js-modal-icon-placeholder" data-icon="' + button.icon + '"></span>');
420         }
421         currentModal.find(Identifiers.footer).append($button);
422       }
423       currentModal
424         .find(Identifiers.footer).find('button')
425         .on('click', (e: JQueryEventObject): void => {
426           $(e.currentTarget).trigger('button.clicked');
427         });
428     } else {
429       currentModal.find(Identifiers.footer).remove();
430     }
431
432     currentModal.on('shown.bs.modal', (e: JQueryEventObject): void => {
433       const $me = $(e.currentTarget);
434       // focus the button which was configured as active button
435       $me.find(Identifiers.footer).find('.t3js-active').first().focus();
436       // Get Icons
437       $me.find(Identifiers.iconPlaceholder).each((index: number, elem: Element): void => {
438         Icons.getIcon($(elem).data('icon'), Icons.sizes.small, null, null, Icons.markupIdentifiers.inline).done((icon: string): void => {
439           this.currentModal.find(Identifiers.iconPlaceholder + '[data-icon=' + $(icon).data('identifier') + ']').replaceWith(icon);
440         });
441       });
442     });
443
444     // Remove modal from Modal.instances when hidden
445     currentModal.on('hidden.bs.modal', (e: JQueryEventObject): void => {
446       if (this.instances.length > 0) {
447         const lastIndex = this.instances.length - 1;
448         this.instances.splice(lastIndex, 1);
449         this.currentModal = this.instances[lastIndex - 1];
450       }
451       currentModal.trigger('modal-destroyed');
452       $(e.currentTarget).remove();
453       // Keep class modal-open on body tag as long as open modals exist
454       if (this.instances.length > 0) {
455         $('body').addClass('modal-open');
456       }
457     });
458
459     // When modal is opened/shown add it to Modal.instances and make it Modal.currentModal
460     currentModal.on('show.bs.modal', (e: JQueryEventObject): void => {
461       this.currentModal = $(e.currentTarget);
462       this.instances.push(this.currentModal);
463     });
464     currentModal.on('modal-dismiss', (e: JQueryEventObject): void => {
465       // Hide modal, the bs.modal events will clean up Modal.instances
466       $(e.currentTarget).modal('hide');
467     });
468
469     if (configuration.callback) {
470       configuration.callback(currentModal);
471     }
472
473     return currentModal.modal();
474   }
475 }
476
477 let modalObject: Modal = null;
478 try {
479   if (parent && parent.window.TYPO3 && parent.window.TYPO3.Modal) {
480     // fetch from parent
481     // we need to trigger the event capturing again, in order to make sure this works inside iframes
482     parent.window.TYPO3.Modal.initializeMarkupTrigger(document);
483     modalObject = parent.window.TYPO3.Modal;
484   } else if (top && top.TYPO3.Modal) {
485     // fetch object from outer frame
486     // we need to trigger the event capturing again, in order to make sure this works inside iframes
487     top.TYPO3.Modal.initializeMarkupTrigger(document);
488     modalObject = top.TYPO3.Modal;
489   }
490 } catch (e) {
491   // This only happens if the opener, parent or top is some other url (eg a local file)
492   // which loaded the current window. Then the browser's cross domain policy jumps in
493   // and raises an exception.
494   // For this case we are safe and we can create our global object below.
495 }
496
497 if (!modalObject) {
498   modalObject = new Modal(new SecurityUtility());
499
500   // expose as global object
501   TYPO3.Modal = modalObject;
502 }
503
504 export = modalObject;