Proyectos de Subversion Moodle

Rev

Rev 1 | | 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
    /**
306
     * Wtacher method to refresh the log panel.
307
     *
308
     * @param {object} args
309
     * @param {HTMLElement} args.element
310
     */
311
    _refreshLog({element}) {
312
        const list = element?.lastChanges ?? [];
313
 
314
        const logContent = list.join("\n");
315
        // Append last log.
316
        const target = this.getElement(this.selectors.LOG);
317
        target.value += `\n\n= Transaction =\n ${logContent}`;
318
        target.scrollTop = target.scrollHeight;
319
    }
320
 
321
    /**
322
     * Listener method to clean the log area.
323
     */
324
    _cleanAreas() {
325
        let target = this.getElement(this.selectors.LOG);
326
        target.value = '';
327
 
328
        this._refreshState();
329
    }
330
 
331
    /**
332
     * Watcher to refresh the state information.
333
     */
334
    _refreshState() {
335
        const target = this.getElement(this.selectors.STATE);
336
        target.value = JSON.stringify(this.controller.state, null, 4);
337
    }
338
 
339
    /**
340
     * Watcher to update the read only information.
341
     */
342
    _refreshReadOnly() {
343
        // Toggle the read mode button.
344
        const target = this.getElement(this.selectors.READMODE);
345
        if (target.dataset.readonly === undefined) {
346
            target.dataset.readonly = target.innerHTML;
347
        }
348
        if (this.controller.readOnly) {
349
            target.innerHTML = target.dataset.readonly;
350
        } else {
351
            target.innerHTML = target.dataset.alt;
352
        }
353
    }
354
 
355
    /**
356
     * Listener to toggle the edit mode of the component.
357
     */
358
    _toggleEditMode() {
359
        this.controller.readOnly = !this.controller.readOnly;
360
    }
361
 
362
    /**
363
     * Check that the edited state JSON is valid.
364
     *
365
     * Not all valid JSON are suitable for transforming the state. For example,
366
     * the first level attributes cannot change the type.
367
     *
368
     * @return {undefined|array} Array of state updates.
369
     */
370
    _checkJSON() {
371
        const invalid = this.getElement(this.selectors.INVALID);
372
        const save = this.getElement(this.selectors.SAVE);
373
 
374
        const edited = this.getElement(this.selectors.STATE).value;
375
 
376
        const currentStateData = this.controller.stateData;
377
 
378
        // Check if the json is tha same as state.
379
        if (edited == JSON.stringify(this.controller.state, null, 4)) {
380
            invalid.style.color = '';
381
            invalid.innerHTML = '';
382
            save.disabled = true;
383
            return undefined;
384
        }
385
 
386
        // Check if the json format is valid.
387
        try {
388
            const newState = JSON.parse(edited);
389
            // Check the first level did not change types.
390
            const result = this._generateStateUpdates(currentStateData, newState);
391
            // Enable save button.
392
            invalid.style.color = '';
393
            invalid.innerHTML = this.strings.savewarning;
394
            save.disabled = false;
395
            return result;
396
        } catch (error) {
397
            invalid.style.color = 'red';
398
            invalid.innerHTML = error.message ?? 'Invalid JSON sctructure';
399
            save.disabled = true;
400
            return undefined;
401
        }
402
    }
403
 
404
    /**
405
     * Listener to save the current edited state into the real state.
406
     */
407
    _saveState() {
408
        const updates = this._checkJSON();
409
        if (!updates) {
410
            return;
411
        }
412
        // Sent the updates to the state manager.
413
        this.controller.processUpdates(updates);
414
    }
415
 
416
    /**
417
     * Check that the edited state JSON is valid.
418
     *
419
     * Not all valid JSON are suitable for transforming the state. For example,
420
     * the first level attributes cannot change the type. This method do a two
421
     * steps comparison between the current state data and the new state data.
422
     *
423
     * A reactive state cannot be overridden like any other variable. To keep
424
     * the watchers updated is necessary to transform the current state into
425
     * the new one. As a result, this method generates all the necessary state
426
     * updates to convert the state into the new state.
427
     *
428
     * @param {object} currentStateData
429
     * @param {object} newStateData
430
     * @return {array} Array of state updates.
431
     * @throws {Error} is the structure is not compatible
432
     */
