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