mod/bigbluebuttonbn/amd/src/recordings.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. * JS for the recordings page on mod_bigbluebuttonbn plugin.
  17. *
  18. * @module mod_bigbluebuttonbn/recordings
  19. * @copyright 2021 Blindside Networks Inc
  20. * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  21. */
  22. import * as repository from './repository';
  23. import {exception as displayException, saveCancelPromise} from 'core/notification';
  24. import {prefetchStrings} from 'core/prefetch';
  25. import {getString, getStrings} from 'core/str';
  26. import {addIconToContainerWithPromise} from 'core/loadingicon';
  27. import Pending from 'core/pending';
  28. const stringsWithKeys = {
  29. first: 'view_recording_yui_first',
  30. prev: 'view_recording_yui_prev',
  31. next: 'view_recording_yui_next',
  32. last: 'view_recording_yui_last',
  33. goToLabel: 'view_recording_yui_page',
  34. goToAction: 'view_recording_yui_go',
  35. perPage: 'view_recording_yui_rows',
  36. showAll: 'view_recording_yui_show_all',
  37. };
  38. // Load global strings.
  39. prefetchStrings('bigbluebuttonbn', Object.entries(stringsWithKeys).map((entry) => entry[1]));
  40. const getStringsForYui = () => {
  41. const stringMap = Object.keys(stringsWithKeys).map(key => {
  42. return {
  43. key: stringsWithKeys[key],
  44. component: 'mod_bigbluebuttonbn',
  45. };
  46. });
  47. // Return an object with the matching string keys (we want an object with {<stringkey>: <stringvalue>...}).
  48. return getStrings(stringMap)
  49. .then((stringArray) => Object.assign(
  50. {},
  51. ...Object.keys(stringsWithKeys).map(
  52. (key, index) => ({[key]: stringArray[index]})
  53. )
  54. ));
  55. };
  56. const getYuiInstance = lang => new Promise(resolve => {
  57. // eslint-disable-next-line
  58. YUI({
  59. lang,
  60. }).use('intl', 'datatable', 'datatable-sort', 'datatable-paginator', 'datatype-number', Y => {
  61. resolve(Y);
  62. });
  63. });
  64. /**
  65. * Format the supplied date per the specified locale.
  66. *
  67. * @param {string} locale
  68. * @param {number} date
  69. * @returns {array}
  70. */
  71. const formatDate = (locale, date) => {
  72. const realDate = new Date(date);
  73. return realDate.toLocaleDateString(locale, {
  74. weekday: 'long',
  75. year: 'numeric',
  76. month: 'long',
  77. day: 'numeric',
  78. });
  79. };
  80. /**
  81. * Format response data for the table.
  82. *
  83. * @param {string} response JSON-encoded table data
  84. * @returns {array}
  85. */
  86. const getFormattedData = response => {
  87. const recordingData = response.tabledata;
  88. return JSON.parse(recordingData.data);
  89. };
  90. const getTableNode = tableSelector => document.querySelector(tableSelector);
  91. const fetchRecordingData = tableSelector => {
  92. const tableNode = getTableNode(tableSelector);
  93. if (tableNode === null) {
  94. return Promise.resolve(false);
  95. }
  96. if (tableNode.dataset.importMode) {
  97. return repository.fetchRecordingsToImport(
  98. tableNode.dataset.bbbid,
  99. tableNode.dataset.bbbSourceInstanceId,
  100. tableNode.dataset.bbbSourceCourseId,
  101. tableNode.dataset.tools,
  102. tableNode.dataset.groupId
  103. );
  104. } else {
  105. return repository.fetchRecordings(
  106. tableNode.dataset.bbbid,
  107. tableNode.dataset.tools,
  108. tableNode.dataset.groupId
  109. );
  110. }
  111. };
  112. /**
  113. * Fetch the data table functinos for the specified table.
  114. *
  115. * @param {String} tableId in which we will display the table
  116. * @param {String} searchFormId The Id of the relate.
  117. * @param {Object} dataTable
  118. * @returns {Object}
  119. * @private
  120. */
  121. const getDataTableFunctions = (tableId, searchFormId, dataTable) => {
  122. const tableNode = getTableNode(tableId);
  123. const bbbid = tableNode.dataset.bbbid;
  124. const updateTableFromResponse = response => {
  125. if (!response || !response.status) {
  126. // There was no output at all.
  127. return;
  128. }
  129. dataTable.get('data').reset(getFormattedData(response));
  130. dataTable.set(
  131. 'currentData',
  132. dataTable.get('data')
  133. );
  134. const currentFilter = dataTable.get('currentFilter');
  135. if (currentFilter) {
  136. filterByText(currentFilter);
  137. }
  138. };
  139. const refreshTableData = () => fetchRecordingData(tableId).then(updateTableFromResponse);
  140. const filterByText = value => {
  141. const dataModel = dataTable.get('currentData');
  142. dataTable.set('currentFilter', value);
  143. const escapedRegex = value.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&");
  144. const rsearch = new RegExp(`<span>.*?${escapedRegex}.*?</span>`, 'i');
  145. dataTable.set('data', dataModel.filter({asList: true}, item => {
  146. const name = item.get('recording');
  147. if (name && rsearch.test(name)) {
  148. return true;
  149. }
  150. const description = item.get('description');
  151. return description && rsearch.test(description);
  152. }));
  153. };
  154. const requestAction = async(element) => {
  155. const getDataFromAction = (element, dataType) => {
  156. const dataElement = element.closest(`[data-${dataType}]`);
  157. if (dataElement) {
  158. return dataElement.dataset[dataType];
  159. }
  160. return null;
  161. };
  162. const elementData = element.dataset;
  163. const payload = {
  164. bigbluebuttonbnid: bbbid,
  165. recordingid: getDataFromAction(element, 'recordingid'),
  166. additionaloptions: getDataFromAction(element, 'additionaloptions'),
  167. action: elementData.action,
  168. };
  169. // Slight change for import, for additional options.
  170. if (!payload.additionaloptions) {
  171. payload.additionaloptions = {};
  172. }
  173. if (elementData.action === 'import') {
  174. const bbbsourceid = getDataFromAction(element, 'source-instance-id');
  175. const bbbcourseid = getDataFromAction(element, 'source-course-id');
  176. if (!payload.additionaloptions) {
  177. payload.additionaloptions = {};
  178. }
  179. payload.additionaloptions.sourceid = bbbsourceid ? bbbsourceid : 0;
  180. payload.additionaloptions.bbbcourseid = bbbcourseid ? bbbcourseid : 0;
  181. }
  182. // Now additional options should be a json string.
  183. payload.additionaloptions = JSON.stringify(payload.additionaloptions);
  184. if (element.dataset.requireConfirmation === "1") {
  185. // Create the confirmation dialogue.
  186. try {
  187. await saveCancelPromise(
  188. getString('confirm'),
  189. recordingConfirmationMessage(payload),
  190. getString('ok', 'moodle'),
  191. );
  192. } catch {
  193. // User cancelled the dialogue.
  194. return;
  195. }
  196. }
  197. return repository.updateRecording(payload);
  198. };
  199. const recordingConfirmationMessage = async(data) => {
  200. const playbackElement = document.querySelector(`#playbacks-${data.recordingid}`);
  201. const recordingType = await getString(
  202. playbackElement.dataset.imported === 'true' ? 'view_recording_link' : 'view_recording',
  203. 'bigbluebuttonbn'
  204. );
  205. const confirmation = await getString(`view_recording_${data.action}_confirmation`, 'bigbluebuttonbn', recordingType);
  206. if (data.action === 'import') {
  207. return confirmation;
  208. }
  209. // If it has associated links imported in a different course/activity, show that in confirmation dialog.
  210. const associatedLinkCount = document.querySelector(`a#recording-${data.action}-${data.recordingid}`)?.dataset?.links;
  211. if (!associatedLinkCount || associatedLinkCount === 0) {
  212. return confirmation;
  213. }
  214. const confirmationWarning = await getString(
  215. associatedLinkCount === 1
  216. ? `view_recording_${data.action}_confirmation_warning_p`
  217. : `view_recording_${data.action}_confirmation_warning_s`,
  218. 'bigbluebuttonbn',
  219. associatedLinkCount
  220. );
  221. return confirmationWarning + '\n\n' + confirmation;
  222. };
  223. /**
  224. * Process an action event.
  225. *
  226. * @param {Event} e
  227. */
  228. const processAction = e => {
  229. const popoutLink = e.target.closest('[data-action="play"]');
  230. if (popoutLink) {
  231. e.preventDefault();
  232. const videoPlayer = window.open('', '_blank');
  233. videoPlayer.opener = null;
  234. videoPlayer.location.href = popoutLink.href;
  235. // TODO send a recording viewed event when this event will be implemented.
  236. return;
  237. }
  238. // Fetch any clicked anchor.
  239. const clickedLink = e.target.closest('a[data-action]');
  240. if (clickedLink && !clickedLink.classList.contains('disabled')) {
  241. e.preventDefault();
  242. // Create a spinning icon on the table.
  243. const iconPromise = addIconToContainerWithPromise(dataTable.get('boundingBox').getDOMNode());
  244. requestAction(clickedLink)
  245. .then(refreshTableData)
  246. .then(iconPromise.resolve)
  247. .catch(displayException);
  248. }
  249. };
  250. const processSearchSubmission = e => {
  251. // Prevent the default action.
  252. e.preventDefault();
  253. const parentNode = e.target.closest('div[role=search]');
  254. const searchInput = parentNode.querySelector('input[name=search]');
  255. filterByText(searchInput.value);
  256. };
  257. const registerEventListeners = () => {
  258. // Add event listeners to the table boundingBox.
  259. const boundingBox = dataTable.get('boundingBox').getDOMNode();
  260. boundingBox.addEventListener('click', processAction);
  261. // Setup the search from handlers.
  262. const searchForm = document.querySelector(searchFormId);
  263. if (searchForm) {
  264. const searchButton = document.querySelector(searchFormId + ' button');
  265. searchButton.addEventListener('click', processSearchSubmission);
  266. }
  267. };
  268. return {
  269. filterByText,
  270. refreshTableData,
  271. registerEventListeners,
  272. };
  273. };
  274. /**
  275. * Setup the data table for the specified BBB instance.
  276. *
  277. * @param {String} tableId in which we will display the table
  278. * @param {String} searchFormId The Id of the relate.
  279. * @param {object} response The response from the data request
  280. * @returns {Promise}
  281. */
  282. const setupDatatable = (tableId, searchFormId, response) => {
  283. if (!response) {
  284. return Promise.resolve();
  285. }
  286. if (!response.status) {
  287. // Something failed. Continue to show the plain output.
  288. return Promise.resolve();
  289. }
  290. const recordingData = response.tabledata;
  291. const pendingPromise = new Pending('mod_bigbluebuttonbn/recordings/setupDatatable');
  292. return Promise.all([getYuiInstance(recordingData.locale), getStringsForYui()])
  293. .then(([yuiInstance, strings]) => {
  294. // Here we use a custom formatter for date.
  295. // See https://clarle.github.io/yui3/yui/docs/api/classes/DataTable.BodyView.Formatters.html
  296. // Inspired from examples here: https://clarle.github.io/yui3/yui/docs/datatable/
  297. // Normally formatter have the prototype: (col) => (cell) => <computed value>, see:
  298. // https://clarle.github.io/yui3/yui/docs/api/files/datatable_js_formatters.js.html#l100 .
  299. const dateCustomFormatter = () => (cell) => formatDate(recordingData.locale, cell.value);
  300. // Add the fetched strings to the YUI Instance.
  301. yuiInstance.Intl.add('datatable-paginator', yuiInstance.config.lang, {...strings});
  302. yuiInstance.DataTable.BodyView.Formatters.customDate = dateCustomFormatter;
  303. return yuiInstance;
  304. })
  305. .then(yuiInstance => {
  306. const tableData = getFormattedData(response);
  307. yuiInstance.RecordsPaginatorView = Y.Base.create('my-paginator-view', yuiInstance.DataTable.Paginator.View, [], {
  308. _modelChange: function(e) {
  309. var changed = e.changed,
  310. totalItems = (changed && changed.totalItems);
  311. if (totalItems) {
  312. this._updateControlsUI(e.target.get('page'));
  313. }
  314. }
  315. });
  316. return new yuiInstance.DataTable({
  317. paginatorView: "RecordsPaginatorView",
  318. width: "1195px",
  319. columns: recordingData.columns,
  320. data: tableData,
  321. rowsPerPage: 10,
  322. paginatorLocation: ['header', 'footer'],
  323. autoSync: true
  324. });
  325. })
  326. .then(dataTable => {
  327. dataTable.render(tableId);
  328. const {registerEventListeners} = getDataTableFunctions(
  329. tableId,
  330. searchFormId,
  331. dataTable);
  332. registerEventListeners();
  333. return dataTable;
  334. })
  335. .then(dataTable => {
  336. pendingPromise.resolve();
  337. return dataTable;
  338. });
  339. };
  340. /**
  341. * Initialise recordings code.
  342. *
  343. * @method init
  344. * @param {String} tableId in which we will display the table
  345. * @param {String} searchFormId The Id of the relate.
  346. */
  347. export const init = (tableId, searchFormId) => {
  348. const pendingPromise = new Pending('mod_bigbluebuttonbn/recordings:init');
  349. fetchRecordingData(tableId)
  350. .then(response => setupDatatable(tableId, searchFormId, response))
  351. .then(() => pendingPromise.resolve())
  352. .catch(displayException);
  353. };