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