Proyectos de Subversion Moodle

Rev

Rev 11 | | Comparar con el anterior | 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
/**
17
 * Reactive module debug panel.
18
 *
19
 * This module contains all the UI components for the reactive debug tools.
20
 * Those tools are only available if the debug is enables and could be used
21
 * from the footer.
22
 *
23
 * @module     core/local/reactive/debugpanel
24
 * @copyright  2021 Ferran Recio <ferran@moodle.com>
25
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
26
 */
27
 
28
import {BaseComponent, DragDrop, debug} from 'core/reactive';
29
import log from 'core/log';
30
import {debounce} from 'core/utils';
31
 
32
/**
33
 * Init the main reactive panel.
34
 *
35
 * @param {element|string} target the DOM main element or its ID
36
 * @param {object} selectors optional css selector overrides
37
 */
38
export const init = (target, selectors) => {
39
    const element = document.getElementById(target);
40
    // Check if the debug reactive module is available.
41
    if (debug === undefined) {
42
        element.remove();
43
        return;
44
    }
45
    // Create the main component.
46
    new GlobalDebugPanel({
47
        element,
48
        reactive: debug,
49
        selectors,
50
    });
51
};
52
 
53
/**
54
 * Init an instance reactive subpanel.
55
 *
56
 * @param {element|string} target the DOM main element or its ID
57
 * @param {object} selectors optional css selector overrides
58
 */
59
export const initsubpanel = (target, selectors) => {
60
    const element = document.getElementById(target);
61
    // Check if the debug reactive module is available.
62
    if (debug === undefined) {
63
        element.remove();
64
        return;
65
    }
66
    // Create the main component.
67
    new DebugInstanceSubpanel({
68
        element,
69
        reactive: debug,
70
        selectors,
71
    });
72
};
73
 
74
/**
75
 * Component for the main reactive dev panel.
76
 *
77
 * This component shows the list of reactive instances and handle the buttons
78
 * to open a specific instance panel.
79
 */
80
class GlobalDebugPanel extends BaseComponent {
81
 
82
    /**
83
     * Constructor hook.
84
     */
85
    create() {
86
        // Optional component name for debugging.
87
        this.name = 'GlobalDebugPanel';
88
        // Default query selectors.
89
        this.selectors = {
90
            LOADERS: `[data-for='loaders']`,
91
            SUBPANEL: `[data-for='subpanel']`,
92
            NOINSTANCES: `[data-for='noinstances']`,
93
            LOG: `[data-for='log']`,
94
        };
95
        this.classes = {
96
            HIDE: `d-none`,
97
        };
98
        // The list of loaded debuggers.
99
        this.subPanels = new Set();
100
    }
101
 
102
    /**
103
     * Initial state ready method.
104
     *
105
     * @param {object} state the initial state
106
     */
107
    stateReady(state) {
108
        this._updateReactivesPanels({state});
109
        // Remove loading wheel.
110
        this.getElement(this.selectors.SUBPANEL).innerHTML = '';
111
    }
112
 
113
    /**
114
     * Component watchers.
115
     *
116
     * @returns {Array} of watchers
117
     */
118
    getWatchers() {
119
        return [
120
            {watch: `reactives:created`, handler: this._updateReactivesPanels},
121
        ];
122
    }
123
 
124
    /**
125
     * Update the list of reactive instances.
126
     * @param {Object} args
127
     * @param {Object} args.state the current state
128
     */
129
    _updateReactivesPanels({state}) {
130
        this.getElement(this.selectors.NOINSTANCES)?.classList?.toggle(
131
            this.classes.HIDE,
132
            state.reactives.size > 0
133
        );
134
        // Generate loading buttons.
135
        state.reactives.forEach(
136
            instance => {
137
                this._createLoader(instance);
138
            }
139
        );
140
    }
141
 
142
    /**
143
     * Create a debug panel button for a specific reactive instance.
144
     *
145
     * @param {object} instance hte instance data
146
     */
147
    _createLoader(instance) {
148
        if (this.subPanels.has(instance.id)) {
149
            return;
150
        }
151
        this.subPanels.add(instance.id);
152
        const loaders = this.getElement(this.selectors.LOADERS);
153
        const btn = document.createElement("button");
154
        btn.innerHTML = instance.id;
155
        btn.dataset.id = instance.id;
156
        loaders.appendChild(btn);
157
        // Add click event.
158
        this.addEventListener(btn, 'click', () => this._openPanel(btn, instance));
159
    }
160
 
161
    /**
162
     * Open a debug panel.
163
     *
164
     * @param {Element} btn the button element
165
     * @param {object} instance the instance data
166
     */
167
    async _openPanel(btn, instance) {
168
        try {
169
            const target = this.getElement(this.selectors.SUBPANEL);
170
            const data = {...instance};
171
            await this.renderComponent(target, 'core/local/reactive/debuginstancepanel', data);
172
        } catch (error) {
173
            log.error('Cannot load reactive debug subpanel');
174
            throw error;
175
        }
176
    }
177
}
178
 
