course/format/amd/src/local/content/actions.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. * Course state actions dispatcher.
  17. *
  18. * This module captures all data-dispatch links in the course content and dispatch the proper
  19. * state mutation, including any confirmation and modal required.
  20. *
  21. * @module core_courseformat/local/content/actions
  22. * @class core_courseformat/local/content/actions
  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} from 'core/reactive';
  27. import {eventTypes} from 'core/local/inplace_editable/events';
  28. import Collapse from 'theme_boost/bootstrap/collapse';
  29. import log from 'core/log';
  30. import Modal from 'core/modal';
  31. import ModalSaveCancel from 'core/modal_save_cancel';
  32. import ModalDeleteCancel from 'core/modal_delete_cancel';
  33. import ModalCopyToClipboard from 'core/modal_copy_to_clipboard';
  34. import ModalEvents from 'core/modal_events';
  35. import Templates from 'core/templates';
  36. import {prefetchStrings} from 'core/prefetch';
  37. import {getString} from 'core/str';
  38. import {getFirst} from 'core/normalise';
  39. import {toggleBulkSelectionAction} from 'core_courseformat/local/content/actions/bulkselection';
  40. import Pending from 'core/pending';
  41. import ContentTree from 'core_courseformat/local/courseeditor/contenttree';
  42. // Load global strings.
  43. prefetchStrings('core', ['movecoursesection', 'movecoursemodule', 'confirm', 'delete']);
  44. // Mutations are dispatched by the course content actions.
  45. // Formats can use this module addActions static method to add custom actions.
  46. // Direct mutations can be simple strings (mutation) name or functions.
  47. const directMutations = {
  48. sectionHide: 'sectionHide',
  49. sectionShow: 'sectionShow',
  50. cmHide: 'cmHide',
  51. cmShow: 'cmShow',
  52. cmStealth: 'cmStealth',
  53. cmMoveRight: 'cmMoveRight',
  54. cmMoveLeft: 'cmMoveLeft',
  55. cmNoGroups: 'cmNoGroups',
  56. cmSeparateGroups: 'cmSeparateGroups',
  57. cmVisibleGroups: 'cmVisibleGroups',
  58. };
  59. export default class extends BaseComponent {
  60. /**
  61. * Constructor hook.
  62. */
  63. create() {
  64. // Optional component name for debugging.
  65. this.name = 'content_actions';
  66. // Default query selectors.
  67. this.selectors = {
  68. ACTIONLINK: `[data-action]`,
  69. // Move modal selectors.
  70. SECTIONLINK: `[data-for='section']`,
  71. CMLINK: `[data-for='cm']`,
  72. SECTIONNODE: `[data-for='sectionnode']`,
  73. MODALTOGGLER: `[data-bs-toggle='collapse']`,
  74. ADDSECTION: `[data-action='addSection']`,
  75. CONTENTTREE: `#destination-selector`,
  76. ACTIONMENU: `.action-menu`,
  77. ACTIONMENUTOGGLER: `[data-bs-toggle="dropdown"]`,
  78. // Availability modal selectors.
  79. OPTIONSRADIO: `[type='radio']`,
  80. COURSEADDSECTION: `#course-addsection`,
  81. ADDSECTIONREGION: `[data-region='section-addsection']`,
  82. };
  83. // Component css classes.
  84. this.classes = {
  85. DISABLED: `disabled`,
  86. ITALIC: `fst-italic`,
  87. DISPLAYNONE: `d-none`,
  88. };
  89. }
  90. /**
  91. * Add extra actions to the module.
  92. *
  93. * @param {array} actions array of methods to execute
  94. */
  95. static addActions(actions) {
  96. for (const [action, mutationReference] of Object.entries(actions)) {
  97. if (typeof mutationReference !== 'function' && typeof mutationReference !== 'string') {
  98. throw new Error(`${action} action must be a mutation name or a function`);
  99. }
  100. directMutations[action] = mutationReference;
  101. }
  102. }
  103. /**
  104. * Initial state ready method.
  105. */
  106. stateReady() {
  107. // Delegate dispatch clicks.
  108. this.addEventListener(
  109. this.element,
  110. 'click',
  111. this._dispatchClick
  112. );
  113. // Any inplace editable update needs state refresh.
  114. this.addEventListener(
  115. this.element,
  116. eventTypes.elementUpdated,
  117. this._inplaceEditableHandler
  118. );
  119. }
  120. _dispatchClick(event) {
  121. const target = event.target.closest(this.selectors.ACTIONLINK);
  122. if (!target) {
  123. return;
  124. }
  125. if (target.classList.contains(this.classes.DISABLED)) {
  126. event.preventDefault();
  127. return;
  128. }
  129. // Invoke proper method.
  130. const actionName = target.dataset.action;
  131. const methodName = this._actionMethodName(actionName);
  132. if (this[methodName] !== undefined) {
  133. this[methodName](target, event);
  134. return;
  135. }
  136. // Check direct mutations or mutations handlers.
  137. if (directMutations[actionName] !== undefined) {
  138. if (typeof directMutations[actionName] === 'function') {
  139. directMutations[actionName](target, event);
  140. return;
  141. }
  142. this._requestMutationAction(target, event, directMutations[actionName]);
  143. return;
  144. }
  145. }
  146. _actionMethodName(name) {
  147. const requestName = name.charAt(0).toUpperCase() + name.slice(1);
  148. return `_request${requestName}`;
  149. }
  150. /**
  151. * Handle inplace editable updates.
  152. *
  153. * @param {Event} event the triggered event
  154. * @private
  155. */
  156. _inplaceEditableHandler(event) {
  157. const itemtype = event.detail?.ajaxreturn?.itemtype;
  158. const itemid = parseInt(event.detail?.ajaxreturn?.itemid);
  159. if (!Number.isFinite(itemid) || !itemtype) {
  160. return;
  161. }
  162. if (itemtype === 'activityname') {
  163. this.reactive.dispatch('cmState', [itemid]);
  164. return;
  165. }
  166. // Sections uses sectionname for normal sections and sectionnamenl for the no link sections.
  167. if (itemtype === 'sectionname' || itemtype === 'sectionnamenl') {
  168. this.reactive.dispatch('sectionState', [itemid]);
  169. return;
  170. }
  171. }
  172. /**
  173. * Return the ids represented by this element.
  174. *
  175. * Depending on the dataset attributes the action could represent a single id
  176. * or a bulk actions with all the current selected ids.
  177. *
  178. * @param {HTMLElement} target
  179. * @returns {Number[]} array of Ids
  180. */
  181. _getTargetIds(target) {
  182. let ids = [];
  183. if (target?.dataset?.id) {
  184. ids.push(target.dataset.id);
  185. }
  186. const bulkType = target?.dataset?.bulk;
  187. if (!bulkType) {
  188. return ids;
  189. }
  190. const bulk = this.reactive.get('bulk');
  191. if (bulk.enabled && bulk.selectedType === bulkType) {
  192. ids = [...ids, ...bulk.selection];
  193. }
  194. return ids;
  195. }
  196. /**
  197. * Handle a move section request.
  198. *
  199. * @param {Element} target the dispatch action element
  200. * @param {Event} event the triggered event
  201. */
  202. async _requestMoveSection(target, event) {
  203. // Check we have an id.
  204. const sectionIds = this._getTargetIds(target);
  205. if (sectionIds.length == 0) {
  206. return;
  207. }
  208. event.preventDefault();
  209. const pendingModalReady = new Pending(`courseformat/actions:prepareMoveSectionModal`);
  210. // The section edit menu to refocus on end.
  211. const editTools = this._getClosestActionMenuToogler(target);
  212. // Collect section information from the state.
  213. const exporter = this.reactive.getExporter();
  214. const data = exporter.course(this.reactive.state);
  215. let titleText = null;
  216. // Add the target section id and title.
  217. let sectionInfo = null;
  218. if (sectionIds.length == 1) {
  219. sectionInfo = this.reactive.get('section', sectionIds[0]);
  220. data.sectionid = sectionInfo.id;
  221. data.sectiontitle = sectionInfo.title;
  222. data.information = await this.reactive.getFormatString('sectionmove_info', data.sectiontitle);
  223. titleText = this.reactive.getFormatString('sectionmove_title');
  224. } else {
  225. data.information = await this.reactive.getFormatString('sectionsmove_info', sectionIds.length);
  226. titleText = this.reactive.getFormatString('sectionsmove_title');
  227. }
  228. // Create the modal.
  229. // Build the modal parameters from the event data.
  230. const modal = await this._modalBodyRenderedPromise(Modal, {
  231. title: titleText,
  232. body: Templates.render('core_courseformat/local/content/movesection', data),
  233. });
  234. const modalBody = getFirst(modal.getBody());
  235. // Disable current selected section ids.
  236. sectionIds.forEach(sectionId => {
  237. const currentElement = modalBody.querySelector(`${this.selectors.SECTIONLINK}[data-id='${sectionId}']`);
  238. this._disableLink(currentElement);
  239. });
  240. // Setup keyboard navigation.
  241. new ContentTree(
  242. modalBody.querySelector(this.selectors.CONTENTTREE),
  243. {
  244. SECTION: this.selectors.SECTIONNODE,
  245. TOGGLER: this.selectors.MODALTOGGLER,
  246. COLLAPSE: this.selectors.MODALTOGGLER,
  247. },
  248. true
  249. );
  250. // Capture click.
  251. modalBody.addEventListener('click', (event) => {
  252. const target = event.target;
  253. if (!target.matches('a') || target.dataset.for != 'section' || target.dataset.id === undefined) {
  254. return;
  255. }
  256. if (target.getAttribute('aria-disabled')) {
  257. return;
  258. }
  259. event.preventDefault();
  260. this.reactive.dispatch('sectionMoveAfter', sectionIds, target.dataset.id);
  261. this._destroyModal(modal, editTools);
  262. });
  263. pendingModalReady.resolve();
  264. }
  265. /**
  266. * Handle a move cm request.
  267. *
  268. * @param {Element} target the dispatch action element
  269. * @param {Event} event the triggered event
  270. */
  271. async _requestMoveCm(target, event) {
  272. // Check we have an id.
  273. const cmIds = this._getTargetIds(target);
  274. if (cmIds.length == 0) {
  275. return;
  276. }
  277. event.preventDefault();
  278. const pendingModalReady = new Pending(`courseformat/actions:prepareMoveCmModal`);
  279. // The section edit menu to refocus on end.
  280. const editTools = this._getClosestActionMenuToogler(target);
  281. // Collect information from the state.
  282. const exporter = this.reactive.getExporter();
  283. const data = exporter.course(this.reactive.state);
  284. let titleText = null;
  285. if (cmIds.length == 1) {
  286. const cmInfo = this.reactive.get('cm', cmIds[0]);
  287. data.cmid = cmInfo.id;
  288. data.cmname = cmInfo.name;
  289. data.information = await this.reactive.getFormatString('cmmove_info', data.cmname);
  290. if (cmInfo.hasdelegatedsection) {
  291. titleText = this.reactive.getFormatString('cmmove_subsectiontitle');
  292. } else {
  293. titleText = this.reactive.getFormatString('cmmove_title');
  294. }
  295. } else {
  296. data.information = await this.reactive.getFormatString('cmsmove_info', cmIds.length);
  297. titleText = this.reactive.getFormatString('cmsmove_title');
  298. }
  299. // Create the modal.
  300. // Build the modal parameters from the event data.
  301. const modal = await this._modalBodyRenderedPromise(Modal, {
  302. title: titleText,
  303. body: Templates.render('core_courseformat/local/content/movecm', data),
  304. });
  305. const modalBody = getFirst(modal.getBody());
  306. // Disable current selected section ids.
  307. cmIds.forEach(cmId => {
  308. const currentElement = modalBody.querySelector(`${this.selectors.CMLINK}[data-id='${cmId}']`);
  309. this._disableLink(currentElement);
  310. });
  311. // Setup keyboard navigation.
  312. new ContentTree(
  313. modalBody.querySelector(this.selectors.CONTENTTREE),
  314. {
  315. SECTION: this.selectors.SECTIONNODE,
  316. TOGGLER: this.selectors.MODALTOGGLER,
  317. COLLAPSE: this.selectors.MODALTOGGLER,
  318. ENTER: this.selectors.SECTIONLINK,
  319. }
  320. );
  321. cmIds.forEach(cmId => {
  322. const cmInfo = this.reactive.get('cm', cmId);
  323. let selector;
  324. if (!cmInfo.hasdelegatedsection) {
  325. selector = `${this.selectors.CMLINK}[data-id='${cmId}']`;
  326. } else {
  327. selector = `${this.selectors.SECTIONLINK}[data-id='${cmInfo.sectionid}']`;
  328. }
  329. const currentElement = modalBody.querySelector(selector);
  330. this._expandCmMoveModalParentSections(modalBody, currentElement);
  331. });
  332. modalBody.addEventListener('click', (event) => {
  333. const target = event.target;
  334. if (!target.matches('a') || target.dataset.for === undefined || target.dataset.id === undefined) {
  335. return;
  336. }
  337. if (target.getAttribute('aria-disabled')) {
  338. return;
  339. }
  340. event.preventDefault();
  341. let targetSectionId;
  342. let targetCmId;
  343. let droppedCmIds = [...cmIds];
  344. if (target.dataset.for == 'cm') {
  345. const dropData = exporter.cmDraggableData(this.reactive.state, target.dataset.id);
  346. targetSectionId = dropData.sectionid;
  347. targetCmId = dropData.nextcmid;
  348. } else {
  349. const section = this.reactive.get('section', target.dataset.id);
  350. targetSectionId = target.dataset.id;
  351. targetCmId = section?.cmlist[0];
  352. }
  353. const section = this.reactive.get('section', targetSectionId);
  354. if (section.component) {
  355. // Remove cmIds which are not allowed to be moved to this delegated section (mostly
  356. // all other delegated cm).
  357. droppedCmIds = droppedCmIds.filter(cmId => {
  358. const cmInfo = this.reactive.get('cm', cmId);
  359. return !cmInfo.hasdelegatedsection;
  360. });
  361. }
  362. if (droppedCmIds.length === 0) {
  363. return; // No cm to move.
  364. }
  365. this.reactive.dispatch('cmMove', droppedCmIds, targetSectionId, targetCmId);
  366. this._destroyModal(modal, editTools);
  367. });
  368. pendingModalReady.resolve();
  369. }
  370. /**
  371. * Expand all the modal tree branches that contains the element.
  372. *
  373. * @private
  374. * @param {HTMLElement} modalBody the modal body element
  375. * @param {HTMLElement} element the element to display
  376. */
  377. _expandCmMoveModalParentSections(modalBody, element) {
  378. const sectionnode = element.closest(this.selectors.SECTIONNODE);
  379. if (!sectionnode) {
  380. return;
  381. }
  382. const toggler = sectionnode.querySelector(this.selectors.MODALTOGGLER);
  383. let collapsibleId = toggler.dataset.target ?? toggler.getAttribute('href');
  384. if (collapsibleId) {
  385. // We cannot be sure we have # in the id element name.
  386. collapsibleId = collapsibleId.replace('#', '');
  387. const expandNode = modalBody.querySelector(`#${collapsibleId}`);
  388. new Collapse(expandNode, {toggle: false}).show();
  389. }
  390. // Section are a tree structure, we need to expand all the parents.
  391. this._expandCmMoveModalParentSections(modalBody, sectionnode.parentElement);
  392. }
  393. /**
  394. * Handle a create section request.
  395. *
  396. * @param {Element} target the dispatch action element
  397. * @param {Event} event the triggered event
  398. */
  399. async _requestAddSection(target, event) {
  400. event.preventDefault();
  401. this.reactive.dispatch('addSection', target.dataset.id ?? 0);
  402. }
  403. /**
  404. * Handle a create subsection request.
  405. *
  406. * @deprecated since Moodle 5.0 MDL-83469.
  407. * @todo MDL-83851 This will be deleted in Moodle 6.0.
  408. * @param {Element} target the dispatch action element
  409. * @param {Event} event the triggered event
  410. */
  411. async _requestAddModule(target, event) {
  412. log.debug('AddModule action is deprecated. Use newModule instead');
  413. event.preventDefault();
  414. this.reactive.dispatch('addModule', target.dataset.modname, target.dataset.sectionnum, target.dataset.beforemod);
  415. }
  416. /**
  417. * Handle a new create subsection request.
  418. *
  419. * @param {Element} target the dispatch action element
  420. * @param {Event} event the triggered event
  421. */
  422. async _requestNewModule(target, event) {
  423. event.preventDefault();
  424. this.reactive.dispatch('newModule', target.dataset.modname, target.dataset.sectionid, target.dataset.beforemod);
  425. }
  426. /**
  427. * Handle a delete section request.
  428. *
  429. * @param {Element} target the dispatch action element
  430. * @param {Event} event the triggered event
  431. */
  432. async _requestDeleteSection(target, event) {
  433. const sectionIds = this._getTargetIds(target);
  434. if (sectionIds.length == 0) {
  435. return;
  436. }
  437. event.preventDefault();
  438. // We don't need confirmation to delete empty sections.
  439. let needsConfirmation = sectionIds.some(sectionId => {
  440. const sectionInfo = this.reactive.get('section', sectionId);
  441. const cmList = sectionInfo.cmlist ?? [];
  442. return (cmList.length || sectionInfo.hassummary || sectionInfo.rawtitle);
  443. });
  444. if (!needsConfirmation) {
  445. this._dispatchSectionDelete(sectionIds, target);
  446. return;
  447. }
  448. let bodyText = null;
  449. let titleText = null;
  450. if (sectionIds.length == 1) {
  451. titleText = this.reactive.getFormatString('sectiondelete_title');
  452. const sectionInfo = this.reactive.get('section', sectionIds[0]);
  453. bodyText = this.reactive.getFormatString('sectiondelete_info', {name: sectionInfo.title});
  454. } else {
  455. titleText = this.reactive.getFormatString('sectionsdelete_title');
  456. bodyText = this.reactive.getFormatString('sectionsdelete_info', {count: sectionIds.length});
  457. }
  458. const modal = await this._modalBodyRenderedPromise(ModalDeleteCancel, {
  459. title: titleText,
  460. body: bodyText,
  461. });
  462. modal.getRoot().on(
  463. ModalEvents.delete,
  464. e => {
  465. // Stop the default save button behaviour which is to close the modal.
  466. e.preventDefault();
  467. modal.destroy();
  468. this._dispatchSectionDelete(sectionIds, target);
  469. }
  470. );
  471. }
  472. /**
  473. * Dispatch the section delete action and handle the redirection if necessary.
  474. *
  475. * @param {Array} sectionIds the IDs of the sections to delete.
  476. * @param {Element} target the dispatch action element
  477. */
  478. async _dispatchSectionDelete(sectionIds, target) {
  479. await this.reactive.dispatch('sectionDelete', sectionIds);
  480. if (target.baseURI.includes('section.php')) {
  481. // Redirect to the course main page if the section is the current page.
  482. window.location.href = this.reactive.get('course').baseurl;
  483. }
  484. }
  485. /**
  486. * Handle a toggle cm selection.
  487. *
  488. * @param {Element} target the dispatch action element
  489. * @param {Event} event the triggered event
  490. */
  491. async _requestToggleSelectionCm(target, event) {
  492. toggleBulkSelectionAction(this.reactive, target, event, 'cm');
  493. }
  494. /**
  495. * Handle a toggle section selection.
  496. *
  497. * @param {Element} target the dispatch action element
  498. * @param {Event} event the triggered event
  499. */
  500. async _requestToggleSelectionSection(target, event) {
  501. toggleBulkSelectionAction(this.reactive, target, event, 'section');
  502. }
  503. /**
  504. * Basic mutation action helper.
  505. *
  506. * @param {Element} target the dispatch action element
  507. * @param {Event} event the triggered event
  508. * @param {string} mutationName the mutation name
  509. */
  510. async _requestMutationAction(target, event, mutationName) {
  511. if (!target.dataset.id && target.dataset.for !== 'bulkaction') {
  512. return;
  513. }
  514. event.preventDefault();
  515. if (target.dataset.for === 'bulkaction') {
  516. // If the mutation is a bulk action we use the current selection.
  517. this.reactive.dispatch(mutationName, this.reactive.get('bulk').selection);
  518. } else {
  519. this.reactive.dispatch(mutationName, [target.dataset.id]);
  520. }
  521. }
  522. /**
  523. * Handle a course permalink modal request.
  524. *
  525. * @param {Element} target the dispatch action element
  526. * @param {Event} event the triggered event
  527. */
  528. _requestPermalink(target, event) {
  529. event.preventDefault();
  530. ModalCopyToClipboard.create(
  531. {
  532. text: target.getAttribute('href'),
  533. },
  534. getString('sectionlink', 'course')
  535. );
  536. return;
  537. }
  538. /**
  539. * Handle a course module duplicate request.
  540. *
  541. * @param {Element} target the dispatch action element
  542. * @param {Event} event the triggered event
  543. */
  544. async _requestCmDuplicate(target, event) {
  545. const cmIds = this._getTargetIds(target);
  546. if (cmIds.length == 0) {
  547. return;
  548. }
  549. const sectionId = target.dataset.sectionid ?? null;
  550. event.preventDefault();
  551. this.reactive.dispatch('cmDuplicate', cmIds, sectionId);
  552. }
  553. /**
  554. * Handle a delete cm request.
  555. *
  556. * @param {Element} target the dispatch action element
  557. * @param {Event} event the triggered event
  558. */
  559. async _requestCmDelete(target, event) {
  560. const cmIds = this._getTargetIds(target);
  561. if (cmIds.length == 0) {
  562. return;
  563. }
  564. event.preventDefault();
  565. let bodyText = null;
  566. let titleText = null;
  567. let delegatedsection = null;
  568. if (cmIds.length == 1) {
  569. const cmInfo = this.reactive.get('cm', cmIds[0]);
  570. if (cmInfo.hasdelegatedsection) {
  571. delegatedsection = cmInfo.delegatesectionid;
  572. titleText = this.reactive.getFormatString('cmdelete_subsectiontitle');
  573. bodyText = getString(
  574. 'sectiondelete_info',
  575. 'core_courseformat',
  576. {
  577. type: cmInfo.modname,
  578. name: cmInfo.name,
  579. }
  580. );
  581. } else {
  582. titleText = this.reactive.getFormatString('cmdelete_title');
  583. bodyText = getString(
  584. 'cmdelete_info',
  585. 'core_courseformat',
  586. {
  587. type: cmInfo.modname,
  588. name: cmInfo.name,
  589. }
  590. );
  591. }
  592. } else {
  593. titleText = getString('cmsdelete_title', 'core_courseformat');
  594. bodyText = getString(
  595. 'cmsdelete_info',
  596. 'core_courseformat',
  597. {count: cmIds.length}
  598. );
  599. }
  600. const modal = await this._modalBodyRenderedPromise(ModalDeleteCancel, {
  601. title: titleText,
  602. body: bodyText,
  603. });
  604. modal.getRoot().on(
  605. ModalEvents.delete,
  606. e => {
  607. // Stop the default save button behaviour which is to close the modal.
  608. e.preventDefault();
  609. modal.destroy();
  610. this.reactive.dispatch('cmDelete', cmIds);
  611. if (cmIds.length == 1 && delegatedsection && target.baseURI.includes('section.php')) {
  612. // Redirect to the course main page if the subsection is the current page.
  613. let parameters = new URLSearchParams(window.location.search);
  614. if (parameters.has('id') && parameters.get('id') == delegatedsection) {
  615. this._dispatchSectionDelete([delegatedsection], target);
  616. }
  617. }
  618. }
  619. );
  620. }
  621. /**
  622. * Handle a cm availability change request.
  623. *
  624. * @param {Element} target the dispatch action element
  625. */
  626. async _requestCmAvailability(target) {
  627. const cmIds = this._getTargetIds(target);
  628. if (cmIds.length == 0) {
  629. return;
  630. }
  631. // Show the availability modal to decide which action to trigger.
  632. const exporter = this.reactive.getExporter();
  633. const data = {
  634. allowstealth: exporter.canUseStealth(this.reactive.state, cmIds),
  635. };
  636. const modal = await this._modalBodyRenderedPromise(ModalSaveCancel, {
  637. title: getString('availability', 'core'),
  638. body: Templates.render('core_courseformat/local/content/cm/availabilitymodal', data),
  639. saveButtonText: getString('apply', 'core'),
  640. });
  641. this._setupMutationRadioButtonModal(modal, cmIds);
  642. }
  643. /**
  644. * Handle a section availability change request.
  645. *
  646. * @param {Element} target the dispatch action element
  647. */
  648. async _requestSectionAvailability(target) {
  649. const sectionIds = this._getTargetIds(target);
  650. if (sectionIds.length == 0) {
  651. return;
  652. }
  653. const title = (sectionIds.length == 1) ? 'sectionavailability_title' : 'sectionsavailability_title';
  654. // Show the availability modal to decide which action to trigger.
  655. const modal = await this._modalBodyRenderedPromise(ModalSaveCancel, {
  656. title: this.reactive.getFormatString(title),
  657. body: Templates.render('core_courseformat/local/content/section/availabilitymodal', []),
  658. saveButtonText: getString('apply', 'core'),
  659. });
  660. this._setupMutationRadioButtonModal(modal, sectionIds);
  661. }
  662. /**
  663. * Add events to a mutation selector radio buttons modal.
  664. * @param {Modal} modal
  665. * @param {Number[]} ids the section or cm ids to apply the mutation
  666. */
  667. _setupMutationRadioButtonModal(modal, ids) {
  668. // The save button is not enabled until the user selects an option.
  669. modal.setButtonDisabled('save', true);
  670. const submitFunction = (radio) => {
  671. const mutation = radio?.value;
  672. if (!mutation) {
  673. return false;
  674. }
  675. this.reactive.dispatch(mutation, ids);
  676. return true;
  677. };
  678. const modalBody = getFirst(modal.getBody());
  679. const radioOptions = modalBody.querySelectorAll(this.selectors.OPTIONSRADIO);
  680. radioOptions.forEach(radio => {
  681. radio.addEventListener('change', () => {
  682. modal.setButtonDisabled('save', false);
  683. });
  684. radio.parentNode.addEventListener('click', () => {
  685. radio.checked = true;
  686. modal.setButtonDisabled('save', false);
  687. });
  688. radio.parentNode.addEventListener('dblclick', dbClickEvent => {
  689. if (submitFunction(radio)) {
  690. dbClickEvent.preventDefault();
  691. modal.destroy();
  692. }
  693. });
  694. });
  695. modal.getRoot().on(
  696. ModalEvents.save,
  697. () => {
  698. const radio = modalBody.querySelector(`${this.selectors.OPTIONSRADIO}:checked`);
  699. submitFunction(radio);
  700. }
  701. );
  702. }
  703. /**
  704. * Replace an element with a copy with a different tag name.
  705. *
  706. * @param {Element} element the original element
  707. */
  708. _disableLink(element) {
  709. if (element) {
  710. element.style.pointerEvents = 'none';
  711. element.style.userSelect = 'none';
  712. element.classList.add(this.classes.DISABLED);
  713. element.classList.add(this.classes.ITALIC);
  714. element.setAttribute('aria-disabled', true);
  715. element.addEventListener('click', event => event.preventDefault());
  716. }
  717. }
  718. /**
  719. * Render a modal and return a body ready promise.
  720. *
  721. * @param {Modal} ModalClass the modal class
  722. * @param {object} modalParams the modal params
  723. * @return {Promise} the modal body ready promise
  724. */
  725. _modalBodyRenderedPromise(ModalClass, modalParams) {
  726. return new Promise((resolve, reject) => {
  727. ModalClass.create(modalParams).then((modal) => {
  728. modal.setRemoveOnClose(true);
  729. // Handle body loading event.
  730. modal.getRoot().on(ModalEvents.bodyRendered, () => {
  731. resolve(modal);
  732. });
  733. // Configure some extra modal params.
  734. if (modalParams.saveButtonText !== undefined) {
  735. modal.setSaveButtonText(modalParams.saveButtonText);
  736. }
  737. if (modalParams.deleteButtonText !== undefined) {
  738. modal.setDeleteButtonText(modalParams.saveButtonText);
  739. }
  740. modal.show();
  741. return;
  742. }).catch(() => {
  743. reject(`Cannot load modal content`);
  744. });
  745. });
  746. }
  747. /**
  748. * Hide and later destroy a modal.
  749. *
  750. * Behat will fail if we remove the modal while some boostrap collapse is executing.
  751. *
  752. * @param {Modal} modal
  753. * @param {HTMLElement} element the dom element to focus on.
  754. */
  755. _destroyModal(modal, element) {
  756. modal.hide();
  757. const pendingDestroy = new Pending(`courseformat/actions:destroyModal`);
  758. if (element) {
  759. element.focus();
  760. }
  761. setTimeout(() =>{
  762. modal.destroy();
  763. pendingDestroy.resolve();
  764. }, 500);
  765. }
  766. /**
  767. * Get the closest actions menu toggler to an action element.
  768. *
  769. * @param {HTMLElement} element the action link element
  770. * @returns {HTMLElement|undefined}
  771. */
  772. _getClosestActionMenuToogler(element) {
  773. const actionMenu = element.closest(this.selectors.ACTIONMENU);
  774. if (!actionMenu) {
  775. return undefined;
  776. }
  777. return actionMenu.querySelector(this.selectors.ACTIONMENUTOGGLER);
  778. }
  779. }