lib/amd/src/tag.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. * AJAX helper for the tag management page.
  17. *
  18. * @module core/tag
  19. * @copyright 2015 Marina Glancy
  20. * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  21. * @since 3.0
  22. */
  23. import $ from 'jquery';
  24. import {call as fetchMany} from 'core/ajax';
  25. import * as Notification from 'core/notification';
  26. import * as Templates from 'core/templates';
  27. import {getString} from 'core/str';
  28. import * as ModalEvents from 'core/modal_events';
  29. import Pending from 'core/pending';
  30. import SaveCancelModal from 'core/modal_save_cancel';
  31. import Config from 'core/config';
  32. import {eventTypes as inplaceEditableEvents} from 'core/local/inplace_editable/events';
  33. import * as reportSelectors from 'core_reportbuilder/local/selectors';
  34. const getTagIndex = (tagindex) => fetchMany([{
  35. methodname: 'core_tag_get_tagindex',
  36. args: {tagindex}
  37. }])[0];
  38. const getCheckedTags = (root) => root.querySelectorAll('[data-togglegroup="report-select-all"][data-toggle="slave"]:checked');
  39. const handleCombineRequest = async(tagManagementCombine) => {
  40. const pendingPromise = new Pending('core/tag:tag-management-combine');
  41. const form = tagManagementCombine.closest('form');
  42. const reportElement = document.querySelector(reportSelectors.regions.report);
  43. const checkedTags = getCheckedTags(reportElement);
  44. if (checkedTags.length <= 1) {
  45. // We need at least 2 tags to combine them.
  46. Notification.alert(
  47. getString('combineselected', 'tag'),
  48. getString('selectmultipletags', 'tag'),
  49. getString('ok'),
  50. );
  51. return;
  52. }
  53. const tags = Array.from(checkedTags.values()).map((tag) => {
  54. const namedElement = document.querySelector(`.inplaceeditable[data-itemtype=tagname][data-itemid="${tag.value}"]`);
  55. return {
  56. id: tag.value,
  57. name: namedElement.dataset.value,
  58. };
  59. });
  60. const modal = await SaveCancelModal.create({
  61. title: getString('combineselected', 'tag'),
  62. buttons: {
  63. save: getString('continue', 'core'),
  64. },
  65. body: Templates.render('core_tag/combine_tags', {tags}),
  66. show: true,
  67. removeOnClose: true,
  68. });
  69. // Handle save event.
  70. modal.getRoot().on(ModalEvents.save, (e) => {
  71. e.preventDefault();
  72. // Append this temp element in the form in the tags list, not the form in the modal. Confusing, right?!?
  73. const tempElement = document.createElement('input');
  74. tempElement.hidden = true;
  75. tempElement.name = tagManagementCombine.name;
  76. form.append(tempElement);
  77. // Append selected tags element.
  78. const tagsElement = document.createElement('input');
  79. tagsElement.hidden = true;
  80. tagsElement.name = 'tagschecked';
  81. tagsElement.value = [...checkedTags].map(check => check.value).join(',');
  82. form.append(tagsElement);
  83. // Get the selected tag from the modal.
  84. var maintag = $('input[name=maintag]:checked', '#combinetags_form').val();
  85. // Append this in the tags list form.
  86. $("<input type='hidden'/>").attr('name', 'maintag').attr('value', maintag).appendTo(form);
  87. // Submit the tags list form.
  88. form.submit();
  89. });
  90. await modal.getBodyPromise();
  91. // Tick the first option.
  92. const firstOption = document.querySelector('#combinetags_form input[type=radio]');
  93. firstOption.focus();
  94. firstOption.checked = true;
  95. pendingPromise.resolve();
  96. return;
  97. };
  98. const addStandardTags = async() => {
  99. var pendingPromise = new Pending('core/tag:addstandardtag');
  100. const modal = await SaveCancelModal.create({
  101. title: getString('addotags', 'tag'),
  102. body: Templates.render('core_tag/add_tags', {
  103. actionurl: window.location.href,
  104. sesskey: M.cfg.sesskey,
  105. }),
  106. buttons: {
  107. save: getString('continue', 'core'),
  108. },
  109. removeOnClose: true,
  110. show: true,
  111. });
  112. // Handle save event.
  113. modal.getRoot().on(ModalEvents.save, (e) => {
  114. var tagsInput = $(e.currentTarget).find('#id_tagslist');
  115. var name = tagsInput.val().trim();
  116. // Set the text field's value to the trimmed value.
  117. tagsInput.val(name);
  118. // Add submit event listener to the form.
  119. var tagsForm = $('#addtags_form');
  120. tagsForm.on('submit', function(e) {
  121. // Validate the form.
  122. var form = $('#addtags_form');
  123. if (form[0].checkValidity() === false) {
  124. e.preventDefault();
  125. e.stopPropagation();
  126. }
  127. form.addClass('was-validated');
  128. // BS2 compatibility.
  129. $('[data-region="tagslistinput"]').addClass('error');
  130. var errorMessage = $('#id_tagslist_error_message');
  131. errorMessage.removeAttr('hidden');
  132. errorMessage.addClass('help-block');
  133. });
  134. // Try to submit the form.
  135. tagsForm.submit();
  136. return false;
  137. });
  138. await modal.getBodyPromise();
  139. pendingPromise.resolve();
  140. };
  141. const deleteSelectedTags = async(bulkActionDeleteButton) => {
  142. const form = bulkActionDeleteButton.closest('form');
  143. const reportElement = document.querySelector(reportSelectors.regions.report);
  144. const checkedTags = getCheckedTags(reportElement);
  145. if (!checkedTags.length) {
  146. return;
  147. }
  148. try {
  149. await Notification.saveCancelPromise(
  150. getString('delete'),
  151. getString('confirmdeletetags', 'tag'),
  152. getString('yes'),
  153. getString('no'),
  154. );
  155. // Append this temp element in the form in the tags list, not the form in the modal. Confusing, right?!?
  156. const tempElement = document.createElement('input');
  157. tempElement.hidden = true;
  158. tempElement.name = bulkActionDeleteButton.name;
  159. form.append(tempElement);
  160. // Append selected tags element.
  161. const tagsElement = document.createElement('input');
  162. tagsElement.hidden = true;
  163. tagsElement.name = 'tagschecked';
  164. tagsElement.value = [...checkedTags].map(check => check.value).join(',');
  165. form.append(tagsElement);
  166. form.submit();
  167. } catch {
  168. return;
  169. }
  170. };
  171. const deleteSelectedTag = async(button) => {
  172. try {
  173. await Notification.saveCancelPromise(
  174. getString('delete'),
  175. getString('confirmdeletetag', 'tag'),
  176. getString('yes'),
  177. getString('no'),
  178. );
  179. window.location.href = button.href;
  180. } catch {
  181. return;
  182. }
  183. };
  184. const deleteSelectedCollection = async(button) => {
  185. try {
  186. await Notification.saveCancelPromise(
  187. getString('delete'),
  188. getString('suredeletecoll', 'tag', button.dataset.collname),
  189. getString('yes'),
  190. getString('no'),
  191. );
  192. const redirectTarget = new URL(button.dataset.url);
  193. redirectTarget.searchParams.set('sesskey', Config.sesskey);
  194. window.location.href = redirectTarget;
  195. } catch {
  196. return;
  197. }
  198. };
  199. const addTagCollection = async(link) => {
  200. const pendingPromise = new Pending('core/tag:initManageCollectionsPage-addtagcoll');
  201. const href = link.dataset.url;
  202. const modal = await SaveCancelModal.create({
  203. title: getString('addtagcoll', 'tag'),
  204. buttons: {
  205. save: getString('create', 'core'),
  206. },
  207. body: Templates.render('core_tag/add_tag_collection', {
  208. actionurl: href,
  209. sesskey: M.cfg.sesskey,
  210. }),
  211. removeOnClose: true,
  212. show: true,
  213. });
  214. // Handle save event.
  215. modal.getRoot().on(ModalEvents.save, (e) => {
  216. const collectionInput = $(e.currentTarget).find('#addtagcoll_name');
  217. const name = collectionInput.val().trim();
  218. // Set the text field's value to the trimmed value.
  219. collectionInput.val(name);
  220. // Add submit event listener to the form.
  221. const form = $('#addtagcoll_form');
  222. form.on('submit', function(e) {
  223. // Validate the form.
  224. if (form[0].checkValidity() === false) {
  225. e.preventDefault();
  226. e.stopPropagation();
  227. }
  228. form.addClass('was-validated');
  229. // BS2 compatibility.
  230. $('[data-region="addtagcoll_nameinput"]').addClass('error');
  231. const errorMessage = $('#id_addtagcoll_name_error_message');
  232. errorMessage.removeAttr('hidden');
  233. errorMessage.addClass('help-block');
  234. });
  235. // Try to submit the form.
  236. form.submit();
  237. return false;
  238. });
  239. pendingPromise.resolve();
  240. };
  241. /**
  242. * Initialises tag index page.
  243. *
  244. * @method initTagindexPage
  245. */
  246. export const initTagindexPage = async() => {
  247. document.addEventListener('click', async(e) => {
  248. const targetArea = e.target.closest('a[data-quickload="1"]');
  249. if (!targetArea) {
  250. return;
  251. }
  252. const tagArea = targetArea.closest('.tagarea[data-ta]');
  253. if (!tagArea) {
  254. return;
  255. }
  256. e.preventDefault();
  257. const pendingPromise = new Pending('core/tag:initTagindexPage');
  258. const query = targetArea.search.replace(/^\?/, '');
  259. const params = Object.fromEntries((new URLSearchParams(query)).entries());
  260. try {
  261. const data = await getTagIndex(params);
  262. const {html, js} = await Templates.renderForPromise('core_tag/index', data);
  263. Templates.replaceNode(tagArea, html, js);
  264. } catch (error) {
  265. Notification.exception(error);
  266. }
  267. pendingPromise.resolve();
  268. });
  269. };
  270. /**
  271. * Initialises tag management page.
  272. *
  273. * @method initManagePage
  274. */
  275. export const initManagePage = () => {
  276. // Toggle row class when updating flag.
  277. $('body').on(inplaceEditableEvents.elementUpdated, '[data-inplaceeditable][data-itemtype=tagflag]', function(e) {
  278. var row = $(e.target).closest('tr');
  279. row.toggleClass('table-warning', e.detail.ajaxreturn.value === '1');
  280. });
  281. // Confirmation for bulk tag combine button.
  282. document.addEventListener('click', async(e) => {
  283. const tagManagementCombine = e.target.closest('#tag-management-combine');
  284. if (tagManagementCombine) {
  285. e.preventDefault();
  286. handleCombineRequest(tagManagementCombine);
  287. }
  288. if (e.target.closest('[data-action="addstandardtag"]')) {
  289. e.preventDefault();
  290. addStandardTags();
  291. }
  292. const bulkActionDeleteButton = e.target.closest('#tag-management-delete');
  293. if (bulkActionDeleteButton) {
  294. e.preventDefault();
  295. deleteSelectedTags(bulkActionDeleteButton);
  296. }
  297. const rowDeleteButton = e.target.closest('.tagdelete');
  298. if (rowDeleteButton) {
  299. e.preventDefault();
  300. deleteSelectedTag(rowDeleteButton);
  301. }
  302. });
  303. // When user changes tag name to some name that already exists suggest to combine the tags.
  304. $('body').on(inplaceEditableEvents.elementUpdateFailed, '[data-inplaceeditable][data-itemtype=tagname]', async(e) => {
  305. var exception = e.detail.exception; // The exception object returned by the callback.
  306. var newvalue = e.detail.newvalue; // The value that user tried to udpated the element to.
  307. var tagid = $(e.target).attr('data-itemid');
  308. if (exception.errorcode !== 'namesalreadybeeingused') {
  309. return;
  310. }
  311. e.preventDefault(); // This will prevent default error dialogue.
  312. try {
  313. await Notification.saveCancelPromise(
  314. getString('confirm'),
  315. getString('nameuseddocombine', 'tag'),
  316. getString('yes'),
  317. getString('cancel'),
  318. );
  319. // The Promise will resolve on 'Yes' button, and reject on 'Cancel' button.
  320. const redirectTarget = new URL(window.location);
  321. redirectTarget.searchParams.set('newname', newvalue);
  322. redirectTarget.searchParams.set('tagid', tagid);
  323. redirectTarget.searchParams.set('action', 'renamecombine');
  324. redirectTarget.searchParams.set('sesskey', Config.sesskey);
  325. window.location.href = redirectTarget;
  326. } catch {
  327. return;
  328. }
  329. });
  330. };
  331. /**
  332. * Initialises tag collection management page.
  333. *
  334. * @method initManageCollectionsPage
  335. */
  336. export const initManageCollectionsPage = () => {
  337. $('body').on(inplaceEditableEvents.elementUpdated, '[data-inplaceeditable]', function(e) {
  338. var pendingPromise = new Pending('core/tag:initManageCollectionsPage-updated');
  339. var ajaxreturn = e.detail.ajaxreturn,
  340. areaid, collid, isenabled;
  341. if (ajaxreturn.component === 'core_tag' && ajaxreturn.itemtype === 'tagareaenable') {
  342. areaid = $(this).attr('data-itemid');
  343. $(".tag-collections-table ul[data-collectionid] li[data-areaid=" + areaid + "]").hide();
  344. isenabled = ajaxreturn.value;
  345. if (isenabled === '1') {
  346. $(this).closest('tr').removeClass('dimmed_text');
  347. collid = $(this).closest('tr').find('[data-itemtype="tagareacollection"]').attr("data-value");
  348. $(".tag-collections-table ul[data-collectionid=" + collid + "] li[data-areaid=" + areaid + "]").show();
  349. } else {
  350. $(this).closest('tr').addClass('dimmed_text');
  351. }
  352. }
  353. if (ajaxreturn.component === 'core_tag' && ajaxreturn.itemtype === 'tagareacollection') {
  354. areaid = $(this).attr('data-itemid');
  355. $(".tag-collections-table ul[data-collectionid] li[data-areaid=" + areaid + "]").hide();
  356. collid = $(this).attr('data-value');
  357. isenabled = $(this).closest('tr').find('[data-itemtype="tagareaenable"]').attr("data-value");
  358. if (isenabled === "1") {
  359. $(".tag-collections-table ul[data-collectionid=" + collid + "] li[data-areaid=" + areaid + "]").show();
  360. }
  361. }
  362. pendingPromise.resolve();
  363. });
  364. document.addEventListener('click', async(e) => {
  365. const addTagCollectionNode = e.target.closest('.addtagcoll > a');
  366. if (addTagCollectionNode) {
  367. e.preventDefault();
  368. addTagCollection(addTagCollectionNode);
  369. return;
  370. }
  371. const deleteCollectionButton = e.target.closest('.tag-collections-table .action_delete');
  372. if (deleteCollectionButton) {
  373. e.preventDefault();
  374. deleteSelectedCollection(deleteCollectionButton);
  375. }
  376. });
  377. };