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             }
248         }
250         inPageReplyCreateButton.fadeOut(ANIMATION_DURATION, () => {
251             const inPageReplyForm = getInPageReplyForm(postContainer);
252             inPageReplyForm.slideDown({
253                 duration: ANIMATION_DURATION,
254                 queue: false,
255                 complete: () => {
256                     inPageReplyForm.find('textarea').focus();
257                 }
258             }).css('display', 'none').fadeIn(ANIMATION_DURATION);
260             if (repliesVisibilityToggleContainer.length && hasReplies(postContainer)) {
261                 repliesVisibilityToggleContainer.fadeIn(ANIMATION_DURATION);
262                 hideReplies(postContainer);
263             }
264         });
265     };
266 };
268 /**
269  * Hide the in page reply form in the given in page reply container. The form
270  * display will be animated.
271  *
272  * @param {Object} postContainer jQuery element for the post container
273  * @param {Number|null} postIdToSee Id of the post to scroll into view (if any)
274  */
275 const hideInPageReplyForm = (postContainer, postIdToSee = null) => {
276     const inPageReplyForm = getInPageReplyForm(postContainer);
277     const inPageReplyCreateButton = getInPageReplyCreateButton(postContainer);
278     const repliesVisibilityToggleContainer = getRepliesVisibilityToggleContainer(postContainer);
280     if (repliesVisibilityToggleContainer.length && hasReplies(postContainer)) {
281         repliesVisibilityToggleContainer.fadeOut(ANIMATION_DURATION);
282         if (!repliesVisible(postContainer)) {
283             showReplies(postContainer, postIdToSee);
284         }
285     }
287     inPageReplyForm.slideUp({
288         duration: ANIMATION_DURATION,
289         queue: false,
290         complete: () => {
291             inPageReplyCreateButton.fadeIn(ANIMATION_DURATION);
292         }
293     }).fadeOut(200);
294 };
296 /**
297  * Check if the in page reply container contains the in page reply form.
298  *
299  * @param {Object} inPageReplyContainer jQuery element for the in page reply container
300  * @return {Bool}
301  */
302 const hasInPageReplyForm = (inPageReplyContainer) => {
303     return inPageReplyContainer.find(Selectors.post.inpageReplyContent).length > 0;
304 };
306 /**
307  * Render the template to generate the in page reply form HTML.
308  *
309  * @param {Object} additionalTemplateContext Additional render context for the in page reply template
310  * @param {Object} button jQuery element for the reply button that was clicked
311  * @param {Object} postContainer jQuery element for the post container
312  * @return {Object} jQuery promise
313  */
314 const renderInPageReplyTemplate = (additionalTemplateContext, button, postContainer) => {
315     const postContentContainer = getPostContentContainer(postContainer);
316     const currentSubject = postContentContainer.find(Selectors.post.forumSubject).text();
317     const currentAuthorName = postContentContainer.find(Selectors.post.authorName).text();
318     const context = {
319         postid: postContainer.data('post-id'),
320         "reply_url": button.attr('data-href'),
321         sesskey: M.cfg.sesskey,
322         parentsubject: currentSubject,
323         parentauthorname: currentAuthorName,
324         canreplyprivately: button.data('can-reply-privately'),
325         postformat: InPageReply.CONTENT_FORMATS.MOODLE,
326         ...additionalTemplateContext
327     };
329     return Templates.render('mod_forum/inpage_reply_v2', context);
330 };
332 /**
333  * Increment the total reply count in the show/hide replies buttons for the post.
334  *
335  * @param {Object} postContainer jQuery element for the post container
336  */
337 const incrementTotalReplyCount = (postContainer) => {
338     getRepliesVisibilityToggleContainer(postContainer).find(Selectors.post.replyCount).each((index, element) => {
339         const currentCount = parseInt(element.innerText, 10);
340         element.innerText = currentCount + 1;
341     });
342 };
344 /**
345  * Create all of the event listeners for the discussion.
346  *
347  * @param {Object} root jQuery element for the discussion container
348  */
349 const registerEventListeners = (root) => {
350     CustomEvents.define(root, [CustomEvents.events.activate]);
351     // Auto expanding text area for in page reply.
352     AutoRows.init(root);
354     // Reply button is clicked.
355     root.on(CustomEvents.events.activate, Selectors.post.inpageReplyCreateButton, (e, data) => {
356         data.originalEvent.preventDefault();
357         const postContainer = getPostContainer($(e.currentTarget));
358         showInPageReplyForm(postContainer);
359     });
361     // Cancel in page reply button.
362     root.on(CustomEvents.events.activate, Selectors.post.inpageReplyCancelButton, (e, data) => {
363         data.originalEvent.preventDefault();
364         const postContainer = getPostContainer($(e.currentTarget));
365         hideInPageReplyForm(postContainer);
366     });
368     // Show replies button clicked.
369     root.on(CustomEvents.events.activate, Selectors.post.showReplies, (e, data) => {
370         data.originalEvent.preventDefault();
371         const postContainer = getPostContainer($(e.target));
372         showReplies(postContainer);
373     });
375     // Hide replies button clicked.
376     root.on(CustomEvents.events.activate, Selectors.post.hideReplies, (e, data) => {
377         data.originalEvent.preventDefault();
378         const postContainer = getPostContainer($(e.target));
379         hideReplies(postContainer);
380     });
382     // Post created with in page reply.
383     root.on(InPageReply.EVENTS.POST_CREATED, Selectors.post.inpageSubmitBtn, (e, newPostId) => {
384         const currentTarget = $(e.currentTarget);
385         const postContainer = getPostContainer(currentTarget);
386         const postContainers = getParentPostContainers(currentTarget);
387         hideInPageReplyForm(postContainer, newPostId);
389         postContainers.each((index, container) => {
390             incrementTotalReplyCount($(container));
391         });
392     });
393 };
395 /**
396  * Initialise the javascript for the discussion in nested v2 display mode.
397  *
398  * @param {Object} root jQuery element for the discussion container
399  * @param {Object} context Additional render context for the in page reply template
400  */
401 export const init = (root, context) => {
402     // Build the showInPageReplyForm function with the additional render context.
403     showInPageReplyForm = buildShowInPageReplyFormFunction(context);
404     // Add discussion event listeners.
405     registerEventListeners(root);
406     // Initialise default discussion javascript (keyboard nav etc).
407     Discussion.init(root);
408     // Add in page reply javascript.
409     InPageReply.init(root);
411     // Initialise the settings menu javascript.
412     const discussionToolsContainer = root.find(Selectors.discussion.tools);
413     LockToggle.init(discussionToolsContainer, false);
414     FavouriteToggle.init(discussionToolsContainer, false, (toggleElement, response) => {
415         const newTargetState = response.userstate.favourited ? 0 : 1;
416         return toggleElement.data('targetstate', newTargetState);
417     });
418     Pin.init(discussionToolsContainer, false, (toggleElement, response) => {
419         const newTargetState = response.pinned ? 0 : 1;
420         return toggleElement.data('targetstate', newTargetState);
421     });
422     Subscribe.init(discussionToolsContainer, false, (toggleElement, response) => {
423         const newTargetState = response.userstate.subscribed ? 0 : 1;
424         toggleElement.data('targetstate', newTargetState);
425     });
426 };