lib/amd/src/local/reactive/debugpanel.js

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