433
    _generateStateUpdates(currentStateData, newStateData) {
434
 
435
        const updates = [];
436
 
437
        const ids = {};
438
 
439
        // Step 1: Add all overrides newStateData.
440
        for (const [key, newValue] of Object.entries(newStateData)) {
441
            // Check is it is new.
442
            if (Array.isArray(newValue)) {
443
                ids[key] = {};
444
                newValue.forEach(element => {
445
                    if (element.id === undefined) {
446
                        throw Error(`Array ${key} element without id attribute`);
447
                    }
448
                    updates.push({
449
                        name: key,
450
                        action: 'override',
451
                        fields: element,
452
                    });
453
                    const index = String(element.id).valueOf();
454
                    ids[key][index] = true;
455
                });
456
            } else {
457
                updates.push({
458
                    name: key,
459
                    action: 'override',
460
                    fields: newValue,
461
                });
462
            }
463
        }
464
        // Step 2: delete unnecesary data from currentStateData.
465
        for (const [key, oldValue] of Object.entries(currentStateData)) {
466
            let deleteField = false;
467
            // Check if the attribute is still there.
468
            if (newStateData[key] === undefined) {
469
                deleteField = true;
470
            }
471
            if (Array.isArray(oldValue)) {
472
                if (!deleteField && ids[key] === undefined) {
473
                    throw Error(`Array ${key} cannot change to object.`);
474
                }
475
                oldValue.forEach(element => {
476
                    const index = String(element.id).valueOf();
477
                    let deleteEntry = deleteField;
478
                    // Check if the id is there.
479
                    if (!deleteEntry && ids[key][index] === undefined) {
480
                        deleteEntry = true;
481
                    }
482
                    if (deleteEntry) {
483
                        updates.push({
484
                            name: key,
485
                            action: 'delete',
486
                            fields: element,
487
                        });
488
                    }
489
                });
490
            } else {
491
                if (!deleteField && ids[key] !== undefined) {
492
                    throw Error(`Object ${key} cannot change to array.`);
493
                }
494
                if (deleteField) {
495
                    updates.push({
496
                        name: key,
497
                        action: 'delete',
498
                        fields: oldValue,
499
                    });
500
                }
501
            }
502
        }
503
        // Delete all elements without action.
504
        return updates;
505
    }
506
 
507
    // Drag and drop methods.
508
 
509
    /**
510
     * Get the draggable data of this component.
511
     *
512
     * @returns {Object} exported course module drop data
513
     */
514
    getDraggableData() {
515
        return this.draggable;
516
    }
517
 
518
    /**
519
     * The element drop end hook.
520
     *
521
     * @param {Object} dropdata the dropdata
522
     * @param {Event} event the dropdata
523
     */
524
    dragEnd(dropdata, event) {
525
        this.element.style.top = `${event.newFixedTop}px`;
526
        this.element.style.left = `${event.newFixedLeft}px`;
527
    }
528
 
529
    /**
530
     * Pin and unpin the panel.
531
     */
532
    _togglePin() {
533
        this.draggable = !this.draggable;
534
        this.dragdrop.setDraggable(this.draggable);
535
        if (this.draggable) {
536
            this._unpin();
537
        } else {
538
            this._pin();
539
        }
540
    }
541
 
542
    /**
543
     * Unpin the panel form the footer.
544
     */
545
    _unpin() {
546
        // Find the initial spot.
547
        const pageCenterY = window.innerHeight / 2;
548
        const pageCenterX = window.innerWidth / 2;
549
        // Put the element in the middle of the screen
550
        const style = {
551
            position: 'fixed',
552
            resize: 'both',
553
            overflow: 'auto',
554
            height: '400px',
555
            width: '400px',
556
            top: `${pageCenterY - 200}px`,
557
            left: `${pageCenterX - 200}px`,
558
        };
559
        Object.assign(this.element.style, style);
560
        // Small also the text areas.
561
        this.getElement(this.selectors.STATE).style.height = '50px';
562
        this.getElement(this.selectors.LOG).style.height = '50px';
563
 
564
        this._toggleButtonText(this.getElement(this.selectors.PIN));
565
    }
566
 
567
    /**
568
     * Pin the panel into the footer.
569
     */
570
    _pin() {
571
        const props = [
572
            'position',
573
            'resize',
574
            'overflow',
575
            'top',
576
            'left',
577
            'height',
578
            'width',
579
        ];
580
        props.forEach(
581
            prop => this.element.style.removeProperty(prop)
582
        );
583
        this._toggleButtonText(this.getElement(this.selectors.PIN));
584
    }
585
 
586
    /**
587
     * Toogle the button text with the data-alt value.
588
     *
589
     * @param {Element} element the button element
590
     */
591
    _toggleButtonText(element) {
592
        [element.innerHTML, element.dataset.alt] = [element.dataset.alt, element.innerHTML];
593
    }
594
 
595
}