weekly release 4.0dev
[moodle.git] / mod / forum / amd / src / discussion_nested_v2.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/>.
16 /**
17  * Module for viewing a discussion in nested v2 view.
18  *
19  * @copyright  2019 Ryan Wyllie <ryan@moodle.com>
20  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
21  */
22 import $ from 'jquery';
23 import AutoRows from 'core/auto_rows';
24 import CustomEvents from 'core/custom_interaction_events';
25 import Notification from 'core/notification';
26 import Templates from 'core/templates';
27 import Discussion from 'mod_forum/discussion';
28 import InPageReply from 'mod_forum/inpage_reply';
29 import LockToggle from 'mod_forum/lock_toggle';
30 import FavouriteToggle from 'mod_forum/favourite_toggle';
31 import Pin from 'mod_forum/pin_toggle';
32 import Selectors from 'mod_forum/selectors';
33 import Subscribe from 'mod_forum/subscription_toggle';
35 const ANIMATION_DURATION = 150;
37 /**
38  * Get the closest post container element from the given element.
39  *
40  * @param {Object} element jQuery element to search from
41  * @return {Object} jQuery element
42  */
43 const getPostContainer = (element) => {
44     return element.closest(Selectors.post.post);
45 };
47 /**
48  * Get the closest post container element from the given element.
49  *
50  * @param {Object} element jQuery element to search from
51  * @param {Number} id Id of the post to find.
52  * @return {Object} jQuery element
53  */
54 const getPostContainerById = (element, id) => {
55     return element.find(`${Selectors.post.post}[data-post-id=${id}]`);
56 };
58 /**
59  * Get the parent post container elements from the given element.
60  *
61  * @param {Object} element jQuery element to search from
62  * @return {Object} jQuery element
63  */
64 const getParentPostContainers = (element) => {
65     return element.parents(Selectors.post.post);
66 };
68 /**
69  * Get the post content container element from the post container element.
70  *
71  * @param {Object} postContainer jQuery element for the post container
72  * @return {Object} jQuery element
73  */
74 const getPostContentContainer = (postContainer) => {
75     return postContainer.children().not(Selectors.post.repliesContainer).find(Selectors.post.forumCoreContent);
76 };
78 /**
79  * Get the in page reply container element from the post container element.
80  *
81  * @param {Object} postContainer jQuery element for the post container
82  * @return {Object} jQuery element
83  */
84 const getInPageReplyContainer = (postContainer) => {
85     return postContainer.children().filter(Selectors.post.inpageReplyContainer);
86 };
88 /**
89  * Get the in page reply form element from the post container element.
90  *
91  * @param {Object} postContainer jQuery element for the post container
92  * @return {Object} jQuery element
93  */
94 const getInPageReplyForm = (postContainer) => {
95     return getInPageReplyContainer(postContainer).find(Selectors.post.inpageReplyContent);
96 };
98 /**
99  * Get the in page reply create (reply) button element from the post container element.
100  *
101  * @param {Object} postContainer jQuery element for the post container
102  * @return {Object} jQuery element
103  */
104 const getInPageReplyCreateButton = (postContainer) => {
105     return getPostContentContainer(postContainer).find(Selectors.post.inpageReplyCreateButton);
106 };
108 /**
109  * Get the replies visibility toggle container (show/hide replies button container) element
110  * from the post container element.
111  *
112  * @param {Object} postContainer jQuery element for the post container
113  * @return {Object} jQuery element
114  */
115 const getRepliesVisibilityToggleContainer = (postContainer) => {
116     return postContainer.children(Selectors.post.repliesVisibilityToggleContainer);
117 };
119 /**
120  * Get the replies container element from the post container element.
121  *
122  * @param {Object} postContainer jQuery element for the post container
123  * @return {Object} jQuery element
124  */
125 const getRepliesContainer = (postContainer) => {
126     return postContainer.children(Selectors.post.repliesContainer);
127 };
129 /**
130  * Check if the post has any replies.
131  *
132  * @param {Object} postContainer jQuery element for the post container
133  * @return {Bool}
134  */
135 const hasReplies = (postContainer) => {
136     return getRepliesContainer(postContainer).children().length > 0;
137 };
139 /**
140  * Get the show replies button element from the replies visibility toggle container element.
141  *
142  * @param {Object} replyVisibilityToggleContainer jQuery element for the toggle container
143  * @return {Object} jQuery element
144  */
145 const getShowRepliesButton = (replyVisibilityToggleContainer) => {
146     return replyVisibilityToggleContainer.find(Selectors.post.showReplies);
147 };
149 /**
150  * Get the hide replies button element from the replies visibility toggle container element.
151  *
152  * @param {Object} replyVisibilityToggleContainer jQuery element for the toggle container
153  * @return {Object} jQuery element
154  */
155 const getHideRepliesButton = (replyVisibilityToggleContainer) => {
156     return replyVisibilityToggleContainer.find(Selectors.post.hideReplies);
157 };
159 /**
160  * Check if the replies are visible.
161  *
162  * @param {Object} postContainer jQuery element for the post container
163  * @return {Bool}
164  */
165 const repliesVisible = (postContainer) => {
166     const repliesContainer = getRepliesContainer(postContainer);
167     return repliesContainer.is(':visible');
168 };
170 /**
171  * Show the post replies.
172  *
173  * @param {Object} postContainer jQuery element for the post container
174  * @param {Number|null} postIdToSee Id of the post to scroll into view (if any)
175  */
176 const showReplies = (postContainer, postIdToSee = null) => {
177     const repliesContainer = getRepliesContainer(postContainer);
178     const replyVisibilityToggleContainer = getRepliesVisibilityToggleContainer(postContainer);
179     const showButton = getShowRepliesButton(replyVisibilityToggleContainer);
180     const hideButton = getHideRepliesButton(replyVisibilityToggleContainer);
182     showButton.addClass('hidden');
183     hideButton.removeClass('hidden');
185     repliesContainer.slideDown({
186         duration: ANIMATION_DURATION,
187         queue: false,
188         complete: () => {
189             if (postIdToSee) {
190                 const postContainerToSee = getPostContainerById(repliesContainer, postIdToSee);
191                 if (postContainerToSee.length) {
192                     postContainerToSee[0].scrollIntoView();
193                 }
194             }
195         }
196     }).css('display', 'none').fadeIn(ANIMATION_DURATION);
197 };
199 /**
200  * Hide the post replies.
201  *
202  * @param {Object} postContainer jQuery element for the post container
203  */
204 const hideReplies = (postContainer) => {
205     const repliesContainer = getRepliesContainer(postContainer);
206     const replyVisibilityToggleContainer = getRepliesVisibilityToggleContainer(postContainer);
207     const showButton = getShowRepliesButton(replyVisibilityToggleContainer);
208     const hideButton = getHideRepliesButton(replyVisibilityToggleContainer);
210     showButton.removeClass('hidden');
211     hideButton.addClass('hidden');
213     repliesContainer.slideUp({
214         duration: ANIMATION_DURATION,
215         queue: false
216     }).fadeOut(ANIMATION_DURATION);
217 };
219 /** Variable to hold the showInPageReplyForm function after it's built. */
220 let showInPageReplyForm = null;
222 /**
223  * Build the showInPageReplyForm function with the given additional template context.
224  *
225  * @param {Object} additionalTemplateContext Additional render context for the in page reply template.
226  * @return {Function}
227  */
228 const buildShowInPageReplyFormFunction = (additionalTemplateContext) => {
229     /**
230      * Show the in page reply form in the given in page reply container. The form
231      * display will be animated.
232      *
233      * @param {Object} postContainer jQuery element for the post container
234      */
235     return async (postContainer) => {
237         const inPageReplyContainer = getInPageReplyContainer(postContainer);
238         const repliesVisibilityToggleContainer = getRepliesVisibilityToggleContainer(postContainer);
239         const inPageReplyCreateButton = getInPageReplyCreateButton(postContainer);
241         if (!hasInPageReplyForm(inPageReplyContainer)) {
242             try {
243                 const html = await renderInPageReplyTemplate(additionalTemplateContext, inPageReplyCreateButton, postContainer);
244                 Templates.appendNodeContents(inPageReplyContainer, html, '');
245             } catch (e) {
246                 Notification.exception(e);
247             }
249             // Load formchangechecker module.
250             import('core/yui')
251                 .then(Y => {
252                     return new Promise(resolve => {
253                         Y.use('moodle-core-formchangechecker', Y => {
254                             resolve(Y);
255                         });
256                     });
257                 })
258                 .then(Y => {
259                     M.core_formchangechecker.init({formid: Y.one(postContainer[0].querySelector('form')).generateID()});
260                     return Y;
261                 })
262                 .catch();
263         }
265         inPageReplyCreateButton.fadeOut(ANIMATION_DURATION, () => {
266             const inPageReplyForm = getInPageReplyForm(postContainer);
267             inPageReplyForm.slideDown({
268                 duration: ANIMATION_DURATION,
269                 queue: false,
270                 complete: () => {
271                     inPageReplyForm.find('textarea').focus();
272                 }
273             }).css('display', 'none').fadeIn(ANIMATION_DURATION);
275             if (repliesVisibilityToggleContainer.length && hasReplies(postContainer)) {
276                 repliesVisibilityToggleContainer.fadeIn(ANIMATION_DURATION);
277                 hideReplies(postContainer);
278             }
279         });
280     };
281 };
283 /**
284  * Hide the in page reply form in the given in page reply container. The form
285  * display will be animated.
286  *
287  * @param {Object} postContainer jQuery element for the post container
288  * @param {Number|null} postIdToSee Id of the post to scroll into view (if any)
289  */
290 const hideInPageReplyForm = (postContainer, postIdToSee = null) => {
291     const inPageReplyForm = getInPageReplyForm(postContainer);
292     const inPageReplyCreateButton = getInPageReplyCreateButton(postContainer);
293     const repliesVisibilityToggleContainer = getRepliesVisibilityToggleContainer(postContainer);
295     if (repliesVisibilityToggleContainer.length && hasReplies(postContainer)) {
296         repliesVisibilityToggleContainer.fadeOut(ANIMATION_DURATION);
297         if (!repliesVisible(postContainer)) {
298             showReplies(postContainer, postIdToSee);
299         }
300     }
302     inPageReplyForm.slideUp({
303         duration: ANIMATION_DURATION,
304         queue: false,
305         complete: () => {
306             inPageReplyCreateButton.fadeIn(ANIMATION_DURATION);
307         }
308     }).fadeOut(200);
309 };
311 /**
312  * Check if the in page reply container contains the in page reply form.
313  *
314  * @param {Object} inPageReplyContainer jQuery element for the in page reply container
315  * @return {Bool}
316  */
317 const hasInPageReplyForm = (inPageReplyContainer) => {
318     return inPageReplyContainer.find(Selectors.post.inpageReplyContent).length > 0;
319 };
321 /**
322  * Render the template to generate the in page reply form HTML.
323  *
324  * @param {Object} additionalTemplateContext Additional render context for the in page reply template
325  * @param {Object} button jQuery element for the reply button that was clicked
326  * @param {Object} postContainer jQuery element for the post container
327  * @return {Object} jQuery promise
328  */
329 const renderInPageReplyTemplate = (additionalTemplateContext, button, postContainer) => {
330     const postContentContainer = getPostContentContainer(postContainer);
331     const currentSubject = postContentContainer.find(Selectors.post.forumSubject).text();
332     const currentAuthorName = postContentContainer.find(Selectors.post.authorName).text();
333     const context = {
334         postid: postContainer.data('post-id'),
335         "reply_url": button.attr('data-href'),
336         sesskey: M.cfg.sesskey,
337         parentsubject: currentSubject,
338         parentauthorname: currentAuthorName,
339         canreplyprivately: button.data('can-reply-privately'),
340         postformat: InPageReply.CONTENT_FORMATS.MOODLE,
341         ...additionalTemplateContext
342     };
344     return Templates.render('mod_forum/inpage_reply_v2', context);
345 };
347 /**
348  * Increment the total reply count in the show/hide replies buttons for the post.
349  *
350  * @param {Object} postContainer jQuery element for the post container
351  */
352 const incrementTotalReplyCount = (postContainer) => {
353     getRepliesVisibilityToggleContainer(postContainer).find(Selectors.post.replyCount).each((index, element) => {
354         const currentCount = parseInt(element.innerText, 10);
355         element.innerText = currentCount + 1;
356     });
357 };
359 /**
360  * Create all of the event listeners for the discussion.
361  *
362  * @param {Object} root jQuery element for the discussion container
363  */
364 const registerEventListeners = (root) => {
365     CustomEvents.define(root, [CustomEvents.events.activate]);
366     // Auto expanding text area for in page reply.
367     AutoRows.init(root);
369     // Reply button is clicked.
370     root.on(CustomEvents.events.activate, Selectors.post.inpageReplyCreateButton, (e, data) => {
371         data.originalEvent.preventDefault();
372         const postContainer = getPostContainer($(e.currentTarget));
373         showInPageReplyForm(postContainer);
374     });
376     // Cancel in page reply button.
377     root.on(CustomEvents.events.activate, Selectors.post.inpageReplyCancelButton, (e, data) => {
378         data.originalEvent.preventDefault();
379         const postContainer = getPostContainer($(e.currentTarget));
380         hideInPageReplyForm(postContainer);
381     });
383     // Show replies button clicked.
384     root.on(CustomEvents.events.activate, Selectors.post.showReplies, (e, data) => {
385         data.originalEvent.preventDefault();
386         const postContainer = getPostContainer($(e.target));
387         showReplies(postContainer);
388     });
390     // Hide replies button clicked.
391     root.on(CustomEvents.events.activate, Selectors.post.hideReplies, (e, data) => {
392         data.originalEvent.preventDefault();
393         const postContainer = getPostContainer($(e.target));
394         hideReplies(postContainer);
395     });
397     // Post created with in page reply.
398     root.on(InPageReply.EVENTS.POST_CREATED, Selectors.post.inpageSubmitBtn, (e, newPostId) => {
399         const currentTarget = $(e.currentTarget);
400         const postContainer = getPostContainer(currentTarget);
401         const postContainers = getParentPostContainers(currentTarget);
402         hideInPageReplyForm(postContainer, newPostId);
404         postContainers.each((index, container) => {
405             incrementTotalReplyCount($(container));
406         });
407     });
408 };
410 /**
411  * Initialise the javascript for the discussion in nested v2 display mode.
412  *
413  * @param {Object} root jQuery element for the discussion container
414  * @param {Object} context Additional render context for the in page reply template
415  */
416 export const init = (root, context) => {
417     // Build the showInPageReplyForm function with the additional render context.
418     showInPageReplyForm = buildShowInPageReplyFormFunction(context);
419     // Add discussion event listeners.
420     registerEventListeners(root);
421     // Initialise default discussion javascript (keyboard nav etc).
422     Discussion.init(root);
423     // Add in page reply javascript.
424     InPageReply.init(root);
426     // Initialise the settings menu javascript.
427     const discussionToolsContainer = root.find(Selectors.discussion.tools);
428     LockToggle.init(discussionToolsContainer, false);
429     FavouriteToggle.init(discussionToolsContainer, false, (toggleElement, response) => {
430         const newTargetState = response.userstate.favourited ? 0 : 1;
431         return toggleElement.data('targetstate', newTargetState);
432     });
433     Pin.init(discussionToolsContainer, false, (toggleElement, response) => {
434         const newTargetState = response.pinned ? 0 : 1;
435         return toggleElement.data('targetstate', newTargetState);
436     });
437     Subscribe.init(discussionToolsContainer, false, (toggleElement, response) => {
438         const newTargetState = response.userstate.subscribed ? 0 : 1;
439         toggleElement.data('targetstate', newTargetState);
440     });
441 };