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