Proyectos de Subversion Moodle

Rev

| Ultima modificación | Ver Log |

Rev Autor Línea Nro. Línea
1 efrain 1
// This file is part of Moodle - http://moodle.org/
2
//
3
// Moodle is free software: you can redistribute it and/or modify
4
// it under the terms of the GNU General Public License as published by
5
// the Free Software Foundation, either version 3 of the License, or
6
// (at your option) any later version.
7
//
8
// Moodle is distributed in the hope that it will be useful,
9
// but WITHOUT ANY WARRANTY; without even the implied warranty of
10
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
11
// GNU General Public License for more details.
12
//
13
// You should have received a copy of the GNU General Public License
14
// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
15
 
16
import Templates from 'core/templates';
17
import {addOverlay, removeOverlay, removeAllOverlays} from 'core/local/reactive/overlay';
18
 
19
/**
20
 * Reactive UI component base class.
21
 *
22
 * Each UI reactive component should extend this class to interact with a reactive state.
23
 *
24
 * @module     core/local/reactive/basecomponent
25
 * @class     core/local/reactive/basecomponent
26
 * @copyright  2020 Ferran Recio <ferran@moodle.com>
27
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
28
 */
29
export default class {
30
 
31
    /**
32
     * The component descriptor data structure.
33
     *
34
     * This structure is used by any component and init method to define the way the component will interact
35
     * with the interface and whith reactive instance operates. The logic behind this object is to avoid
36
     * unnecessary dependancies between the final interface and the state logic.
37
     *
38
     * Any component interacts with a single main DOM element (description.element) but it can use internal
39
     * selector to select elements within this main element (descriptor.selectors). By default each component
40
     * will provide it's own default selectors, but those can be overridden by the "descriptor.selectors"
41
     * property in case the mustache wants to reuse the same component logic but with a different interface.
42
     *
43
     * @typedef {object} descriptor
44
     * @property {Reactive} reactive an optional reactive module to register in
45
     * @property {DOMElement} element all components needs an element to anchor events
46
     * @property {object} [selectors] an optional object to override query selectors
47
     */
48
 
49
    /**
50
     * The class constructor.
51
     *
52
     * The only param this method gets is a constructor with all the mandatory
53
     * and optional component data. Component will receive the same descriptor
54
     * as create method param.
55
     *
56
     * This method will call the "create" method before registering the component into
57
     * the reactive module. This way any component can add default selectors and events.
58
     *
59
     * @param {descriptor} descriptor data to create the object.
60
     */
61
    constructor(descriptor) {
62
 
63
        if (descriptor.element === undefined || !(descriptor.element instanceof HTMLElement)) {
64
            throw Error(`Reactive components needs a main DOM element to dispatch events`);
65
        }
66
 
67
        this.element = descriptor.element;
68
 
69
        // Variable to track event listeners.
70
        this.eventHandlers = new Map([]);
71
        this.eventListeners = [];
72
 
73
        // Empty default component selectors.
74
        this.selectors = {};
75
 
76
        // Empty default event list from the static method.
77
        this.events = this.constructor.getEvents();
78
 
79
        // Call create function to get the component defaults.
80
        this.create(descriptor);
81
 
82
        // Overwrite the components selectors if necessary.
83
        if (descriptor.selectors !== undefined) {
84
            this.addSelectors(descriptor.selectors);
85
        }
86
 
87
        // Register into a reactive instance.
88
        if (descriptor.reactive === undefined) {
89
            // Ask parent components for registration.
90
            this.element.dispatchEvent(new CustomEvent(
91
                'core/reactive:requestRegistration',
92
                {
93
                    bubbles: true,
94
                    detail: {component: this},
95
                }
96
            ));
97
        } else {
98
            this.reactive = descriptor.reactive;
99
            this.reactive.registerComponent(this);
100
            // Add a listener to register child components.
101
            this.addEventListener(
102
                this.element,
103
                'core/reactive:requestRegistration',
104
                (event) => {
105
                    if (event?.detail?.component) {
106
                        event.stopPropagation();
107
                        this.registerChildComponent(event.detail.component);
108
                    }
109
                }
110
            );
111
        }
112
    }
113
 
114
    /**
115
     * Return the component custom event names.
116
     *
117
     * Components may override this method to provide their own events.
118
     *
119
     * Component custom events is an important part of component reusability. This function
120
     * is static because is part of the component definition and should be accessible from
121
     * outsite the instances. However, values will be available at instance level in the
122
     * this.events object.
123
     *
124
     * @returns {Object} the component events.
125
     */
126
    static getEvents() {
127
        return {};
128
    }
129
 
130
    /**
131
     * Component create function.
132
     *
133
     * Default init method will call "create" when all internal attributes are set
134
     * but before the component is not yet registered in the reactive module.
135
     *
136
     * In this method any component can define its own defaults such as:
137
     * - this.selectors {object} the default query selectors of this component.
138
     * - this.events {object} a list of event names this component dispatch
139
     * - extract any data from the main dom element (this.element)
140
     * - set any other data the component uses
141
     *
142
     * @param {descriptor} descriptor the component descriptor
143
     */
144
    // eslint-disable-next-line no-unused-vars
145
    create(descriptor) {
146
        // Components may override this method to initialize selects, events or other data.
147
    }
148
 
149
    /**
150
     * Component destroy hook.
151
     *
152
     * BaseComponent call this method when a component is unregistered or removed.
153
     *
154
     * Components may override this method to clean the HTML or do some action when the
155
     * component is unregistered or removed.
156
     */
157
    destroy() {
158
        // Components can override this method.
159
    }
160
 
161
    /**
162
     * Return the list of watchers that component has.
163
     *
164
     * Each watcher is represented by an object with two attributes:
165
     * - watch (string) the specific state event to watch. Example 'section.visible:updated'
166
     * - handler (function) the function to call when the watching state change happens
167
     *
168
     * Any component shoudl override this method to define their state watchers.
169
     *
170
     * @returns {array} array of watchers.
171
     */
172
    getWatchers() {
173
        return [];
174
    }
175
 
176
    /**
177
     * Reactive module will call this method when the state is ready.
178
     *
179
     * Component can override this method to update/load the component HTML or to bind
180
     * listeners to HTML entities.
181
     */
182
    stateReady() {
183
        // Components can override this method.
184
    }
185
 
186
    /**
187
     * Get the main DOM element of this component or a subelement.
188
     *
189
     * @param {string|undefined} query optional subelement query
190
     * @param {string|undefined} dataId optional data-id value
191
     * @returns {element|undefined} the DOM element (if any)
192
     */
193
    getElement(query, dataId) {
194
        if (query === undefined && dataId === undefined) {
195
            return this.element;
196
        }
197
        const dataSelector = (dataId) ? `[data-id='${dataId}']` : '';
198
        const selector = `${query ?? ''}${dataSelector}`;
199
        return this.element.querySelector(selector);
200
    }
201
 
202
    /**
203
     * Get the all subelement that match a query selector.
204
     *
205
     * @param {string|undefined} query optional subelement query
206
     * @param {string|undefined} dataId optional data-id value
207
     * @returns {NodeList} the DOM elements
208
     */
209
    getElements(query, dataId) {
210
        const dataSelector = (dataId) ? `[data-id='${dataId}']` : '';
211
        const selector = `${query ?? ''}${dataSelector}`;
212
        return this.element.querySelectorAll(selector);
213
    }
214
 
215
    /**
216
     * Add or update the component selectors.
217
     *
218
     * @param {Object} newSelectors an object of new selectors.
219
     */
220
    addSelectors(newSelectors) {
221
        for (const [selectorName, selector] of Object.entries(newSelectors)) {
222
            this.selectors[selectorName] = selector;
223
        }
224
    }
225
 
226
    /**
227
     * Return a component selector.
228
     *
229
     * @param {string} selectorName the selector name
230
     * @return {string|undefined} the query selector
231
     */
232
    getSelector(selectorName) {
233
        return this.selectors[selectorName];
234
    }
235
 
236
    /**
237
     * Dispatch a custom event on this.element.
238
     *
239
     * This is just a convenient method to dispatch custom events from within a component.
240
     * Components are free to use an alternative function to dispatch custom
241
     * events. The only restriction is that it should be dispatched on this.element
242
     * and specify "bubbles:true" to alert any component listeners.
243
     *
244
     * @param {string} eventName the event name
245
     * @param {*} detail event detail data
246
     */
247
    dispatchEvent(eventName, detail) {
248
        this.element.dispatchEvent(new CustomEvent(eventName, {
249
            bubbles: true,
250
            detail: detail,
251
        }));
252
    }
253
 
254
    /**
255
     * Render a new Component using a mustache file.
256
     *
257
     * It is important to note that this method should NOT be used for loading regular mustache files
258
     * as it returns a Promise that will only be resolved if the mustache registers a component instance.
259
     *
260
     * @param {element} target the DOM element that contains the component
261
     * @param {string} file the component mustache file to render
262
     * @param {*} data the mustache data
263
     * @return {Promise} a promise of the resulting component instance
264
     */
265
    renderComponent(target, file, data) {
266
        return new Promise((resolve, reject) => {
267
            target.addEventListener('ComponentRegistration:Success', ({detail}) => {
268
                resolve(detail.component);
269
            });
270
            target.addEventListener('ComponentRegistration:Fail', () => {
271
                reject(`Registration of ${file} fails.`);
272
            });
273
            Templates.renderForPromise(
274
                file,
275
                data
276
            ).then(({html, js}) => {
277
                Templates.replaceNodeContents(target, html, js);
278
                return true;
279
            }).catch(error => {
280
                reject(`Rendering of ${file} throws an error.`);
281
                throw error;
282
            });
283
        });
284
    }
285
 
286
    /**
287
     * Add and bind an event listener to a target and keep track of all event listeners.
288
     *
289
     * The native element.addEventListener method is not object oriented friently as the
290
     * "this" represents the element that triggers the event and not the listener class.
291
     * As components can be unregister and removed at any time, the BaseComponent provides
292
     * this method to keep track of all component listeners and do all of the bind stuff.
293
     *
294
     * @param {Element} target the event target
295
     * @param {string} type the event name
296
     * @param {function} listener the class method that recieve the event
297
     */
298
    addEventListener(target, type, listener) {
299
 
300
        // Check if we have the bind version of that listener.
301
        let bindListener = this.eventHandlers.get(listener);
302
 
303
        if (bindListener === undefined) {
304
            bindListener = listener.bind(this);
305
            this.eventHandlers.set(listener, bindListener);
306
        }
307
 
308
        target.addEventListener(type, bindListener);
309
 
310
        // Keep track of all component event listeners in case we need to remove them.
311
        this.eventListeners.push({
312
            target,
313
            type,
314
            bindListener,
315
        });
316
 
317
    }
318
 
319
    /**
320
     * Remove an event listener from a component.
321
     *
322
     * This method allows components to remove listeners without keeping track of the
323
     * listeners bind versions of the method. Both addEventListener and removeEventListener
324
     * keeps internally the relation between the original class method and the bind one.
325
     *
326
     * @param {Element} target the event target
327
     * @param {string} type the event name
328
     * @param {function} listener the class method that recieve the event
329
     */
330
    removeEventListener(target, type, listener) {
331
        // Check if we have the bind version of that listener.
332
        let bindListener = this.eventHandlers.get(listener);
333
 
334
        if (bindListener === undefined) {
335
            // This listener has not been added.
336
            return;
337
        }
338
 
339
        target.removeEventListener(type, bindListener);
340
    }
341
 
342
    /**
343
     * Remove all event listeners from this component.
344
     *
345
     * This method is called also when the component is unregistered or removed.
346
     *
347
     * Note that only listeners registered with the addEventListener method
348
     * will be removed. Other manual listeners will keep active.
349
     */
350
    removeAllEventListeners() {
351
        this.eventListeners.forEach(({target, type, bindListener}) => {
352
            target.removeEventListener(type, bindListener);
353
        });
354
        this.eventListeners = [];
355
    }
356
 
357
    /**
358
     * Remove a previously rendered component instance.
359
     *
360
     * This method will remove the component HTML and unregister it from the
361
     * reactive module.
362
     */
363
    remove() {
364
        this.unregister();
365
        this.element.remove();
366
    }
367
 
368
    /**
369
     * Unregister the component from the reactive module.
370
     *
371
     * This method will disable the component logic, event listeners and watchers
372
     * but it won't remove any HTML created by the component. However, it will trigger
373
     * the destroy hook to allow the component to clean parts of the interface.
374
     */
375
    unregister() {
376
        this.reactive.unregisterComponent(this);
377
        this.removeAllEventListeners();
378
        this.destroy();
379
    }
380
 
381
    /**
382
     * Dispatch a component registration event to inform the parent node.
383
     *
384
     * The registration event is different from the rest of the component events because
385
     * is the only way in which components can communicate its existence to a possible parent.
386
     * Most components will be created by including a mustache file, child components
387
     * must emit a registration event to the parent DOM element to alert about the registration.
388
     */
389
    dispatchRegistrationSuccess() {
390
        // The registration event does not bubble because we just want to comunicate with the parentNode.
391
        // Otherwise, any component can get multiple registrations events and could not differentiate
392
        // between child components and grand child components.
393
        if (this.element.parentNode === undefined) {
394
            return;
395
        }
396
        // This custom element is captured by renderComponent method.
397
        this.element.parentNode.dispatchEvent(new CustomEvent(
398
            'ComponentRegistration:Success',
399
            {
400
                bubbles: false,
401
                detail: {component: this},
402
            }
403
        ));
404
    }
405
 
406
    /**
407
     * Dispatch a component registration fail event to inform the parent node.
408
     *
409
     * As dispatchRegistrationSuccess, this method will communicate the registration fail to the
410
     * parent node to inform the possible parent component.
411
     */
412
    dispatchRegistrationFail() {
413
        if (this.element.parentNode === undefined) {
414
            return;
415
        }
416
        // This custom element is captured only by renderComponent method.
417
        this.element.parentNode.dispatchEvent(new CustomEvent(
418
            'ComponentRegistration:Fail',
419
            {
420
                bubbles: false,
421
                detail: {component: this},
422
            }
423
        ));
424
    }
425
 
426
    /**
427
     * Register a child component into the reactive instance.
428
     *
429
     * @param {self} component the component to register.
430
     */
431
    registerChildComponent(component) {
432
        component.reactive = this.reactive;
433
        this.reactive.registerComponent(component);
434
    }
435
 
436
    /**
437
     * Set the lock value and locks or unlocks the element.
438
     *
439
     * @param {boolean} locked the new locked value
440
     */
441
    set locked(locked) {
442
        this.setElementLocked(this.element, locked);
443
    }
444
 
445
    /**
446
     * Get the current locked value from the element.
447
     *
448
     * @return {boolean}
449
     */
450
    get locked() {
451
        return this.getElementLocked(this.element);
452
    }
453
 
454
    /**
455
     * Lock/unlock an element.
456
     *
457
     * @param {Element} target the event target
458
     * @param {boolean} locked the new locked value
459
     */
460
    setElementLocked(target, locked) {
461
        target.dataset.locked = locked ?? false;
462
        if (locked) {
463
            // Disable interactions.
464
            target.style.pointerEvents = 'none';
465
            target.style.userSelect = 'none';
466
            // Check if it is draggable.
467
            if (target.hasAttribute('draggable')) {
468
                target.setAttribute('draggable', false);
469
            }
470
            target.setAttribute('aria-busy', true);
471
        } else {
472
            // Enable interactions.
473
            target.style.pointerEvents = null;
474
            target.style.userSelect = null;
475
            // Check if it was draggable.
476
            if (target.hasAttribute('draggable')) {
477
                target.setAttribute('draggable', true);
478
            }
479
            target.setAttribute('aria-busy', false);
480
        }
481
    }
482
 
483
    /**
484
     * Get the current locked value from the element.
485
     *
486
     * @param {Element} target the event target
487
     * @return {boolean}
488
     */
489
    getElementLocked(target) {
490
        return target.dataset.locked ?? false;
491
    }
492
 
493
    /**
494
     * Adds an overlay to a specific page element.
495
     *
496
     * @param {Object} definition the overlay definition.
497
     * @param {String} definition.content an optional overlay content.
498
     * @param {String} definition.classes an optional CSS classes
499
     * @param {Element} target optional parent object (this.element will be used if none provided)
500
     */
501
    async addOverlay(definition, target) {
502
        if (this._overlay) {
503
            this.removeOverlay();
504
        }
505
        this._overlay = await addOverlay(
506
            {
507
                content: definition.content,
508
                css: definition.classes ?? 'file-drop-zone',
509
            },
510
            target ?? this.element
511
        );
512
    }
513
 
514
    /**
515
     * Remove the current overlay.
516
     */
517
    removeOverlay() {
518
        if (!this._overlay) {
519
            return;
520
        }
521
        removeOverlay(this._overlay);
522
        this._overlay = null;
523
    }
524
 
525
    /**
526
     * Remove all page overlais.
527
     */
528
    removeAllOverlays() {
529
        removeAllOverlays();
530
    }
531
}