mod/assign/amd/src/grading_navigation.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. * Javascript to handle changing users via the user selector in the header.
  17. *
  18. * @module mod_assign/grading_navigation
  19. * @copyright 2016 Damyon Wiese <damyon@moodle.com>
  20. * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  21. * @since 3.1
  22. */
  23. define(['jquery', 'core/notification', 'core/str', 'core/form-autocomplete',
  24. 'core/ajax', 'core_user/repository', 'mod_assign/grading_form_change_checker'],
  25. function($, notification, str, autocomplete, ajax, UserRepository, checker) {
  26. /**
  27. * GradingNavigation class.
  28. *
  29. * @class mod_assign/grading_navigation
  30. * @param {String} selector The selector for the page region containing the user navigation.
  31. */
  32. var GradingNavigation = function(selector) {
  33. this._regionSelector = selector;
  34. this._region = $(selector);
  35. this._filters = [];
  36. this._users = [];
  37. this._filteredUsers = [];
  38. this._lastXofYUpdate = 0;
  39. this._firstLoadUsers = true;
  40. let url = new URL(window.location);
  41. if (parseInt(url.searchParams.get('treset')) > 0) {
  42. // Remove 'treset' url parameter to make sure that
  43. // table preferences won't be reset on page refresh.
  44. url.searchParams.delete('treset');
  45. window.history.replaceState({}, "", url);
  46. }
  47. // Get the current user list from a webservice.
  48. this._loadAllUsers();
  49. // We do not allow navigation while ajax requests are pending.
  50. // Attach listeners to the select and arrow buttons.
  51. this._region.find('[data-action="previous-user"]').on('click', this._handlePreviousUser.bind(this));
  52. this._region.find('[data-action="next-user"]').on('click', this._handleNextUser.bind(this));
  53. this._region.find('[data-action="change-user"]').on('change', this._handleChangeUser.bind(this));
  54. this._region.find('[data-region="user-filters"]').on('click', this._toggleExpandFilters.bind(this));
  55. this._region.find('[data-region="user-resettable"]').on('click', this._toggleResetTable.bind());
  56. $(document).on('user-changed', this._refreshSelector.bind(this));
  57. $(document).on('reset-table', this._toggleResetTable.bind(this));
  58. $(document).on('done-saving-show-next', this._handleNextUser.bind(this));
  59. // Position the configure filters panel under the link that expands it.
  60. var toggleLink = this._region.find('[data-region="user-filters"]');
  61. var configPanel = $(document.getElementById(toggleLink.attr('aria-controls')));
  62. configPanel.on('change', 'select', this._filterChanged.bind(this));
  63. var userid = $('[data-region="grading-navigation-panel"]').data('first-userid');
  64. if (userid) {
  65. this._selectUserById(userid);
  66. }
  67. str.get_string('changeuser', 'mod_assign').done(function(s) {
  68. autocomplete.enhance('[data-action=change-user]', false, 'mod_assign/participant_selector', s);
  69. }
  70. ).fail(notification.exception);
  71. $(document).bind("start-loading-user", function() {
  72. this._isLoading = true;
  73. }.bind(this));
  74. $(document).bind("finish-loading-user", function() {
  75. this._isLoading = false;
  76. }.bind(this));
  77. };
  78. /** @property {Boolean} Boolean tracking active ajax requests. */
  79. GradingNavigation.prototype._isLoading = false;
  80. /** @property {String} Selector for the page region containing the user navigation. */
  81. GradingNavigation.prototype._regionSelector = null;
  82. /** @property {Array} The list of active filter keys */
  83. GradingNavigation.prototype._filters = null;
  84. /** @property {Array} The list of users */
  85. GradingNavigation.prototype._users = null;
  86. /** @property {JQuery} JQuery node for the page region containing the user navigation. */
  87. GradingNavigation.prototype._region = null;
  88. /** @property {String} Last active filters */
  89. GradingNavigation.prototype._lastFilters = '';
  90. /**
  91. * Load the list of all users for this assignment.
  92. *
  93. * @private
  94. * @method _loadAllUsers
  95. * @return {Boolean} True if the user list was fetched.
  96. */
  97. GradingNavigation.prototype._loadAllUsers = function() {
  98. var select = this._region.find('[data-action=change-user]');
  99. var assignmentid = select.attr('data-assignmentid');
  100. var groupid = select.attr('data-groupid');
  101. var filterPanel = this._region.find('[data-region="configure-filters"]');
  102. var filter = filterPanel.find('select[name="filter"]').val();
  103. var workflowFilter = filterPanel.find('select[name="workflowfilter"]');
  104. if (workflowFilter) {
  105. filter += ',' + workflowFilter.val();
  106. }
  107. var markerFilter = filterPanel.find('select[name="markerfilter"]');
  108. if (markerFilter) {
  109. filter += ',' + markerFilter.val();
  110. }
  111. if (this._lastFilters == filter) {
  112. return false;
  113. }
  114. this._lastFilters = filter;
  115. ajax.call([{
  116. methodname: 'mod_assign_list_participants',
  117. args: {assignid: assignmentid, groupid: groupid, filter: '', onlyids: true, tablesort: true},
  118. done: this._usersLoaded.bind(this),
  119. fail: notification.exception
  120. }]);
  121. return true;
  122. };
  123. /**
  124. * Call back to rebuild the user selector and x of y info when the user list is updated.
  125. *
  126. * @private
  127. * @method _usersLoaded
  128. * @param {Array} users
  129. */
  130. GradingNavigation.prototype._usersLoaded = function(users) {
  131. this._firstLoadUsers = false;
  132. this._filteredUsers = this._users = users;
  133. if (this._users.length) {
  134. // Position the configure filters panel under the link that expands it.
  135. var toggleLink = this._region.find('[data-region="user-filters"]');
  136. var configPanel = $(document.getElementById(toggleLink.attr('aria-controls')));
  137. configPanel.find('select[name="filter"]').trigger('change');
  138. $('[data-region="grade-panel"]').show();
  139. $('[data-region="grade-actions-panel"]').show();
  140. } else {
  141. this._selectNoUser();
  142. }
  143. this._triggerNextUserEvent();
  144. };
  145. /**
  146. * Close the configure filters panel if a click is detected outside of it.
  147. *
  148. * @private
  149. * @method _checkClickOutsideConfigureFilters
  150. * @param {Event} event
  151. */
  152. GradingNavigation.prototype._checkClickOutsideConfigureFilters = function(event) {
  153. var configPanel = this._region.find('[data-region="configure-filters"]');
  154. if (!configPanel.is(event.target) && configPanel.has(event.target).length === 0) {
  155. var toggleLink = this._region.find('[data-region="user-filters"]');
  156. configPanel.hide();
  157. configPanel.attr('aria-hidden', 'true');
  158. toggleLink.attr('aria-expanded', 'false');
  159. $(document).unbind('click.mod_assign_grading_navigation');
  160. }
  161. };
  162. /**
  163. * Close the configure filters panel if a click is detected outside of it.
  164. *
  165. * @private
  166. * @method _updateFilterPreference
  167. * @param {Number} userId The current user id.
  168. * @param {Array} filterList The list of current filter values.
  169. * @param {Array} preferenceNames The names of the preferences to update
  170. * @return {Promise} Resolved when all the preferences are updated.
  171. */
  172. GradingNavigation.prototype._updateFilterPreferences = function(userId, filterList, preferenceNames) {
  173. var preferences = [],
  174. i = 0;
  175. if (filterList.length == 0 || this._firstLoadUsers) {
  176. // Nothing to update.
  177. var deferred = $.Deferred();
  178. deferred.resolve();
  179. return deferred;
  180. }
  181. // General filter.
  182. // Set the user preferences to the current filters.
  183. for (i = 0; i < filterList.length; i++) {
  184. var newValue = filterList[i];
  185. if (newValue == 'none') {
  186. newValue = '';
  187. }
  188. preferences.push({
  189. userid: userId,
  190. name: preferenceNames[i],
  191. value: newValue
  192. });
  193. }
  194. return UserRepository.setUserPreferences(preferences);
  195. };
  196. /**
  197. * Turn a filter on or off.
  198. *
  199. * @private
  200. * @method _filterChanged
  201. */
  202. GradingNavigation.prototype._filterChanged = function() {
  203. // There are 3 types of filter right now.
  204. var filterPanel = this._region.find('[data-region="configure-filters"]');
  205. var filters = filterPanel.find('select');
  206. var preferenceNames = [];
  207. this._filters = [];
  208. filters.each(function(idx, ele) {
  209. var element = $(ele);
  210. this._filters.push(element.val());
  211. preferenceNames.push('assign_' + element.prop('name'));
  212. }.bind(this));
  213. // Update the active filter string.
  214. var filterlist = [];
  215. filterPanel.find('option:checked').each(function(idx, ele) {
  216. filterlist[filterlist.length] = $(ele).text();
  217. });
  218. if (filterlist.length) {
  219. this._region.find('[data-region="user-filters"] span').text(filterlist.join(', '));
  220. } else {
  221. str.get_string('nofilters', 'mod_assign').done(function(s) {
  222. this._region.find('[data-region="user-filters"] span').text(s);
  223. }.bind(this)).fail(notification.exception);
  224. }
  225. var select = this._region.find('[data-action=change-user]');
  226. var currentUserID = select.data('currentuserid');
  227. this._updateFilterPreferences(currentUserID, this._filters, preferenceNames).then(function() {
  228. // Reload the list of users to apply the new filters.
  229. if (!this._loadAllUsers()) {
  230. var userid = parseInt(select.attr('data-selected'));
  231. let foundIndex = null;
  232. // Search the returned users for the current selection.
  233. $.each(this._filteredUsers, function(index, user) {
  234. if (userid == user.id) {
  235. foundIndex = index;
  236. }
  237. });
  238. if (this._filteredUsers.length) {
  239. this._selectUserById(this._filteredUsers[foundIndex ?? 0].id);
  240. } else {
  241. this._selectNoUser();
  242. }
  243. }
  244. }.bind(this)).catch(notification.exception);
  245. this._refreshCount();
  246. };
  247. /**
  248. * Select no users, because no users match the filters.
  249. *
  250. * @private
  251. * @method _selectNoUser
  252. */
  253. GradingNavigation.prototype._selectNoUser = function() {
  254. // Detect unsaved changes, and offer to save them - otherwise change user right now.
  255. if (this._isLoading) {
  256. return;
  257. }
  258. $('[data-region="grade-panel"]').hide();
  259. $('[data-region="grade-actions-panel"]').hide();
  260. if (checker.checkFormForChanges('[data-region="grade-panel"] .gradeform')) {
  261. // Form has changes, so we need to confirm before switching users.
  262. str.get_strings([
  263. {key: 'unsavedchanges', component: 'mod_assign'},
  264. {key: 'unsavedchangesquestion', component: 'mod_assign'},
  265. {key: 'saveandcontinue', component: 'mod_assign'},
  266. {key: 'cancel', component: 'core'},
  267. ]).done(function(strs) {
  268. notification.confirm(strs[0], strs[1], strs[2], strs[3], function() {
  269. $(document).trigger('save-changes', -1);
  270. });
  271. });
  272. } else {
  273. $(document).trigger('user-changed', -1);
  274. }
  275. };
  276. /**
  277. * Select the specified user by id.
  278. *
  279. * @private
  280. * @method _selectUserById
  281. * @param {Number} userid
  282. */
  283. GradingNavigation.prototype._selectUserById = function(userid) {
  284. var select = this._region.find('[data-action=change-user]');
  285. var useridnumber = parseInt(userid, 10);
  286. // Detect unsaved changes, and offer to save them - otherwise change user right now.
  287. if (this._isLoading) {
  288. return;
  289. }
  290. if (checker.checkFormForChanges('[data-region="grade-panel"] .gradeform')) {
  291. // Form has changes, so we need to confirm before switching users.
  292. str.get_strings([
  293. {key: 'unsavedchanges', component: 'mod_assign'},
  294. {key: 'unsavedchangesquestion', component: 'mod_assign'},
  295. {key: 'saveandcontinue', component: 'mod_assign'},
  296. {key: 'cancel', component: 'core'},
  297. ]).done(function(strs) {
  298. notification.confirm(strs[0], strs[1], strs[2], strs[3], function() {
  299. $(document).trigger('save-changes', useridnumber);
  300. });
  301. });
  302. } else {
  303. select.attr('data-selected', userid);
  304. // If we have some filtered users, and userid is specified, then trigger change.
  305. if (this._filteredUsers.length > 0 && !isNaN(useridnumber) && useridnumber > 0) {
  306. $(document).trigger('user-changed', useridnumber);
  307. }
  308. }
  309. };
  310. /**
  311. * Expand or collapse the filter config panel.
  312. *
  313. * @private
  314. * @method _toggleExpandFilters
  315. * @param {Event} event
  316. */
  317. GradingNavigation.prototype._toggleExpandFilters = function(event) {
  318. event.preventDefault();
  319. var toggleLink = $(event.target).closest('[data-region="user-filters"]');
  320. var expanded = toggleLink.attr('aria-expanded') == 'true';
  321. var configPanel = $(document.getElementById(toggleLink.attr('aria-controls')));
  322. if (expanded) {
  323. configPanel.hide();
  324. configPanel.attr('aria-hidden', 'true');
  325. toggleLink.attr('aria-expanded', 'false');
  326. $(document).unbind('click.mod_assign_grading_navigation');
  327. } else {
  328. configPanel.css('display', 'inline-block');
  329. configPanel.attr('aria-hidden', 'false');
  330. toggleLink.attr('aria-expanded', 'true');
  331. event.stopPropagation();
  332. $(document).on('click.mod_assign_grading_navigation', this._checkClickOutsideConfigureFilters.bind(this));
  333. }
  334. };
  335. /**
  336. * Reset table preferences.
  337. *
  338. * @private
  339. * @method _toggleResetTable
  340. */
  341. GradingNavigation.prototype._toggleResetTable = function() {
  342. let url = new URL(window.location);
  343. url.searchParams.set('treset', '1');
  344. window.location.href = url;
  345. };
  346. /**
  347. * Change to the previous user in the grading list.
  348. *
  349. * @private
  350. * @method _handlePreviousUser
  351. * @param {Event} e
  352. */
  353. GradingNavigation.prototype._handlePreviousUser = function(e) {
  354. e.preventDefault();
  355. var select = this._region.find('[data-action=change-user]');
  356. var currentUserId = select.attr('data-selected');
  357. var i = 0;
  358. var currentIndex = 0;
  359. for (i = 0; i < this._filteredUsers.length; i++) {
  360. if (this._filteredUsers[i].id == currentUserId) {
  361. currentIndex = i;
  362. break;
  363. }
  364. }
  365. var count = this._filteredUsers.length;
  366. var newIndex = (currentIndex - 1);
  367. if (newIndex < 0) {
  368. newIndex = count - 1;
  369. }
  370. if (count) {
  371. this._selectUserById(this._filteredUsers[newIndex].id);
  372. }
  373. };
  374. /**
  375. * Change to the next user in the grading list.
  376. *
  377. * @param {Event} e
  378. * @param {Boolean} saved Has the form already been saved? Skips checking for changes if true.
  379. */
  380. GradingNavigation.prototype._handleNextUser = function(e, saved) {
  381. e.preventDefault();
  382. var select = this._region.find('[data-action=change-user]');
  383. var currentUserId = select.attr('data-selected');
  384. var i = 0;
  385. var currentIndex = 0;
  386. for (i = 0; i < this._filteredUsers.length; i++) {
  387. if (this._filteredUsers[i].id == currentUserId) {
  388. currentIndex = i;
  389. break;
  390. }
  391. }
  392. var count = this._filteredUsers.length;
  393. var newIndex = (currentIndex + 1) % count;
  394. if (saved && count) {
  395. // If we've already saved the grade, skip checking if we've made any changes.
  396. var userid = this._filteredUsers[newIndex].id;
  397. var useridnumber = parseInt(userid, 10);
  398. select.attr('data-selected', userid);
  399. if (!isNaN(useridnumber) && useridnumber > 0) {
  400. $(document).trigger('user-changed', userid);
  401. }
  402. } else if (count) {
  403. this._selectUserById(this._filteredUsers[newIndex].id);
  404. }
  405. };
  406. /**
  407. * Set count string. This method only sets the value for the last time it was ever called to deal
  408. * with promises that return in a non-predictable order.
  409. *
  410. * @private
  411. * @method _setCountString
  412. * @param {Number} x
  413. * @param {Number} y
  414. */
  415. GradingNavigation.prototype._setCountString = function(x, y) {
  416. var updateNumber = 0;
  417. this._lastXofYUpdate++;
  418. updateNumber = this._lastXofYUpdate;
  419. var param = {x: x, y: y};
  420. str.get_string('xofy', 'mod_assign', param).done(function(s) {
  421. if (updateNumber == this._lastXofYUpdate) {
  422. this._region.find('[data-region="user-count-summary"]').text(s);
  423. }
  424. }.bind(this)).fail(notification.exception);
  425. };
  426. /**
  427. * Rebuild the x of y string.
  428. *
  429. * @private
  430. * @method _refreshCount
  431. */
  432. GradingNavigation.prototype._refreshCount = function() {
  433. var select = this._region.find('[data-action=change-user]');
  434. var userid = select.attr('data-selected');
  435. var i = 0;
  436. var currentIndex = 0;
  437. if (isNaN(userid) || userid <= 0) {
  438. this._region.find('[data-region="user-count"]').hide();
  439. } else {
  440. this._region.find('[data-region="user-count"]').show();
  441. for (i = 0; i < this._filteredUsers.length; i++) {
  442. if (this._filteredUsers[i].id == userid) {
  443. currentIndex = i;
  444. break;
  445. }
  446. }
  447. var count = this._filteredUsers.length;
  448. if (count) {
  449. currentIndex += 1;
  450. }
  451. this._setCountString(currentIndex, count);
  452. // Update window URL
  453. if (currentIndex > 0) {
  454. var url = new URL(window.location);
  455. if (parseInt(url.searchParams.get('blindid')) > 0) {
  456. var newid = this._filteredUsers[currentIndex - 1].recordid;
  457. url.searchParams.set('blindid', newid);
  458. } else {
  459. url.searchParams.set('userid', userid);
  460. }
  461. // We do this so a browser refresh will return to the same user.
  462. window.history.replaceState({}, "", url);
  463. }
  464. }
  465. };
  466. /**
  467. * Respond to a user-changed event by updating the selector.
  468. *
  469. * @private
  470. * @method _refreshSelector
  471. * @param {Event} event
  472. * @param {String} userid
  473. */
  474. GradingNavigation.prototype._refreshSelector = function(event, userid) {
  475. var select = this._region.find('[data-action=change-user]');
  476. userid = parseInt(userid, 10);
  477. if (!isNaN(userid) && userid > 0) {
  478. select.attr('data-selected', userid);
  479. }
  480. this._refreshCount();
  481. };
  482. /**
  483. * Trigger the next user event depending on the number of filtered users
  484. *
  485. * @private
  486. * @method _triggerNextUserEvent
  487. */
  488. GradingNavigation.prototype._triggerNextUserEvent = function() {
  489. if (this._filteredUsers.length > 1) {
  490. $(document).trigger('next-user', {nextUserId: null, nextUser: true});
  491. } else {
  492. $(document).trigger('next-user', {nextUser: false});
  493. }
  494. };
  495. /**
  496. * Change to a different user in the grading list.
  497. *
  498. * @private
  499. * @method _handleChangeUser
  500. */
  501. GradingNavigation.prototype._handleChangeUser = function() {
  502. var select = this._region.find('[data-action=change-user]');
  503. var userid = parseInt(select.val(), 10);
  504. if (this._isLoading) {
  505. return;
  506. }
  507. if (checker.checkFormForChanges('[data-region="grade-panel"] .gradeform')) {
  508. // Form has changes, so we need to confirm before switching users.
  509. str.get_strings([
  510. {key: 'unsavedchanges', component: 'mod_assign'},
  511. {key: 'unsavedchangesquestion', component: 'mod_assign'},
  512. {key: 'saveandcontinue', component: 'mod_assign'},
  513. {key: 'cancel', component: 'core'},
  514. ]).done(function(strs) {
  515. notification.confirm(strs[0], strs[1], strs[2], strs[3], function() {
  516. $(document).trigger('save-changes', userid);
  517. });
  518. });
  519. } else {
  520. if (!isNaN(userid) && userid > 0) {
  521. select.attr('data-selected', userid);
  522. $(document).trigger('user-changed', userid);
  523. }
  524. }
  525. };
  526. return GradingNavigation;
  527. });