179
/**
180
 * Component for the main reactive dev panel.
181
 *
182
 * This component shows the list of reactive instances and handle the buttons
183
 * to open a specific instance panel.
184
 */
185
class DebugInstanceSubpanel extends BaseComponent {
186
 
187
    /**
188
     * Constructor hook.
189
     */
190
    create() {
191
        // Optional component name for debugging.
192
        this.name = 'DebugInstanceSubpanel';
193
        // Default query selectors.
194
        this.selectors = {
195
            NAME: `[data-for='name']`,
196
            CLOSE: `[data-for='close']`,
197
            READMODE: `[data-for='readmode']`,
198
            HIGHLIGHT: `[data-for='highlight']`,
199
            LOG: `[data-for='log']`,
200
            STATE: `[data-for='state']`,
201
            CLEAN: `[data-for='clean']`,
202
            PIN: `[data-for='pin']`,
203
            SAVE: `[data-for='save']`,
204
            INVALID: `[data-for='invalid']`,
205
        };
206
        this.id = this.element.dataset.id;
207
        this.controller = M.reactive[this.id];
208
 
209
        // The component is created always pinned.
210
        this.draggable = false;
211
        // We want the element to be dragged like modal.
212
        this.relativeDrag = true;
213
        // Save warning (will be loaded when state is ready.
214
        this.strings = {
215
            savewarning: '',
216
        };
217
    }
218
 
219
    /**
220
     * Initial state ready method.
221
     *
222
     */
223
    stateReady() {
224
        // Enable drag and drop.
225
        this.dragdrop = new DragDrop(this);
226
 
227
        // Close button.
228
        this.addEventListener(
229
            this.getElement(this.selectors.CLOSE),
230
            'click',
231
            this.remove
232
        );
233
        // Highlight button.
234
        if (this.controller.highlight) {
235
            this._toggleButtonText(this.getElement(this.selectors.HIGHLIGHT));
236
        }
237
        this.addEventListener(
238
            this.getElement(this.selectors.HIGHLIGHT),
239
            'click',
240
            () => {
241
                this.controller.highlight = !this.controller.highlight;
242
                this._toggleButtonText(this.getElement(this.selectors.HIGHLIGHT));
243
            }
244
        );
245
        // Edit mode button.
246
        this.addEventListener(
247
            this.getElement(this.selectors.READMODE),
248
            'click',
249
            this._toggleEditMode
250
        );
251
        // Clean log and state.
252
        this.addEventListener(
253
            this.getElement(this.selectors.CLEAN),
254
            'click',
255
            this._cleanAreas
256
        );
257
        // Unpin panel butotn.
258
        this.addEventListener(
259
            this.getElement(this.selectors.PIN),
260
            'click',
261
            this._togglePin
262
        );
263
        // Save button, state format error message and state textarea.
264
        this.getElement(this.selectors.SAVE).disabled = true;
265
 
266
        this.addEventListener(
267
            this.getElement(this.selectors.STATE),
268
            'keyup',
11 efrain 269
            debounce(this._checkJSON.bind(this), 500)
1 efrain 270
        );
271
 
272
        this.addEventListener(
273
            this.getElement(this.selectors.SAVE),
274
            'click',
275
            this._saveState
276
        );
277
        // Save the default save warning message.
278
        this.strings.savewarning = this.getElement(this.selectors.INVALID)?.innerHTML ?? '';
279
        // Add current state.
280
        this._refreshState();
281
    }
282
 
283
    /**
284
     * Remove all subcomponents dependencies.
285
     */
286
    destroy() {
287
        if (this.dragdrop !== undefined) {
288
            this.dragdrop.unregister();
289
        }
290
    }
291
 
292
    /**
293
     * Component watchers.
294
     *
295
     * @returns {Array} of watchers
296
     */
297
    getWatchers() {
298
        return [
299
            {watch: `reactives[${this.id}].lastChanges:updated`, handler: this._refreshLog},
300
            {watch: `reactives[${this.id}].modified:updated`, handler: this._refreshState},
301
            {watch: `reactives[${this.id}].readOnly:updated`, handler: this._refreshReadOnly},
302
        ];
303
    }
304
 
305
    /**
1441 ariadna 306
     * Watcher method to refresh the log panel.
1 efrain 307
     *
308
     * @param {object} args
309
     * @param {HTMLElement} args.element
310
     */
311
    _refreshLog({element}) {
312
        const list = element?.lastChanges ?? [];
313
 
314
        // Append last log.
1441 ariadna 315
 
1 efrain 316
        const target = this.getElement(this.selectors.LOG);
1441 ariadna 317
        if (target.value !== '') {
318
            target.value += '\n\n';
319
        }
320
 
321
        const logContent = list.join("\n");
322
 
323
        target.value += `= Transaction =\n${logContent}`;
1 efrain 324
        target.scrollTop = target.scrollHeight;
325
    }
326
 
327
    /**
328
     * Listener method to clean the log area.
329
     */
330
    _cleanAreas() {
331
        let target = this.getElement(this.selectors.LOG);
332
        target.value = '';
333
 
334
        this._refreshState();
335
    }
336
 
337
    /**
338
     * Watcher to refresh the state information.
339
     */
340
    _refreshState() {
341
        const target = this.getElement(this.selectors.STATE);
342
        target.value = JSON.stringify(this.controller.state, null, 4);
343
    }
344
 
345
    /**
346
     * Watcher to update the read only information.
347
     */
348
    _refreshReadOnly() {
349
        // Toggle the read mode button.
350
        const target = this.getElement(this.selectors.READMODE);
351
        if (target.dataset.readonly === undefined) {
352
            target.dataset.readonly = target.innerHTML;
353
        }
354
        if (this.controller.readOnly) {
355
            target.innerHTML = target.dataset.readonly;
356
        } else {
357
            target.innerHTML = target.dataset.alt;
358
        }
359
    }
360
 
361
    /**
362
     * Listener to toggle the edit mode of the component.
363
     */
364
    _toggleEditMode() {
365
        this.controller.readOnly = !this.controller.readOnly;
366
    }
367
 
368
    /**
369
     * Check that the edited state JSON is valid.
370
     *
371
     * Not all valid JSON are suitable for transforming the state. For example,
372
     * the first level attributes cannot change the type.
373
     *
374
     * @return {undefined|array} Array of state updates.
375
     */
376
    _checkJSON() {
377
        const invalid = this.getElement(this.selectors.INVALID);
378
        const save = this.getElement(this.selectors.SAVE);
379
 
380
        const edited = this.getElement(this.selectors.STATE).value;
381
 
382
        const currentStateData = this.controller.stateData;
383
 
384
        // Check if the json is tha same as state.
385
        if (edited == JSON.stringify(this.controller.state, null, 4)) {
386
            invalid.style.color = '';
387
            invalid.innerHTML = '';
388
            save.disabled = true;
389
            return undefined;
390
        }
391
 
392
        // Check if the json format is valid.
393
        try {
394
            const newState = JSON.parse(edited);
395
            // Check the first level did not change types.
396
            const result = this._generateStateUpdates(currentStateData, newState);
397
            // Enable save button.
398
            invalid.style.color = '';
399
            invalid.innerHTML = this.strings.savewarning;
400
            save.disabled = false;
401
            return result;
402
        } catch (error) {
403
            invalid.style.color = 'red';
404
            invalid.innerHTML = error.message ?? 'Invalid JSON sctructure';
405
            save.disabled = true;
406
            return undefined;
407
        }
408
    }
409
 
410
    /**
411
     * Listener to save the current edited state into the real state.
412
     */
413
    _saveState() {
414
        const updates = this._checkJSON();
415
        if (!updates) {
416
            return;
417
        }
418
        // Sent the updates to the state manager.
419
        this.controller.processUpdates(updates);
420
    }
421
 
422
    /**
423
     * Check that the edited state JSON is valid.
424
     *
425
     * Not all valid JSON are suitable for transforming the state. For example,
426
     * the first level attributes cannot change the type. This method do a two
427
     * steps comparison between the current state data and the new state data.
428
     *
429
     * A reactive state cannot be overridden like any other variable. To keep
430
     * the watchers updated is necessary to transform the current state into
431
     * the new one. As a result, this method generates all the necessary state
432
     * updates to convert the state into the new state.
433
     *
434
     * @param {object} currentStateData
435
     * @param {object} newStateData
436
     * @return {array} Array of state updates.
437
     * @throws {Error} is the structure is not compatible
438
     */
439
    _generateStateUpdates(currentStateData, newStateData) {
440
 
441
        const updates = [];
442
 
443
        const ids = {};
444
 
445
        // Step 1: Add all overrides newStateData.
446
        for (const [key, newValue] of Object.entries(newStateData)) {
447
            // Check is it is new.
448
            if (Array.isArray(newValue)) {
449
                ids[key] = {};
450
                newValue.forEach(element => {
451
                    if (element.id === undefined) {
452
                        throw Error(`Array ${key} element without id attribute`);
453
                    }
454
                    updates.push({
455
                        name: key,
456
                        action: 'override',
457
                        fields: element,
458
                    });
459
                    const index = String(element.id).valueOf();
460
                    ids[key][index] = true;
461
                });
462
            } else {
463
                updates.push({
464
                    name: key,
465
                    action: 'override',
466
                    fields: newValue,
467
                });
468
            }
469
        }
470
        // Step 2: delete unnecesary data from currentStateData.
471
        for (const [key, oldValue] of Object.entries(currentStateData)) {
472
            let deleteField = false;
473
            // Check if the attribute is still there.
474
            if (newStateData[key] === undefined) {
475
                deleteField = true;
476
            }
477
            if (Array.isArray(oldValue)) {
478
                if (!deleteField && ids[key] === undefined) {
479
                    throw Error(`Array ${key} cannot change to object.`);
480
                }
481
                oldValue.forEach(element => {
482
                    const index = String(element.id).valueOf();
483
                    let deleteEntry = deleteField;
484
                    // Check if the id is there.
485
                    if (!deleteEntry && ids[key][index] === undefined) {
486
                        deleteEntry = true;
487
                    }
488
                    if (deleteEntry) {
489
                        updates.push({
490
                            name: key,
491
                            action: 'delete',
492
                            fields: element,
493
                        });
494
                    }
495
                });
496
            } else {
497
                if (!deleteField && ids[key] !== undefined) {
498
                    throw Error(`Object ${key} cannot change to array.`);
499
                }
500
                if (deleteField) {
501
                    updates.push({
502
                        name: key,
503
                        action: 'delete',
504
                        fields: oldValue,
505
                    });
506
                }
507
            }
508
        }
509
        // Delete all elements without action.
510
        return updates;
511
    }
512
 
513
    // Drag and drop methods.
514
 
515
    /**
516
     * Get the draggable data of this component.
517
     *
518
     * @returns {Object} exported course module drop data
519
     */
520
    getDraggableData() {
521
        return this.draggable;
522
    }
523
 
524
    /**
525
     * The element drop end hook.
526
     *
527
     * @param {Object} dropdata the dropdata
528
     * @param {Event} event the dropdata
529
     */
530
    dragEnd(dropdata, event) {
531
        this.element.style.top = `${event.newFixedTop}px`;
532
        this.element.style.left = `${event.newFixedLeft}px`;
533
    }
534
 
535
    /**
536
     * Pin and unpin the panel.
537
     */
538
    _togglePin() {
539
        this.draggable = !this.draggable;
540
        this.dragdrop.setDraggable(this.draggable);
541
        if (this.draggable) {
542
            this._unpin();
543
        } else {
544
            this._pin();
545
        }
546
    }
547
 
548
    /**
549
     * Unpin the panel form the footer.
550
     */
551
    _unpin() {
552
        // Find the initial spot.
553
        const pageCenterY = window.innerHeight / 2;
554
        const pageCenterX = window.innerWidth / 2;
555
        // Put the element in the middle of the screen
556
        const style = {
557
            position: 'fixed',
558
            resize: 'both',
559
            overflow: 'auto',
560
            height: '400px',
561
            width: '400px',
562
            top: `${pageCenterY - 200}px`,
563
            left: `${pageCenterX - 200}px`,
564
        };
565
        Object.assign(this.element.style, style);
566
        // Small also the text areas.
567
        this.getElement(this.selectors.STATE).style.height = '50px';
568
        this.getElement(this.selectors.LOG).style.height = '50px';
569
 
570
        this._toggleButtonText(this.getElement(this.selectors.PIN));
571
    }
572
 
573
    /**
574
     * Pin the panel into the footer.
575
     */
576
    _pin() {
577
        const props = [
578
            'position',
579
            'resize',
580
            'overflow',
581
            'top',
582
            'left',
583
            'height',
584
            'width',
585
        ];
586
        props.forEach(
587
            prop => this.element.style.removeProperty(prop)
588
        );
589
        this._toggleButtonText(this.getElement(this.selectors.PIN));
590
    }
591
 
592
    /**
593
     * Toogle the button text with the data-alt value.
594
     *
595
     * @param {Element} element the button element
596
     */
597
    _toggleButtonText(element) {
598
        [element.innerHTML, element.dataset.alt] = [element.dataset.alt, element.innerHTML];
599
    }
600
 
601
}