From 0844ee6875f6835472e6af5172d559c9d05dd312 Mon Sep 17 00:00:00 2001 From: Awais Ansari <79941147+awais-ansari@users.noreply.github.com> Date: Mon, 8 May 2023 16:21:29 +0500 Subject: [PATCH] Perf: improved discussions MFE's components re-rendering and loading time (#513) * chore: configure WDYR for react profiling * perf: reduced post content re-rendering * perf: post content view and it child optimization * perf: add memoization in post editor * perf: add memoization in postCommnetsView * perf: improved endorsed comment view rendering * perf: improved re-rendering in reply component * fix: uncomment questionType commentsView * fix: removed console errors in postContent area * perf: reduced postType and postId dependancy * perf: improved re-rendering in discussionHome * perf: improved re-rendering of postsList and its child components * perf: improved re-rendering of legacyTopic and learner sidebar * fix: postFilterBar filter was not updating * fix: resolve duplicate comment posts issue * fix: memory leaking issue in comments view * fix: duplicate topic posts in inContext sidebar * perf: add lazy loading * chore: remove WDYR configuration * fix: alert banner padding * chore: update package-lock file * fix: bind tour API call with buttons --- package-lock.json | 22 - public/index.html | 3 +- src/components/FormikErrorFeedback.jsx | 27 +- src/components/HTMLLoader.jsx | 8 +- .../NavigationBar/CourseTabsNavigation.jsx | 21 +- src/components/PostPreviewPanel.jsx | 33 +- src/components/Search.jsx | 81 +-- src/components/SearchInfo.jsx | 29 +- src/components/Spinner.jsx | 11 + src/components/TinyMCEEditor.jsx | 56 +- src/components/TopicStats.jsx | 14 +- src/components/index.js | 1 + src/data/hooks.js | 2 + src/discussions/common/ActionsDropdown.jsx | 39 +- src/discussions/common/AlertBanner.jsx | 84 ++- src/discussions/common/AlertBar.jsx | 16 +- src/discussions/common/AuthorLabel.jsx | 34 +- src/discussions/common/Confirmation.jsx | 8 +- .../common/EndorsedAlertBanner.jsx | 39 +- src/discussions/common/HoverCard.jsx | 54 +- src/discussions/data/constants.js | 12 + src/discussions/data/hooks.js | 92 +-- .../BlackoutInformationBanner.jsx | 27 +- .../discussions-home/DiscussionContent.jsx | 44 +- .../discussions-home/DiscussionSidebar.jsx | 66 ++- .../discussions-home/DiscussionsHome.jsx | 142 ++--- .../discussions-home/InformationBanner.jsx | 21 +- src/discussions/empty-posts/EmptyLearners.jsx | 11 +- src/discussions/empty-posts/EmptyPage.jsx | 8 +- src/discussions/empty-posts/EmptyPosts.jsx | 21 +- src/discussions/empty-posts/EmptyTopics.jsx | 23 +- .../in-context-topics/TopicPostsView.jsx | 49 +- .../in-context-topics/TopicsView.jsx | 58 +- .../components/EmptyTopics.jsx | 22 +- .../components/NoResults.jsx | 13 +- .../topic-search/TopicSearchBar.jsx | 63 +-- .../topic/ArchivedBaseGroup.jsx | 34 +- .../topic/SectionBaseGroup.jsx | 81 +-- .../in-context-topics/topic/Topic.jsx | 13 +- src/discussions/learners/LearnerPostsView.jsx | 55 +- src/discussions/learners/LearnersView.jsx | 38 +- .../learners/learner/LearnerAvatar.jsx | 33 +- .../learners/learner/LearnerCard.jsx | 35 +- .../learners/learner/LearnerFilterBar.jsx | 29 +- .../learners/learner/LearnerFooter.jsx | 45 +- .../breadcrumb-menu/BreadcrumbDropdown.jsx | 15 +- .../breadcrumb-menu/LegacyBreadcrumbMenu.jsx | 79 ++- .../navigation-bar/NavigationBar.jsx | 40 +- .../post-comments/PostCommentsView.jsx | 113 ++-- .../post-comments/comments/CommentsSort.jsx | 17 +- .../post-comments/comments/CommentsView.jsx | 82 ++- .../comments/comment/Comment.jsx | 259 +++++---- .../comments/comment/CommentEditor.jsx | 53 +- .../comments/comment/CommentHeader.jsx | 55 +- .../post-comments/comments/comment/Reply.jsx | 148 +++-- .../comments/comment/ResponseEditor.jsx | 36 +- src/discussions/post-comments/data/api.js | 3 +- src/discussions/post-comments/data/hooks.js | 47 +- .../post-comments/data/selectors.js | 9 + src/discussions/post-comments/data/thunks.js | 3 +- .../post-comments/postCommentsContext.js | 8 + src/discussions/posts/NoResults.jsx | 15 +- src/discussions/posts/PostsList.jsx | 76 ++- src/discussions/posts/PostsView.jsx | 60 +- src/discussions/posts/data/hooks.js | 26 + src/discussions/posts/data/selectors.js | 14 + src/discussions/posts/data/slices.js | 41 +- .../posts/post-actions-bar/PostActionsBar.jsx | 25 +- .../posts/post-editor/PostEditor.jsx | 525 ++++++++---------- .../posts/post-editor/PostTypeCard.jsx | 44 ++ .../posts/post-filter-bar/PostFilterBar.jsx | 117 ++-- .../posts/post/ClosePostReasonModal.jsx | 22 +- src/discussions/posts/post/LikeButton.jsx | 22 +- src/discussions/posts/post/Post.jsx | 229 +++++--- src/discussions/posts/post/PostFooter.jsx | 103 ++-- src/discussions/posts/post/PostHeader.jsx | 125 +++-- src/discussions/posts/post/PostLink.jsx | 97 ++-- .../posts/post/PostSummaryFooter.jsx | 77 ++- src/discussions/topics/TopicsView.jsx | 54 +- src/discussions/topics/data/selectors.js | 4 + .../topics/topic-group/LegacyTopicGroup.jsx | 22 +- .../topics/topic-group/TopicGroupBase.jsx | 97 ++-- .../topics/topic-group/topic/Topic.jsx | 54 +- .../tours/DiscussionsProductTour.jsx | 14 +- src/discussions/utils.js | 22 +- src/index.scss | 17 +- 86 files changed, 2501 insertions(+), 1985 deletions(-) create mode 100644 src/components/Spinner.jsx create mode 100644 src/discussions/data/constants.js create mode 100644 src/discussions/post-comments/postCommentsContext.js create mode 100644 src/discussions/posts/data/hooks.js create mode 100644 src/discussions/posts/post-editor/PostTypeCard.jsx diff --git a/package-lock.json b/package-lock.json index 01ce8c43..cdbf32d2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,7 +26,6 @@ "raw-loader": "4.0.2", "react": "16.14.0", "react-dom": "16.14.0", - "react-mathjax-preview": "2.2.6", "react-redux": "7.2.6", "react-router": "5.2.1", "react-router-dom": "5.3.0", @@ -22327,19 +22326,6 @@ "react": ">=16.8.0" } }, - "node_modules/react-mathjax-preview": { - "version": "2.2.6", - "resolved": "https://registry.npmjs.org/react-mathjax-preview/-/react-mathjax-preview-2.2.6.tgz", - "integrity": "sha512-lS+wQ49jd/XyXu8tTWWZ38qfNJrAlxsDjkUrQoldfM1MdS6s18DxFV4dwP9xoAsq86kuSb/raXU8qJrH5dtV5w==", - "dependencies": { - "dompurify": "^2.0.8" - }, - "peerDependencies": { - "prop-types": "^15", - "react": "^16 || ^17", - "react-dom": "^16 || ^17" - } - }, "node_modules/react-overlays": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/react-overlays/-/react-overlays-5.2.0.tgz", @@ -43849,14 +43835,6 @@ "integrity": "sha512-j1U1CWWs68nBPOg7tkQqnlFcAMFF6oEK6MgqAo15f8A5p7mjH6xyKn2gHbkcimpwfO0VQXqxAswnSYVr8lWzjw==", "requires": {} }, - "react-mathjax-preview": { - "version": "2.2.6", - "resolved": "https://registry.npmjs.org/react-mathjax-preview/-/react-mathjax-preview-2.2.6.tgz", - "integrity": "sha512-lS+wQ49jd/XyXu8tTWWZ38qfNJrAlxsDjkUrQoldfM1MdS6s18DxFV4dwP9xoAsq86kuSb/raXU8qJrH5dtV5w==", - "requires": { - "dompurify": "^2.0.8" - } - }, "react-overlays": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/react-overlays/-/react-overlays-5.2.0.tgz", diff --git a/public/index.html b/public/index.html index 8300ea7d..b5ca161d 100644 --- a/public/index.html +++ b/public/index.html @@ -9,7 +9,7 @@ href="<%=htmlWebpackPlugin.options.FAVICON_URL%>" type="image/x-icon" /> - diff --git a/src/components/FormikErrorFeedback.jsx b/src/components/FormikErrorFeedback.jsx index 0f61f4ff..ad17305a 100644 --- a/src/components/FormikErrorFeedback.jsx +++ b/src/components/FormikErrorFeedback.jsx @@ -5,31 +5,26 @@ import { getIn, useFormikContext } from 'formik'; import { Form, TransitionReplace } from '@edx/paragon'; -function FormikErrorFeedback({ name }) { - const { - touched, - errors, - } = useFormikContext(); +const FormikErrorFeedback = ({ name }) => { + const { touched, errors } = useFormikContext(); const fieldTouched = getIn(touched, name); const fieldError = getIn(errors, name); return ( - {fieldTouched && fieldError - ? ( - - {fieldError} - - ) - : ( - - )} + {fieldTouched && fieldError ? ( + + {fieldError} + + ) : ( + + )} ); -} +}; FormikErrorFeedback.propTypes = { name: PropTypes.string.isRequired, }; -export default FormikErrorFeedback; +export default React.memo(FormikErrorFeedback); diff --git a/src/components/HTMLLoader.jsx b/src/components/HTMLLoader.jsx index c8cd38df..d5beb12b 100644 --- a/src/components/HTMLLoader.jsx +++ b/src/components/HTMLLoader.jsx @@ -12,9 +12,9 @@ const defaultSanitizeOptions = { ADD_ATTR: ['columnalign'], }; -function HTMLLoader({ +const HTMLLoader = ({ htmlNode, componentId, cssClassName, testId, delay, -}) { +}) => { const sanitizedMath = DOMPurify.sanitize(htmlNode, { ...defaultSanitizeOptions }); const previewRef = useRef(null); const debouncedPostContent = useDebounce(htmlNode, delay); @@ -45,7 +45,7 @@ function HTMLLoader({ return (
); -} +}; HTMLLoader.propTypes = { htmlNode: PropTypes.node, @@ -63,4 +63,4 @@ HTMLLoader.defaultProps = { delay: 0, }; -export default HTMLLoader; +export default React.memo(HTMLLoader); diff --git a/src/components/NavigationBar/CourseTabsNavigation.jsx b/src/components/NavigationBar/CourseTabsNavigation.jsx index 026bd8fc..6a1c6fa3 100644 --- a/src/components/NavigationBar/CourseTabsNavigation.jsx +++ b/src/components/NavigationBar/CourseTabsNavigation.jsx @@ -4,7 +4,7 @@ import PropTypes from 'prop-types'; import classNames from 'classnames'; import { useDispatch, useSelector } from 'react-redux'; -import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { useIntl } from '@edx/frontend-platform/i18n'; import { fetchTab } from './data/thunks'; import Tabs from './tabs/Tabs'; @@ -12,12 +12,13 @@ import messages from './messages'; import './navBar.scss'; -function CourseTabsNavigation({ - activeTab, className, intl, courseId, rootSlug, -}) { +const CourseTabsNavigation = ({ + activeTab, className, courseId, rootSlug, +}) => { const dispatch = useDispatch(); - + const intl = useIntl(); const tabs = useSelector(state => state.courseTabs.tabs); + useEffect(() => { dispatch(fetchTab(courseId, rootSlug)); }, [courseId]); @@ -25,8 +26,7 @@ function CourseTabsNavigation({ return (
- {!!tabs.length - && ( + {!!tabs.length && ( ))} - )} + )}
); -} +}; CourseTabsNavigation.propTypes = { activeTab: PropTypes.string, className: PropTypes.string, rootSlug: PropTypes.string, courseId: PropTypes.string.isRequired, - intl: intlShape.isRequired, }; CourseTabsNavigation.defaultProps = { @@ -61,4 +60,4 @@ CourseTabsNavigation.defaultProps = { rootSlug: 'outline', }; -export default injectIntl(CourseTabsNavigation); +export default React.memo(CourseTabsNavigation); diff --git a/src/components/PostPreviewPanel.jsx b/src/components/PostPreviewPanel.jsx index 85f18f73..4aef8dc8 100644 --- a/src/components/PostPreviewPanel.jsx +++ b/src/components/PostPreviewPanel.jsx @@ -1,16 +1,17 @@ import React, { useState } from 'react'; import PropTypes from 'prop-types'; -import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { useIntl } from '@edx/frontend-platform/i18n'; import { Button, Icon, IconButton } from '@edx/paragon'; import { Close } from '@edx/paragon/icons'; import messages from '../discussions/posts/post-editor/messages'; import HTMLLoader from './HTMLLoader'; -function PostPreviewPanel({ - htmlNode, intl, isPost, editExisting, -}) { +const PostPreviewPanel = ({ + htmlNode, isPost, editExisting, +}) => { + const intl = useIntl(); const [showPreviewPane, setShowPreviewPane] = useState(false); return ( @@ -30,13 +31,15 @@ function PostPreviewPanel({ iconClassNames="icon-size" data-testid="hide-preview-button" /> - + {htmlNode && ( + + )}
)}
@@ -55,18 +58,18 @@ function PostPreviewPanel({
); -} +}; PostPreviewPanel.propTypes = { - intl: intlShape.isRequired, - htmlNode: PropTypes.node.isRequired, + htmlNode: PropTypes.node, isPost: PropTypes.bool, editExisting: PropTypes.bool, }; PostPreviewPanel.defaultProps = { + htmlNode: '', isPost: false, editExisting: false, }; -export default injectIntl(PostPreviewPanel); +export default React.memo(PostPreviewPanel); diff --git a/src/components/Search.jsx b/src/components/Search.jsx index 8f415960..5370ae97 100644 --- a/src/components/Search.jsx +++ b/src/components/Search.jsx @@ -1,9 +1,11 @@ -import React, { useContext, useEffect } from 'react'; +import React, { + useCallback, useContext, useEffect, useState, +} from 'react'; import camelCase from 'lodash/camelCase'; import { useDispatch, useSelector } from 'react-redux'; -import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { useIntl } from '@edx/frontend-platform/i18n'; import { Icon, SearchField } from '@edx/paragon'; import { Search as SearchIcon } from '@edx/paragon/icons'; @@ -13,7 +15,8 @@ import { setSearchQuery } from '../discussions/posts/data'; import postsMessages from '../discussions/posts/post-actions-bar/messages'; import { setFilter as setTopicFilter } from '../discussions/topics/data/slices'; -function Search({ intl }) { +const Search = () => { + const intl = useIntl(); const dispatch = useDispatch(); const { page } = useContext(DiscussionContext); const postSearch = useSelector(({ threads }) => threads.filters.search); @@ -21,8 +24,9 @@ function Search({ intl }) { const learnerSearch = useSelector(({ learners }) => learners.usernameSearch); const isPostSearch = ['posts', 'my-posts'].includes(page); const isTopicSearch = 'topics'.includes(page); - let searchValue = ''; + const [searchValue, setSearchValue] = useState(''); let currentValue = ''; + if (isPostSearch) { currentValue = postSearch; } else if (isTopicSearch) { @@ -31,20 +35,21 @@ function Search({ intl }) { currentValue = learnerSearch; } - const onClear = () => { + const onClear = useCallback(() => { dispatch(setSearchQuery('')); dispatch(setTopicFilter('')); dispatch(setUsernameSearch('')); - }; + }, []); - const onChange = (query) => { - searchValue = query; - }; + const onChange = useCallback((query) => { + setSearchValue(query); + }, []); - const onSubmit = (query) => { + const onSubmit = useCallback((query) => { if (query === '') { return; } + if (isPostSearch) { dispatch(setSearchQuery(query)); } else if (page === 'topics') { @@ -52,36 +57,36 @@ function Search({ intl }) { } else if (page === 'learners') { dispatch(setUsernameSearch(query)); } - }; + }, [page, searchValue]); + + const handleIconClick = useCallback((e) => { + e.preventDefault(); + onSubmit(searchValue); + }, [searchValue]); useEffect(() => onClear(), [page]); - return ( - <> - - - - - onSubmit(searchValue)} - data-testid="search-icon" - /> - - - - ); -} -Search.propTypes = { - intl: intlShape.isRequired, + return ( + + + + + + + + ); }; -export default injectIntl(Search); +export default React.memo(Search); diff --git a/src/components/SearchInfo.jsx b/src/components/SearchInfo.jsx index 3fd628b8..e92d05f8 100644 --- a/src/components/SearchInfo.jsx +++ b/src/components/SearchInfo.jsx @@ -1,32 +1,36 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { useIntl } from '@edx/frontend-platform/i18n'; import { Button, Icon } from '@edx/paragon'; import { Search } from '@edx/paragon/icons'; import { RequestStatus } from '../data/constants'; import messages from '../discussions/posts/post-actions-bar/messages'; -function SearchInfo({ - intl, +const SearchInfo = ({ count, text, loadingStatus, onClear, textSearchRewrite, -}) { +}) => { + const intl = useIntl(); + return (
@@ -35,10 +39,9 @@ function SearchInfo({
); -} +}; SearchInfo.propTypes = { - intl: intlShape.isRequired, count: PropTypes.number.isRequired, text: PropTypes.string.isRequired, loadingStatus: PropTypes.string.isRequired, @@ -51,4 +54,4 @@ SearchInfo.defaultProps = { textSearchRewrite: null, }; -export default injectIntl(SearchInfo); +export default React.memo(SearchInfo); diff --git a/src/components/Spinner.jsx b/src/components/Spinner.jsx new file mode 100644 index 00000000..a301021a --- /dev/null +++ b/src/components/Spinner.jsx @@ -0,0 +1,11 @@ +import React from 'react'; + +import { Spinner as ParagonSpinner } from '@edx/paragon'; + +const Spinner = () => ( +
+ +
+); + +export default React.memo(Spinner); diff --git a/src/components/TinyMCEEditor.jsx b/src/components/TinyMCEEditor.jsx index ff1c4499..a61199ca 100644 --- a/src/components/TinyMCEEditor.jsx +++ b/src/components/TinyMCEEditor.jsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useCallback, useState } from 'react'; import { Editor } from '@tinymce/tinymce-react'; import { useParams } from 'react-router'; @@ -42,30 +42,31 @@ import contentCss from '!!raw-loader!tinymce/skins/content/default/content.min.c import contentUiCss from '!!raw-loader!tinymce/skins/ui/oxide/content.min.css'; /* istanbul ignore next */ -const setup = (editor) => { - editor.ui.registry.addButton('openedx_code', { - icon: 'sourcecode', - onAction: () => { - editor.execCommand('CodeSample'); - }, - }); - editor.ui.registry.addButton('openedx_html', { - text: 'HTML', - onAction: () => { - editor.execCommand('mceCodeEditor'); - }, - }); -}; - -/* istanbul ignore next */ -export default function TinyMCEEditor(props) { +function TinyMCEEditor(props) { // note that skin and content_css is disabled to avoid the normal // loading process and is instead loaded as a string via content_style const { courseId, postId } = useParams(); const [showImageWarning, setShowImageWarning] = useState(false); const intl = useIntl(); - const uploadHandler = async (blobInfo, success, failure) => { + + /* istanbul ignore next */ + const setup = useCallback((editor) => { + editor.ui.registry.addButton('openedx_code', { + icon: 'sourcecode', + onAction: () => { + editor.execCommand('CodeSample'); + }, + }); + editor.ui.registry.addButton('openedx_html', { + text: 'HTML', + onAction: () => { + editor.execCommand('mceCodeEditor'); + }, + }); + }, []); + + const uploadHandler = useCallback(async (blobInfo, success, failure) => { try { const blob = blobInfo.blob(); const imageSize = blobInfo.blob().size / 1024; @@ -76,7 +77,7 @@ export default function TinyMCEEditor(props) { const filename = blobInfo.filename(); const { location } = await uploadFile(blob, filename, courseId, postId || 'root'); const img = new Image(); - img.onload = function () { + img.onload = () => { if (img.height > 999 || img.width > 999) { setShowImageWarning(true); } }; img.src = location; @@ -84,7 +85,11 @@ export default function TinyMCEEditor(props) { } catch (e) { failure(e.toString(), { remove: true }); } - }; + }, [courseId, postId]); + + const handleClose = useCallback(() => { + setShowImageWarning(false); + }, []); let contentStyle; // In the test environment this causes an error so set styles to empty since they aren't needed for testing. @@ -131,21 +136,22 @@ export default function TinyMCEEditor(props) { setShowImageWarning(false)} + onClose={handleClose} isBlocking footerNode={( - - )} + )} >

{intl.formatMessage(messages.imageWarningMessage)}

- ); } + +export default React.memo(TinyMCEEditor); diff --git a/src/components/TopicStats.jsx b/src/components/TopicStats.jsx index 416ea828..7f7bb725 100644 --- a/src/components/TopicStats.jsx +++ b/src/components/TopicStats.jsx @@ -4,7 +4,7 @@ import PropTypes from 'prop-types'; import { useSelector } from 'react-redux'; -import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { useIntl } from '@edx/frontend-platform/i18n'; import { Icon, OverlayTrigger, Tooltip } from '@edx/paragon'; import { HelpOutline, PostOutline, Report } from '@edx/paragon/icons'; @@ -14,15 +14,16 @@ import { } from '../discussions/data/selectors'; import messages from '../discussions/in-context-topics/messages'; -function TopicStats({ +const TopicStats = ({ threadCounts, activeFlags, inactiveFlags, - intl, -}) { +}) => { + const intl = useIntl(); const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges); const userIsGroupTa = useSelector(selectUserIsGroupTa); const canSeeReportedStats = (activeFlags || inactiveFlags) && (userHasModerationPrivileges || userIsGroupTa); + return (
); -} +}; TopicStats.propTypes = { threadCounts: PropTypes.shape({ @@ -96,7 +97,6 @@ TopicStats.propTypes = { }), activeFlags: PropTypes.number, inactiveFlags: PropTypes.number, - intl: intlShape.isRequired, }; TopicStats.defaultProps = { @@ -108,4 +108,4 @@ TopicStats.defaultProps = { inactiveFlags: null, }; -export default injectIntl(TopicStats); +export default React.memo(TopicStats); diff --git a/src/components/index.js b/src/components/index.js index 10908904..fe0ee488 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -1,4 +1,5 @@ export { default as PostActionsBar } from '../discussions/posts/post-actions-bar/PostActionsBar'; export { default as Search } from './Search'; +export { default as Spinner } from './Spinner'; export { default as TinyMCEEditor } from './TinyMCEEditor'; export { default as TopicStats } from './TopicStats'; diff --git a/src/data/hooks.js b/src/data/hooks.js index bc5fce84..cf836495 100644 --- a/src/data/hooks.js +++ b/src/data/hooks.js @@ -18,11 +18,13 @@ import { useDispatch } from 'react-redux'; export function useDispatchWithState() { const dispatch = useDispatch(); const [isDispatching, setDispatching] = useState(false); + const dispatchWithState = async (thunk) => { setDispatching(true); await dispatch(thunk); setDispatching(false); }; + return [ isDispatching, dispatchWithState, diff --git a/src/discussions/common/ActionsDropdown.jsx b/src/discussions/common/ActionsDropdown.jsx index 8e42cd97..ec9bd03b 100644 --- a/src/discussions/common/ActionsDropdown.jsx +++ b/src/discussions/common/ActionsDropdown.jsx @@ -1,9 +1,11 @@ -import React, { useCallback, useRef, useState } from 'react'; +import React, { + useCallback, useMemo, useRef, useState, +} from 'react'; import PropTypes from 'prop-types'; import { useSelector } from 'react-redux'; -import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { useIntl } from '@edx/frontend-platform/i18n'; import { logError } from '@edx/frontend-platform/logging'; import { Button, Dropdown, Icon, IconButton, ModalPopup, useToggle, @@ -13,22 +15,22 @@ import { MoreHoriz } from '@edx/paragon/icons'; import { ContentActions } from '../../data/constants'; import { selectBlackoutDate } from '../data/selectors'; import messages from '../messages'; -import { commentShape } from '../post-comments/comments/comment/proptypes'; -import { postShape } from '../posts/post/proptypes'; import { inBlackoutDateRange, useActions } from '../utils'; function ActionsDropdown({ - intl, - commentOrPost, - disabled, actionHandlers, - iconSize, + contentType, + disabled, dropDownIconSize, + iconSize, + id, }) { const buttonRef = useRef(); + const intl = useIntl(); const [isOpen, open, close] = useToggle(false); const [target, setTarget] = useState(null); - const actions = useActions(commentOrPost); + const blackoutDateRange = useSelector(selectBlackoutDate); + const actions = useActions(contentType, id); const handleActions = useCallback((action) => { const actionFunction = actionHandlers[action]; @@ -39,11 +41,12 @@ function ActionsDropdown({ } }, [actionHandlers]); - const blackoutDateRange = useSelector(selectBlackoutDate); // Find and remove edit action if in blackout date range. - if (inBlackoutDateRange(blackoutDateRange)) { - actions.splice(actions.findIndex(action => action.id === 'edit'), 1); - } + useMemo(() => { + if (inBlackoutDateRange(blackoutDateRange)) { + actions.splice(actions.findIndex(action => action.id === 'edit'), 1); + } + }, [actions, blackoutDateRange]); const onClickButton = useCallback(() => { setTarget(buttonRef.current); @@ -80,9 +83,7 @@ function ActionsDropdown({ > {actions.map(action => ( - {(action.action === ContentActions.DELETE) - && } - + {(action.action === ContentActions.DELETE) && } { + const intl = useIntl(); const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges); const userIsGroupTa = useSelector(selectUserIsGroupTa); const userIsGlobalStaff = useSelector(selectUserIsStaff); const { reasonCodesEnabled } = useSelector(selectModerationSettings); - const userIsContentAuthor = getAuthenticatedUser().username === content.author; - const canSeeReportedBanner = content?.abuseFlagged; + const userIsContentAuthor = getAuthenticatedUser().username === author; + const canSeeReportedBanner = abuseFlagged; const canSeeLastEditOrClosedAlert = (userHasModerationPrivileges || userIsGroupTa || userIsGlobalStaff || userIsContentAuthor ); - const editByLabelColor = AvatarOutlineAndLabelColors[content.editByLabel]; - const closedByLabelColor = AvatarOutlineAndLabelColors[content.closedByLabel]; + const editByLabelColor = AvatarOutlineAndLabelColors[editByLabel]; + const closedByLabelColor = AvatarOutlineAndLabelColors[closedByLabel]; return ( <> @@ -42,33 +47,52 @@ function AlertBanner({ )} {reasonCodesEnabled && canSeeLastEditOrClosedAlert && ( <> - {content.lastEdit?.reason && ( + {lastEdit?.reason && ( )} - {content.closed && ( - + {closed && ( + )} )} ); -} - -AlertBanner.propTypes = { - intl: intlShape.isRequired, - content: PropTypes.oneOfType([commentShape.isRequired, postShape.isRequired]).isRequired, }; -export default injectIntl(AlertBanner); +AlertBanner.propTypes = { + author: PropTypes.string.isRequired, + abuseFlagged: PropTypes.bool, + closed: PropTypes.bool, + closedBy: PropTypes.string, + closedByLabel: PropTypes.string, + closeReason: PropTypes.string, + editByLabel: PropTypes.string, + lastEdit: PropTypes.shape({ + editorUsername: PropTypes.string, + reason: PropTypes.string, + }), +}; + +AlertBanner.defaultProps = { + abuseFlagged: false, + closed: undefined, + closedBy: undefined, + closedByLabel: undefined, + closeReason: undefined, + editByLabel: undefined, + lastEdit: {}, +}; + +export default React.memo(AlertBanner); diff --git a/src/discussions/common/AlertBar.jsx b/src/discussions/common/AlertBar.jsx index b989e352..a5c51db6 100644 --- a/src/discussions/common/AlertBar.jsx +++ b/src/discussions/common/AlertBar.jsx @@ -1,24 +1,25 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { useIntl } from '@edx/frontend-platform/i18n'; import { Alert } from '@edx/paragon'; import messages from '../post-comments/messages'; import AuthorLabel from './AuthorLabel'; -function AlertBar({ - intl, +const AlertBar = ({ message, author, authorLabel, labelColor, reason, -}) { +}) => { + const intl = useIntl(); + return (
- {intl.formatMessage(message)} + {message} ); -} +}; AlertBar.propTypes = { - intl: intlShape.isRequired, message: PropTypes.string, author: PropTypes.string, authorLabel: PropTypes.string, @@ -57,4 +57,4 @@ AlertBar.defaultProps = { reason: '', }; -export default injectIntl(AlertBar); +export default React.memo(AlertBar); diff --git a/src/discussions/common/AuthorLabel.jsx b/src/discussions/common/AuthorLabel.jsx index 37b674cb..cd70f1dd 100644 --- a/src/discussions/common/AuthorLabel.jsx +++ b/src/discussions/common/AuthorLabel.jsx @@ -1,23 +1,22 @@ -import React, { useContext } from 'react'; +import React, { useContext, useMemo } from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; -import { Link, useLocation } from 'react-router-dom'; +import { generatePath } from 'react-router'; +import { Link } from 'react-router-dom'; import * as timeago from 'timeago.js'; -import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { useIntl } from '@edx/frontend-platform/i18n'; import { Icon, OverlayTrigger, Tooltip } from '@edx/paragon'; import { Institution, School } from '@edx/paragon/icons'; import { Routes } from '../../data/constants'; import { useShowLearnersTab } from '../data/hooks'; import messages from '../messages'; -import { discussionsPath } from '../utils'; import { DiscussionContext } from './context'; import timeLocale from './time-locale'; -function AuthorLabel({ - intl, +const AuthorLabel = ({ author, authorLabel, linkToProfile, @@ -26,17 +25,18 @@ function AuthorLabel({ postCreatedAt, authorToolTip, postOrComment, -}) { - const location = useLocation(); +}) => { + timeago.register('time-locale', timeLocale); + const intl = useIntl(); const { courseId } = useContext(DiscussionContext); let icon = null; let authorLabelMessage = null; - timeago.register('time-locale', timeLocale); if (authorLabel === 'Staff') { icon = Institution; authorLabelMessage = intl.formatMessage(messages.authorLabelStaff); } + if (authorLabel === 'Community TA') { icon = School; authorLabelMessage = intl.formatMessage(messages.authorLabelTA); @@ -49,7 +49,7 @@ function AuthorLabel({ const showUserNameAsLink = useShowLearnersTab() && linkToProfile && author && author !== intl.formatMessage(messages.anonymous); - const authorName = ( + const authorName = useMemo(() => ( {isRetiredUser ? '[Deactivated]' : author} - ); - const labelContents = ( + ), [author, authorLabelMessage, isRetiredUser]); + + const labelContents = useMemo(() => ( <> )} - ); + ), [author, authorLabelMessage, authorToolTip, icon, isRetiredUser, postCreatedAt, showTextPrimary, alert]); return showUserNameAsLink ? ( @@ -117,7 +118,7 @@ function AuthorLabel({ @@ -127,10 +128,9 @@ function AuthorLabel({
) :
{authorName}{labelContents}
; -} +}; AuthorLabel.propTypes = { - intl: intlShape.isRequired, author: PropTypes.string.isRequired, authorLabel: PropTypes.string, linkToProfile: PropTypes.bool, @@ -151,4 +151,4 @@ AuthorLabel.defaultProps = { postOrComment: false, }; -export default injectIntl(AuthorLabel); +export default React.memo(AuthorLabel); diff --git a/src/discussions/common/Confirmation.jsx b/src/discussions/common/Confirmation.jsx index 06f84b31..59106a1a 100644 --- a/src/discussions/common/Confirmation.jsx +++ b/src/discussions/common/Confirmation.jsx @@ -1,13 +1,12 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { useIntl } from '@edx/frontend-platform/i18n'; import { ActionRow, Button, ModalDialog } from '@edx/paragon'; import messages from '../messages'; function Confirmation({ - intl, isOpen, title, description, @@ -17,6 +16,8 @@ function Confirmation({ confirmButtonVariant, confirmButtonText, }) { + const intl = useIntl(); + return ( @@ -42,7 +43,6 @@ function Confirmation({ } Confirmation.propTypes = { - intl: intlShape.isRequired, isOpen: PropTypes.bool.isRequired, onClose: PropTypes.func.isRequired, comfirmAction: PropTypes.func.isRequired, @@ -59,4 +59,4 @@ Confirmation.defaultProps = { confirmButtonText: '', }; -export default injectIntl(Confirmation); +export default React.memo(Confirmation); diff --git a/src/discussions/common/EndorsedAlertBanner.jsx b/src/discussions/common/EndorsedAlertBanner.jsx index e2fcf125..e92279c1 100644 --- a/src/discussions/common/EndorsedAlertBanner.jsx +++ b/src/discussions/common/EndorsedAlertBanner.jsx @@ -1,30 +1,34 @@ -import React from 'react'; +import React, { useContext } from 'react'; import PropTypes from 'prop-types'; import * as timeago from 'timeago.js'; -import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { useIntl } from '@edx/frontend-platform/i18n'; import { Alert, Icon } from '@edx/paragon'; import { CheckCircle, Verified } from '@edx/paragon/icons'; import { ThreadType } from '../../data/constants'; -import { commentShape } from '../post-comments/comments/comment/proptypes'; import messages from '../post-comments/messages'; +import { PostCommentsContext } from '../post-comments/postCommentsContext'; import AuthorLabel from './AuthorLabel'; import timeLocale from './time-locale'; function EndorsedAlertBanner({ - intl, - content, - postType, + endorsed, + endorsedAt, + endorsedBy, + endorsedByLabel, }) { timeago.register('time-locale', timeLocale); + + const intl = useIntl(); + const { postType } = useContext(PostCommentsContext); const isQuestion = postType === ThreadType.QUESTION; const classes = isQuestion ? 'bg-success-500 text-white' : 'bg-dark-500 text-white'; const iconClass = isQuestion ? CheckCircle : Verified; return ( - content.endorsed && ( + endorsed && ( @@ -61,13 +65,16 @@ function EndorsedAlertBanner({ } EndorsedAlertBanner.propTypes = { - intl: intlShape.isRequired, - content: PropTypes.oneOfType([commentShape.isRequired]).isRequired, - postType: PropTypes.string, + endorsed: PropTypes.bool.isRequired, + endorsedAt: PropTypes.string, + endorsedBy: PropTypes.string, + endorsedByLabel: PropTypes.string, }; EndorsedAlertBanner.defaultProps = { - postType: null, + endorsedAt: null, + endorsedBy: null, + endorsedByLabel: null, }; -export default injectIntl(EndorsedAlertBanner); +export default React.memo(EndorsedAlertBanner); diff --git a/src/discussions/common/HoverCard.jsx b/src/discussions/common/HoverCard.jsx index cd8ae29f..46a72a33 100644 --- a/src/discussions/common/HoverCard.jsx +++ b/src/discussions/common/HoverCard.jsx @@ -3,7 +3,7 @@ import PropTypes from 'prop-types'; import classNames from 'classnames'; -import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { useIntl } from '@edx/frontend-platform/i18n'; import { Button, Icon, IconButton, OverlayTrigger, Tooltip, } from '@edx/paragon'; @@ -12,29 +12,32 @@ import { StarFilled, StarOutline, ThumbUpFilled, ThumbUpOutline, } from '../../components/icons'; import { useUserCanAddThreadInBlackoutDate } from '../data/hooks'; -import { commentShape } from '../post-comments/comments/comment/proptypes'; -import { postShape } from '../posts/post/proptypes'; +import { PostCommentsContext } from '../post-comments/postCommentsContext'; import ActionsDropdown from './ActionsDropdown'; import { DiscussionContext } from './context'; -function HoverCard({ - intl, - commentOrPost, +const HoverCard = ({ + id, + contentType, actionHandlers, handleResponseCommentButton, addResponseCommentButtonMessage, onLike, onFollow, - isClosedPost, + voted, + following, endorseIcons, -}) { +}) => { + const intl = useIntl(); const { enableInContextSidebar } = useContext(DiscussionContext); + const { isClosed } = useContext(PostCommentsContext); const userCanAddThreadInBlackoutDate = useUserCanAddThreadInBlackoutDate(); + return (
{userCanAddThreadInBlackoutDate && (
@@ -43,7 +46,7 @@ function HoverCard({ className={classNames('px-2.5 py-2 border-0 font-style text-gray-700 font-size-12', { 'w-100': enableInContextSidebar })} onClick={() => handleResponseCommentButton()} - disabled={isClosedPost} + disabled={isClosed} style={{ lineHeight: '20px' }} > {addResponseCommentButtonMessage} @@ -76,7 +79,7 @@ function HoverCard({ )}
- {commentOrPost.following !== undefined && ( + {following !== undefined && (
)}
- +
); -} +}; HoverCard.propTypes = { - intl: intlShape.isRequired, - commentOrPost: PropTypes.oneOfType([commentShape, postShape]).isRequired, + id: PropTypes.string.isRequired, + contentType: PropTypes.string.isRequired, actionHandlers: PropTypes.objectOf(PropTypes.func).isRequired, handleResponseCommentButton: PropTypes.func.isRequired, - onLike: PropTypes.func.isRequired, - onFollow: PropTypes.func, addResponseCommentButtonMessage: PropTypes.string.isRequired, - isClosedPost: PropTypes.bool.isRequired, + onLike: PropTypes.func.isRequired, + voted: PropTypes.bool.isRequired, endorseIcons: PropTypes.objectOf(PropTypes.any), + onFollow: PropTypes.func, + following: PropTypes.bool, }; HoverCard.defaultProps = { onFollow: () => null, endorseIcons: null, + following: undefined, }; -export default injectIntl(HoverCard); +export default React.memo(HoverCard); diff --git a/src/discussions/data/constants.js b/src/discussions/data/constants.js new file mode 100644 index 00000000..d8f434f3 --- /dev/null +++ b/src/discussions/data/constants.js @@ -0,0 +1,12 @@ +import { selectCommentOrResponseById } from '../post-comments/data/selectors'; +import { selectThread } from '../posts/data/selectors'; + +export const ContentSelectors = { + POST: selectThread, + COMMENT: selectCommentOrResponseById, +}; + +export const ContentTypes = { + POST: 'POST', + COMMENT: 'COMMENT', +}; diff --git a/src/discussions/data/hooks.js b/src/discussions/data/hooks.js index 3d96de9d..4864c168 100644 --- a/src/discussions/data/hooks.js +++ b/src/discussions/data/hooks.js @@ -1,15 +1,13 @@ -/* eslint-disable import/prefer-default-export */ import { - useContext, - useEffect, - useRef, - useState, + useCallback, + useContext, useEffect, useMemo, useRef, useState, } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { useHistory, useLocation, useRouteMatch } from 'react-router'; import { getAuthenticatedUser } from '@edx/frontend-platform/auth'; +import { useIntl } from '@edx/frontend-platform/i18n'; import { AppContext } from '@edx/frontend-platform/react'; import { breakpoints, useWindowSize } from '@edx/paragon'; @@ -42,16 +40,14 @@ import { fetchCourseConfig } from './thunks'; export function useTotalTopicThreadCount() { const topics = useSelector(selectTopics); - - if (!topics) { - return 0; - } - - return Object.keys(topics) - .reduce((total, topicId) => { + const count = useMemo(() => ( + Object.keys(topics)?.reduce((total, topicId) => { const topic = topics[topicId]; return total + topic.threadCounts.discussion + topic.threadCounts.question; - }, 0); + }, 0)), + []); + + return count; } export const useSidebarVisible = () => { @@ -87,13 +83,14 @@ export function useCourseDiscussionData(courseId) { export function useRedirectToThread(courseId, enableInContextSidebar) { const dispatch = useDispatch(); - const redirectToThread = useSelector( - (state) => state.threads.redirectToThread, - ); const history = useHistory(); const location = useLocation(); - return useEffect(() => { + const redirectToThread = useSelector( + (state) => state.threads.redirectToThread, + ); + + useEffect(() => { // After posting a new thread we'd like to redirect users to it, the topic and post id are temporarily // stored in redirectToThread if (redirectToThread) { @@ -153,17 +150,20 @@ export function useContainerSize(refContainer) { return height; } -export const useAlertBannerVisible = (content) => { +export const useAlertBannerVisible = ( + { + author, abuseFlagged, lastEdit, closed, + } = {}, +) => { const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges); const userIsGroupTa = useSelector(selectUserIsGroupTa); const { reasonCodesEnabled } = useSelector(selectModerationSettings); - const userIsContentAuthor = getAuthenticatedUser().username === content.author; + const userIsContentAuthor = getAuthenticatedUser().username === author; const canSeeLastEditOrClosedAlert = (userHasModerationPrivileges || userIsContentAuthor || userIsGroupTa); - const canSeeReportedBanner = content.abuseFlagged; + const canSeeReportedBanner = abuseFlagged; return ( - (reasonCodesEnabled && canSeeLastEditOrClosedAlert && (content.lastEdit?.reason || content.closed)) - || (content.abuseFlagged && canSeeReportedBanner) + (reasonCodesEnabled && canSeeLastEditOrClosedAlert && (lastEdit?.reason || closed)) || (canSeeReportedBanner) ); }; @@ -193,38 +193,50 @@ export const useCurrentDiscussionTopic = () => { export const useUserCanAddThreadInBlackoutDate = () => { const blackoutDateRange = useSelector(selectBlackoutDate); const isUserAdmin = useSelector(selectUserIsStaff); - const userHasModerationPrivilages = useSelector(selectUserHasModerationPrivileges); + const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges); const isUserGroupTA = useSelector(selectUserIsGroupTa); const isCourseAdmin = useSelector(selectIsCourseAdmin); const isCourseStaff = useSelector(selectIsCourseStaff); - const isInBlackoutDateRange = inBlackoutDateRange(blackoutDateRange); + const isPrivileged = isUserAdmin || userHasModerationPrivileges || isUserGroupTA || isCourseAdmin || isCourseStaff; + const isInBlackoutDateRange = useMemo(() => inBlackoutDateRange(blackoutDateRange), [blackoutDateRange]); - return (!(isInBlackoutDateRange) - || (isUserAdmin || userHasModerationPrivilages || isUserGroupTA || isCourseAdmin || isCourseStaff)); + return (!(isInBlackoutDateRange) || (isPrivileged)); }; function camelToConstant(string) { return string.replace(/[A-Z]/g, (match) => `_${match}`).toUpperCase(); } -export const useTourConfiguration = (intl) => { +export const useTourConfiguration = () => { + const intl = useIntl(); const dispatch = useDispatch(); - const { enableInContextSidebar } = useContext(DiscussionContext); const tours = useSelector(selectTours); - return tours.map((tour) => ( - { - tourId: tour.tourName, - advanceButtonText: intl.formatMessage(messages.advanceButtonText), - dismissButtonText: intl.formatMessage(messages.dismissButtonText), - endButtonText: intl.formatMessage(messages.endButtonText), - enabled: tour && Boolean(tour.enabled && tour.showTour && !enableInContextSidebar), - onDismiss: () => dispatch(updateTourShowStatus(tour.id)), - onEnd: () => dispatch(updateTourShowStatus(tour.id)), - checkpoints: tourCheckpoints(intl)[camelToConstant(tour.tourName)], - } - )); + const handleOnDismiss = useCallback((id) => ( + dispatch(updateTourShowStatus(id)) + ), []); + + const handleOnEnd = useCallback((id) => ( + dispatch(updateTourShowStatus(id)) + ), []); + + const toursConfig = useMemo(() => ( + tours?.map((tour) => ( + { + tourId: tour.tourName, + advanceButtonText: intl.formatMessage(messages.advanceButtonText), + dismissButtonText: intl.formatMessage(messages.dismissButtonText), + endButtonText: intl.formatMessage(messages.endButtonText), + enabled: tour && Boolean(tour.enabled && tour.showTour && !enableInContextSidebar), + onDismiss: () => handleOnDismiss(tour.id), + onEnd: () => handleOnEnd(tour.id), + checkpoints: tourCheckpoints(intl)[camelToConstant(tour.tourName)], + } + )) + ), [tours, enableInContextSidebar]); + + return toursConfig; }; export const useDebounce = (value, delay) => { diff --git a/src/discussions/discussions-home/BlackoutInformationBanner.jsx b/src/discussions/discussions-home/BlackoutInformationBanner.jsx index bd8aea78..301a460b 100644 --- a/src/discussions/discussions-home/BlackoutInformationBanner.jsx +++ b/src/discussions/discussions-home/BlackoutInformationBanner.jsx @@ -1,36 +1,39 @@ -import React, { useState } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import { useSelector } from 'react-redux'; -import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { useIntl } from '@edx/frontend-platform/i18n'; import { PageBanner } from '@edx/paragon'; import { selectBlackoutDate } from '../data/selectors'; import messages from '../messages'; import { inBlackoutDateRange } from '../utils'; -function BlackoutInformationBanner({ - intl, -}) { - const isDiscussionsBlackout = inBlackoutDateRange(useSelector(selectBlackoutDate)); +const BlackoutInformationBanner = () => { + const intl = useIntl(); + const blackoutDate = useSelector(selectBlackoutDate); const [showBanner, setShowBanner] = useState(true); + const isDiscussionsBlackout = useMemo(() => ( + inBlackoutDateRange(blackoutDate) + ), [blackoutDate]); + + const handleDismiss = useCallback(() => { + setShowBanner(false); + }, []); + return ( setShowBanner(false)} + onDismiss={handleDismiss} >
{intl.formatMessage(messages.blackoutDiscussionInformation)}
); -} - -BlackoutInformationBanner.propTypes = { - intl: intlShape.isRequired, }; -export default injectIntl(BlackoutInformationBanner); +export default BlackoutInformationBanner; diff --git a/src/discussions/discussions-home/DiscussionContent.jsx b/src/discussions/discussions-home/DiscussionContent.jsx index 3d2764d2..ca3e668c 100644 --- a/src/discussions/discussions-home/DiscussionContent.jsx +++ b/src/discussions/discussions-home/DiscussionContent.jsx @@ -1,37 +1,39 @@ -import React from 'react'; +import React, { lazy, Suspense } from 'react'; import { useSelector } from 'react-redux'; import { Route, Switch } from 'react-router'; -import { injectIntl } from '@edx/frontend-platform/i18n'; - +import Spinner from '../../components/Spinner'; import { Routes } from '../../data/constants'; -import { PostCommentsView } from '../post-comments'; -import { PostEditor } from '../posts'; -function DiscussionContent() { +const PostEditor = lazy(() => import('../posts/post-editor/PostEditor')); +const PostCommentsView = lazy(() => import('../post-comments/PostCommentsView')); + +const DiscussionContent = () => { const postEditorVisible = useSelector((state) => state.threads.postEditorVisible); return (
- {postEditorVisible ? ( - - - - ) : ( - - - + )}> + {postEditorVisible ? ( + + - - - - - )} + ) : ( + + + + + + + + + )} +
); -} +}; -export default injectIntl(DiscussionContent); +export default DiscussionContent; diff --git a/src/discussions/discussions-home/DiscussionSidebar.jsx b/src/discussions/discussions-home/DiscussionSidebar.jsx index 59332d07..bece2172 100644 --- a/src/discussions/discussions-home/DiscussionSidebar.jsx +++ b/src/discussions/discussions-home/DiscussionSidebar.jsx @@ -1,4 +1,6 @@ -import React, { useContext, useEffect, useRef } from 'react'; +import React, { + lazy, Suspense, useContext, useEffect, useRef, +} from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; @@ -9,18 +11,22 @@ import { import { useWindowSize } from '@edx/paragon'; +import Spinner from '../../components/Spinner'; import { RequestStatus, Routes } from '../../data/constants'; import { DiscussionContext } from '../common/context'; import { useContainerSize, useIsOnDesktop, useIsOnXLDesktop, useShowLearnersTab, } from '../data/hooks'; import { selectconfigLoadingStatus, selectEnableInContext } from '../data/selectors'; -import { TopicPostsView, TopicsView as InContextTopicsView } from '../in-context-topics'; -import { LearnerPostsView, LearnersView } from '../learners'; -import { PostsView } from '../posts'; -import { TopicsView as LegacyTopicsView } from '../topics'; -export default function DiscussionSidebar({ displaySidebar, postActionBarRef }) { +const TopicPostsView = lazy(() => import('../in-context-topics/TopicPostsView')); +const InContextTopicsView = lazy(() => import('../in-context-topics/TopicsView')); +const LearnerPostsView = lazy(() => import('../learners/LearnerPostsView')); +const LearnersView = lazy(() => import('../learners/LearnersView')); +const PostsView = lazy(() => import('../posts/PostsView')); +const LegacyTopicsView = lazy(() => import('../topics/TopicsView')); + +const DiscussionSidebar = ({ displaySidebar, postActionBarRef }) => { const location = useLocation(); const isOnDesktop = useIsOnDesktop(); const isOnXLDesktop = useIsOnXLDesktop(); @@ -55,15 +61,16 @@ export default function DiscussionSidebar({ displaySidebar, postActionBarRef }) })} data-testid="sidebar" > - - {enableInContext && !enableInContextSidebar && ( + )}> + + {enableInContext && !enableInContextSidebar && ( - )} - {enableInContext && !enableInContextSidebar && ( + )} + {enableInContext && !enableInContextSidebar && ( - )} - - - {redirectToLearnersTab && ( + )} + + + {redirectToLearnersTab && ( - )} - {redirectToLearnersTab && ( + )} + {redirectToLearnersTab && ( - )} - {configStatus === RequestStatus.SUCCESSFUL && ( + )} + {configStatus === RequestStatus.SUCCESSFUL && ( - )} - + )} + +
); -} - -DiscussionSidebar.defaultProps = { - displaySidebar: false, - postActionBarRef: null, }; DiscussionSidebar.propTypes = { @@ -112,3 +115,10 @@ DiscussionSidebar.propTypes = { PropTypes.shape({ current: PropTypes.instanceOf(Element) }), ]), }; + +DiscussionSidebar.defaultProps = { + displaySidebar: false, + postActionBarRef: null, +}; + +export default React.memo(DiscussionSidebar); diff --git a/src/discussions/discussions-home/DiscussionsHome.jsx b/src/discussions/discussions-home/DiscussionsHome.jsx index b0ed0668..8950a9e7 100644 --- a/src/discussions/discussions-home/DiscussionsHome.jsx +++ b/src/discussions/discussions-home/DiscussionsHome.jsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef } from 'react'; +import React, { lazy, Suspense, useRef } from 'react'; import classNames from 'classnames'; import { useSelector } from 'react-redux'; @@ -6,12 +6,10 @@ import { Route, Switch, useLocation, useRouteMatch, } from 'react-router'; -import Footer from '@edx/frontend-component-footer'; import { LearningHeader as Header } from '@edx/frontend-component-header'; import { getConfig } from '@edx/frontend-platform'; -import { PostActionsBar } from '../../components'; -import { CourseTabsNavigation } from '../../components/NavigationBar'; +import { Spinner } from '../../components'; import { selectCourseTabs } from '../../components/NavigationBar/data/selectors'; import { ALL_ROUTES, DiscussionProvider, Routes } from '../../data/constants'; import { DiscussionContext } from '../common/context'; @@ -22,17 +20,21 @@ import { selectDiscussionProvider, selectEnableInContext } from '../data/selecto import { EmptyLearners, EmptyPosts, EmptyTopics } from '../empty-posts'; import { EmptyTopic as InContextEmptyTopics } from '../in-context-topics/components'; import messages from '../messages'; -import { LegacyBreadcrumbMenu, NavigationBar } from '../navigation'; import { selectPostEditorVisible } from '../posts/data/selectors'; -import DiscussionsProductTour from '../tours/DiscussionsProductTour'; -import { postMessageToParent } from '../utils'; -import BlackoutInformationBanner from './BlackoutInformationBanner'; -import DiscussionContent from './DiscussionContent'; -import DiscussionSidebar from './DiscussionSidebar'; import useFeedbackWrapper from './FeedbackWrapper'; -import InformationBanner from './InformationBanner'; -export default function DiscussionsHome() { +const Footer = lazy(() => import('@edx/frontend-component-footer')); +const PostActionsBar = lazy(() => import('../posts/post-actions-bar/PostActionsBar')); +const CourseTabsNavigation = lazy(() => import('../../components/NavigationBar/CourseTabsNavigation')); +const LegacyBreadcrumbMenu = lazy(() => import('../navigation/breadcrumb-menu/LegacyBreadcrumbMenu')); +const NavigationBar = lazy(() => import('../navigation/navigation-bar/NavigationBar')); +const DiscussionsProductTour = lazy(() => import('../tours/DiscussionsProductTour')); +const BlackoutInformationBanner = lazy(() => import('./BlackoutInformationBanner')); +const DiscussionContent = lazy(() => import('./DiscussionContent')); +const DiscussionSidebar = lazy(() => import('./DiscussionSidebar')); +const InformationBanner = lazy(() => import('./InformationBanner')); + +const DiscussionsHome = () => { const location = useLocation(); const postActionBarRef = useRef(null); const postEditorVisible = useSelector(selectPostEditorVisible); @@ -40,7 +42,6 @@ export default function DiscussionsHome() { const enableInContext = useSelector(selectEnableInContext); const { courseNumber, courseTitle, org } = useSelector(selectCourseTabs); const { params: { page } } = useRouteMatch(`${Routes.COMMENTS.PAGE}?`); - const { params: { path } } = useRouteMatch(`${Routes.DISCUSSIONS.PATH}/:path*`); const { params } = useRouteMatch(ALL_ROUTES); const isRedirectToLearners = useShowLearnersTab(); const isOnDesktop = useIsOnDesktop(); @@ -60,54 +61,60 @@ export default function DiscussionsHome() { const displayContentArea = (postId || postEditorVisible || (learnerUsername && postId)); if (displayContentArea) { displaySidebar = isOnDesktop; } - useEffect(() => { - if (path && path !== 'undefined') { - postMessageToParent('discussions.navigate', { path }); - } - }, [path]); - return ( - - {!enableInContextSidebar &&
} -
- {!enableInContextSidebar && } -
-
- {!enableInContextSidebar && } - -
- {isFeedbackBannerVisible && } - -
- {provider === DiscussionProvider.LEGACY && ( - + )}> + + {!enableInContextSidebar && ( +
)} - -
- - {displayContentArea && } - {!displayContentArea && ( +
+ {!enableInContextSidebar && } +
+
+ {!enableInContextSidebar && ( + + )} + +
+ {isFeedbackBannerVisible && } + +
+ {provider === DiscussionProvider.LEGACY && ( + )}> + + + )} +
+ )}> + + + {displayContentArea && ( + )}> + + + )} + {!displayContentArea && ( {isRedirectToLearners && } + )} +
+ {!enableInContextSidebar && ( + )} -
- {!enableInContextSidebar && } -
- {!enableInContextSidebar &&
); -} +}; EmptyPage.propTypes = { title: propTypes.string.isRequired, @@ -50,4 +50,4 @@ EmptyPage.defaultProps = { actionText: null, }; -export default EmptyPage; +export default React.memo(EmptyPage); diff --git a/src/discussions/empty-posts/EmptyPosts.jsx b/src/discussions/empty-posts/EmptyPosts.jsx index 4e62215f..26be9bce 100644 --- a/src/discussions/empty-posts/EmptyPosts.jsx +++ b/src/discussions/empty-posts/EmptyPosts.jsx @@ -1,9 +1,9 @@ -import React from 'react'; +import React, { useCallback } from 'react'; import propTypes from 'prop-types'; import { useDispatch, useSelector } from 'react-redux'; -import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { useIntl } from '@edx/frontend-platform/i18n'; import { useIsOnDesktop } from '../data/hooks'; import { selectAreThreadsFiltered, selectPostThreadCount } from '../data/selectors'; @@ -11,16 +11,16 @@ import messages from '../messages'; import { messages as postMessages, showPostEditor } from '../posts'; import EmptyPage from './EmptyPage'; -function EmptyPosts({ intl, subTitleMessage }) { +const EmptyPosts = ({ subTitleMessage }) => { + const intl = useIntl(); const dispatch = useDispatch(); - + const isOnDesktop = useIsOnDesktop(); const isFiltered = useSelector(selectAreThreadsFiltered); const totalThreads = useSelector(selectPostThreadCount); - const isOnDesktop = useIsOnDesktop(); - function addPost() { - return dispatch(showPostEditor()); - } + const addPost = useCallback(() => ( + dispatch(showPostEditor()) + ), []); let title = messages.noPostSelected; let subTitle = null; @@ -49,7 +49,7 @@ function EmptyPosts({ intl, subTitleMessage }) { fullWidth={fullWidth} /> ); -} +}; EmptyPosts.propTypes = { subTitleMessage: propTypes.shape({ @@ -57,7 +57,6 @@ EmptyPosts.propTypes = { defaultMessage: propTypes.string, description: propTypes.string, }).isRequired, - intl: intlShape.isRequired, }; -export default injectIntl(EmptyPosts); +export default React.memo(EmptyPosts); diff --git a/src/discussions/empty-posts/EmptyTopics.jsx b/src/discussions/empty-posts/EmptyTopics.jsx index 5a7aa0bb..b728242f 100644 --- a/src/discussions/empty-posts/EmptyTopics.jsx +++ b/src/discussions/empty-posts/EmptyTopics.jsx @@ -1,9 +1,9 @@ -import React from 'react'; +import React, { useCallback } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { useRouteMatch } from 'react-router'; -import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { useIntl } from '@edx/frontend-platform/i18n'; import { ALL_ROUTES } from '../../data/constants'; import { useIsOnDesktop, useTotalTopicThreadCount } from '../data/hooks'; @@ -12,18 +12,17 @@ import messages from '../messages'; import { messages as postMessages, showPostEditor } from '../posts'; import EmptyPage from './EmptyPage'; -function EmptyTopics({ intl }) { +const EmptyTopics = () => { + const intl = useIntl(); const match = useRouteMatch(ALL_ROUTES); const dispatch = useDispatch(); - + const isOnDesktop = useIsOnDesktop(); const hasGlobalThreads = useTotalTopicThreadCount() > 0; const topicThreadCount = useSelector(selectTopicThreadCount(match.params.topicId)); - function addPost() { - return dispatch(showPostEditor()); - } - - const isOnDesktop = useIsOnDesktop(); + const addPost = useCallback(() => ( + dispatch(showPostEditor()) + ), []); let title = messages.emptyTitle; let fullWidth = false; @@ -62,10 +61,6 @@ function EmptyTopics({ intl }) { fullWidth={fullWidth} /> ); -} - -EmptyTopics.propTypes = { - intl: intlShape.isRequired, }; -export default injectIntl(EmptyTopics); +export default EmptyTopics; diff --git a/src/discussions/in-context-topics/TopicPostsView.jsx b/src/discussions/in-context-topics/TopicPostsView.jsx index 494b68a3..9fc050a6 100644 --- a/src/discussions/in-context-topics/TopicPostsView.jsx +++ b/src/discussions/in-context-topics/TopicPostsView.jsx @@ -1,15 +1,17 @@ -import React, { useContext, useEffect } from 'react'; +import React, { + useCallback, useContext, useEffect, useMemo, +} from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { useLocation } from 'react-router-dom'; -import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { useIntl } from '@edx/frontend-platform/i18n'; import { Spinner } from '@edx/paragon'; import { RequestStatus, Routes } from '../../data/constants'; import { DiscussionContext } from '../common/context'; import { selectDiscussionProvider } from '../data/selectors'; -import { selectTopicThreads } from '../posts/data/selectors'; +import { selectTopicThreadsIds } from '../posts/data/selectors'; import PostsList from '../posts/PostsList'; import { discussionsPath, handleKeyDown } from '../utils'; import { @@ -21,19 +23,34 @@ import { BackButton, NoResults } from './components'; import messages from './messages'; import { Topic } from './topic'; -function TopicPostsView({ intl }) { +const TopicPostsView = () => { + const intl = useIntl(); const location = useLocation(); const dispatch = useDispatch(); const { courseId, topicId, category } = useContext(DiscussionContext); const provider = useSelector(selectDiscussionProvider); const topicsStatus = useSelector(selectLoadingStatus); - const topicsInProgress = topicsStatus === RequestStatus.IN_PROGRESS; - const posts = useSelector(selectTopicThreads([topicId])); + const postsIds = useSelector(selectTopicThreadsIds([topicId])); const selectedSubsectionUnits = useSelector(selectSubsectionUnits(category)); const selectedSubsection = useSelector(selectSubsection(category)); - const selectedUnit = useSelector(selectUnits)?.find(unit => unit.id === topicId); - const selectedNonCoursewareTopic = useSelector(selectNonCoursewareTopics)?.find(topic => topic.id === topicId); + const units = useSelector(selectUnits); + const nonCoursewareTopics = useSelector(selectNonCoursewareTopics); const selectedArchivedTopic = useSelector(selectArchivedTopic(topicId)); + const topicsInProgress = topicsStatus === RequestStatus.IN_PROGRESS; + + const selectedUnit = useMemo(() => ( + units?.find(unit => unit.id === topicId) + ), [units, topicId]); + + const selectedNonCoursewareTopic = useMemo(() => ( + nonCoursewareTopics?.find(topic => topic.id === topicId) + ), [nonCoursewareTopics, topicId]); + + const backButtonPath = useCallback(() => { + const path = selectedUnit ? Routes.TOPICS.CATEGORY : Routes.TOPICS.ALL; + const params = selectedUnit ? { courseId, category: selectedUnit?.parentId } : { courseId }; + return discussionsPath(path, params)(location); + }, [selectedUnit]); useEffect(() => { if (provider && topicsStatus === RequestStatus.IDLE) { @@ -41,12 +58,6 @@ function TopicPostsView({ intl }) { } }, [provider]); - const backButtonPath = () => { - const path = selectedUnit ? Routes.TOPICS.CATEGORY : Routes.TOPICS.ALL; - const params = selectedUnit ? { courseId, category: selectedUnit?.parentId } : { courseId }; - return discussionsPath(path, params)(location); - }; - return (
{topicId ? ( @@ -67,8 +78,8 @@ function TopicPostsView({ intl }) {
handleKeyDown(e)}> {topicId ? ( ) : ( @@ -90,10 +101,6 @@ function TopicPostsView({ intl }) {
); -} - -TopicPostsView.propTypes = { - intl: intlShape.isRequired, }; -export default injectIntl(TopicPostsView); +export default React.memo(TopicPostsView); diff --git a/src/discussions/in-context-topics/TopicsView.jsx b/src/discussions/in-context-topics/TopicsView.jsx index b62dc037..0c84d088 100644 --- a/src/discussions/in-context-topics/TopicsView.jsx +++ b/src/discussions/in-context-topics/TopicsView.jsx @@ -1,4 +1,6 @@ -import React, { useContext, useEffect } from 'react'; +import React, { + useCallback, useContext, useEffect, useMemo, +} from 'react'; import classNames from 'classnames'; import isEmpty from 'lodash/isEmpty'; @@ -21,30 +23,38 @@ import { setFilter } from './data/slices'; import { fetchCourseTopicsV3 } from './data/thunks'; import { ArchivedBaseGroup, SectionBaseGroup, Topic } from './topic'; -function TopicsList() { +const TopicsList = () => { const loadingStatus = useSelector(selectLoadingStatus); const coursewareTopics = useSelector(selectCoursewareTopics); const nonCoursewareTopics = useSelector(selectNonCoursewareTopics); const archivedTopics = useSelector(selectArchivedTopics); + const renderNonCoursewareTopics = useMemo(() => ( + nonCoursewareTopics?.map((topic, index) => ( + + )) + ), [nonCoursewareTopics]); + + const renderCoursewareTopics = useMemo(() => ( + coursewareTopics?.map((topic, index) => ( + + )) + ), [coursewareTopics]); + return ( <> - {nonCoursewareTopics?.map((topic, index) => ( - - ))} - {coursewareTopics?.map((topic, index) => ( - - ))} + {renderNonCoursewareTopics} + {renderCoursewareTopics} {!isEmpty(archivedTopics) && ( ); -} +}; -function TopicsView() { +const TopicsView = () => { const dispatch = useDispatch(); const { courseId } = useContext(DiscussionContext); const provider = useSelector(selectDiscussionProvider); @@ -83,6 +93,10 @@ function TopicsView() { } }, [isPostsFiltered]); + const handleOnClear = useCallback(() => { + dispatch(setFilter('')); + }, []); + return (
{topicFilter && ( @@ -91,7 +105,7 @@ function TopicsView() { text={topicFilter} count={filteredTopics.length} loadingStatus={loadingStatus} - onClear={() => dispatch(setFilter(''))} + onClear={handleOnClear} /> {filteredTopics.length === 0 && loadingStatus === RequestStatus.SUCCESSFUL && } @@ -116,6 +130,6 @@ function TopicsView() {
); -} +}; export default TopicsView; diff --git a/src/discussions/in-context-topics/components/EmptyTopics.jsx b/src/discussions/in-context-topics/components/EmptyTopics.jsx index 9d667839..ef23c80c 100644 --- a/src/discussions/in-context-topics/components/EmptyTopics.jsx +++ b/src/discussions/in-context-topics/components/EmptyTopics.jsx @@ -1,9 +1,9 @@ -import React, { useContext } from 'react'; +import React, { useCallback, useContext } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { useRouteMatch } from 'react-router'; -import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { useIntl } from '@edx/frontend-platform/i18n'; import { ALL_ROUTES } from '../../../data/constants'; import { DiscussionContext } from '../../common/context'; @@ -14,20 +14,20 @@ import messages from '../../messages'; import { messages as postMessages, showPostEditor } from '../../posts'; import { selectCourseWareThreadsCount, selectTotalTopicsThreadsCount } from '../data/selectors'; -function EmptyTopics({ intl }) { +const EmptyTopics = () => { + const intl = useIntl(); const match = useRouteMatch(ALL_ROUTES); const dispatch = useDispatch(); + const isOnDesktop = useIsOnDesktop(); const { enableInContextSidebar } = useContext(DiscussionContext); const courseWareThreadsCount = useSelector(selectCourseWareThreadsCount(match.params.category)); const topicThreadsCount = useSelector(selectPostThreadCount); // hasGlobalThreads is used to determine if there are any post available in courseware and non-courseware topics const hasGlobalThreads = useSelector(selectTotalTopicsThreadsCount) > 0; - function addPost() { - return dispatch(showPostEditor()); - } - - const isOnDesktop = useIsOnDesktop(); + const addPost = useCallback(() => ( + dispatch(showPostEditor()) + ), []); let title = messages.emptyTitle; let fullWidth = false; @@ -74,10 +74,6 @@ function EmptyTopics({ intl }) { fullWidth={fullWidth} /> ); -} - -EmptyTopics.propTypes = { - intl: intlShape.isRequired, }; -export default injectIntl(EmptyTopics); +export default EmptyTopics; diff --git a/src/discussions/in-context-topics/components/NoResults.jsx b/src/discussions/in-context-topics/components/NoResults.jsx index f213a2f9..a2086a96 100644 --- a/src/discussions/in-context-topics/components/NoResults.jsx +++ b/src/discussions/in-context-topics/components/NoResults.jsx @@ -1,11 +1,14 @@ +import React from 'react'; + import { useSelector } from 'react-redux'; -import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { useIntl } from '@edx/frontend-platform/i18n'; import { selectTopics } from '../data/selectors'; import messages from '../messages'; -function NoResults({ intl }) { +const NoResults = () => { + const intl = useIntl(); const topics = useSelector(selectTopics); const title = messages.nothingHere; @@ -20,10 +23,6 @@ function NoResults({ intl }) { { helpMessage && {intl.formatMessage(helpMessage)}} ); -} - -NoResults.propTypes = { - intl: intlShape.isRequired, }; -export default injectIntl(NoResults); +export default NoResults; diff --git a/src/discussions/in-context-topics/topic-search/TopicSearchBar.jsx b/src/discussions/in-context-topics/topic-search/TopicSearchBar.jsx index 6766bb40..2f4ff84b 100644 --- a/src/discussions/in-context-topics/topic-search/TopicSearchBar.jsx +++ b/src/discussions/in-context-topics/topic-search/TopicSearchBar.jsx @@ -1,8 +1,8 @@ -import React, { useContext, useEffect } from 'react'; +import React, { useCallback, useContext, useEffect } from 'react'; import { useDispatch, useSelector } from 'react-redux'; -import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { useIntl } from '@edx/frontend-platform/i18n'; import { Icon, SearchField } from '@edx/paragon'; import { Search as SearchIcon } from '@edx/paragon/icons'; @@ -10,56 +10,51 @@ import { DiscussionContext } from '../../common/context'; import postsMessages from '../../posts/post-actions-bar/messages'; import { setFilter as setTopicFilter } from '../data/slices'; -function TopicSearchBar({ intl }) { +const TopicSearchBar = () => { + const intl = useIntl(); const dispatch = useDispatch(); const { page } = useContext(DiscussionContext); const topicSearch = useSelector(({ inContextTopics }) => inContextTopics.filter); let searchValue = ''; - const onClear = () => { + const onClear = useCallback(() => { dispatch(setTopicFilter('')); - }; + }, []); - const onChange = (query) => { + const onChange = useCallback((query) => { searchValue = query; - }; + }, []); - const onSubmit = (query) => { + const onSubmit = useCallback((query) => { if (query === '') { return; } dispatch(setTopicFilter(query)); - }; + }, []); useEffect(() => onClear(), [page]); return ( - <> - - - + + + + onSubmit(searchValue)} + data-testid="search-icon" /> - - onSubmit(searchValue)} - data-testid="search-icon" - /> - - - + + ); -} - -TopicSearchBar.propTypes = { - intl: intlShape.isRequired, }; -export default injectIntl(TopicSearchBar); +export default TopicSearchBar; diff --git a/src/discussions/in-context-topics/topic/ArchivedBaseGroup.jsx b/src/discussions/in-context-topics/topic/ArchivedBaseGroup.jsx index eef9fcd0..856f7361 100644 --- a/src/discussions/in-context-topics/topic/ArchivedBaseGroup.jsx +++ b/src/discussions/in-context-topics/topic/ArchivedBaseGroup.jsx @@ -1,16 +1,27 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import PropTypes from 'prop-types'; -import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { useIntl } from '@edx/frontend-platform/i18n'; import messages from '../messages'; import Topic, { topicShape } from './Topic'; -function ArchivedBaseGroup({ +const ArchivedBaseGroup = ({ archivedTopics, showDivider, - intl, -}) { +}) => { + const intl = useIntl(); + + const renderArchivedTopics = useMemo(() => ( + archivedTopics?.map((topic, index) => ( + + )) + ), [archivedTopics]); + return ( <> {showDivider && ( @@ -24,25 +35,18 @@ function ArchivedBaseGroup({ data-testid="archived-group" >
{intl.formatMessage(messages.archivedTopics)}
- {archivedTopics?.map((topic, index) => ( - - ))} + {renderArchivedTopics} ); -} +}; ArchivedBaseGroup.propTypes = { archivedTopics: PropTypes.arrayOf(topicShape).isRequired, showDivider: PropTypes.bool, - intl: intlShape.isRequired, }; ArchivedBaseGroup.defaultProps = { showDivider: false, }; -export default injectIntl(ArchivedBaseGroup); +export default React.memo(ArchivedBaseGroup); diff --git a/src/discussions/in-context-topics/topic/SectionBaseGroup.jsx b/src/discussions/in-context-topics/topic/SectionBaseGroup.jsx index a783c248..667eeeee 100644 --- a/src/discussions/in-context-topics/topic/SectionBaseGroup.jsx +++ b/src/discussions/in-context-topics/topic/SectionBaseGroup.jsx @@ -1,11 +1,11 @@ -import React from 'react'; +import React, { useCallback, useMemo } from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; import { useParams } from 'react-router'; import { Link } from 'react-router-dom'; -import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { useIntl } from '@edx/frontend-platform/i18n'; import TopicStats from '../../../components/TopicStats'; import { Routes } from '../../../data/constants'; @@ -13,19 +13,52 @@ import { discussionsPath } from '../../utils'; import messages from '../messages'; import { topicShape } from './Topic'; -function SectionBaseGroup({ +const SectionBaseGroup = ({ section, sectionTitle, sectionId, showDivider, - intl, -}) { +}) => { + const intl = useIntl(); const { courseId } = useParams(); - const isSelected = (id) => window.location.pathname.includes(id); - const sectionUrl = (id) => discussionsPath(Routes.TOPICS.CATEGORY, { + + const isSelected = useCallback((id) => ( + window.location.pathname.includes(id) + ), []); + + const sectionUrl = useCallback((id) => discussionsPath(Routes.TOPICS.CATEGORY, { courseId, category: id, - }); + }), [courseId]); + + const renderSection = useMemo(() => ( + section?.map((subsection, index) => ( + isSelected(subsection.id)} + aria-current={isSelected(section.id) ? 'page' : undefined} + tabIndex={(isSelected(subsection.id) || index === 0) ? 0 : -1} + > +
+
+
+
+ {subsection?.displayName || intl.formatMessage(messages.unnamedSubsection)} +
+ +
+
+
+ + )) + ), [section, sectionUrl, isSelected]); return (
{sectionTitle || intl.formatMessage(messages.unnamedSection)}
- {section.map((subsection, index) => ( - isSelected(subsection.id)} - aria-current={isSelected(section.id) ? 'page' : undefined} - tabIndex={(isSelected(subsection.id) || index === 0) ? 0 : -1} - > -
-
-
-
- {subsection?.displayName || intl.formatMessage(messages.unnamedSubsection)} -
- -
-
-
- - ))} + {renderSection} {showDivider && ( <>
@@ -70,7 +78,7 @@ function SectionBaseGroup({ )}
); -} +}; SectionBaseGroup.propTypes = { section: PropTypes.arrayOf(PropTypes.shape({ @@ -86,7 +94,6 @@ SectionBaseGroup.propTypes = { sectionTitle: PropTypes.string.isRequired, sectionId: PropTypes.string.isRequired, showDivider: PropTypes.bool.isRequired, - intl: intlShape.isRequired, }; -export default injectIntl(SectionBaseGroup); +export default React.memo(SectionBaseGroup); diff --git a/src/discussions/in-context-topics/topic/Topic.jsx b/src/discussions/in-context-topics/topic/Topic.jsx index 927b1494..b8c163b2 100644 --- a/src/discussions/in-context-topics/topic/Topic.jsx +++ b/src/discussions/in-context-topics/topic/Topic.jsx @@ -7,7 +7,7 @@ import { useSelector } from 'react-redux'; import { useParams } from 'react-router'; import { Link } from 'react-router-dom'; -import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { useIntl } from '@edx/frontend-platform/i18n'; import { Icon, OverlayTrigger, Tooltip } from '@edx/paragon'; import { HelpOutline, PostOutline, Report } from '@edx/paragon/icons'; @@ -17,12 +17,12 @@ import { selectUserHasModerationPrivileges, selectUserIsGroupTa } from '../../da import { discussionsPath } from '../../utils'; import messages from '../messages'; -function Topic({ +const Topic = ({ topic, showDivider, index, - intl, -}) { +}) => { + const intl = useIntl(); const { courseId } = useParams(); const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges); const userIsGroupTa = useSelector(selectUserIsGroupTa); @@ -70,7 +70,7 @@ function Topic({ )} ); -} +}; export const topicShape = PropTypes.shape({ id: PropTypes.string, @@ -85,7 +85,6 @@ export const topicShape = PropTypes.shape({ }); Topic.propTypes = { - intl: intlShape.isRequired, topic: topicShape, showDivider: PropTypes.bool, index: PropTypes.number, @@ -99,4 +98,4 @@ Topic.defaultProps = { }, }; -export default injectIntl(Topic); +export default React.memo(Topic); diff --git a/src/discussions/learners/LearnerPostsView.jsx b/src/discussions/learners/LearnerPostsView.jsx index 44ca9be7..a4989009 100644 --- a/src/discussions/learners/LearnerPostsView.jsx +++ b/src/discussions/learners/LearnerPostsView.jsx @@ -6,7 +6,7 @@ import capitalize from 'lodash/capitalize'; import { useDispatch, useSelector } from 'react-redux'; import { useHistory, useLocation } from 'react-router-dom'; -import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { useIntl } from '@edx/frontend-platform/i18n'; import { Button, Icon, IconButton, Spinner, } from '@edx/paragon'; @@ -18,33 +18,36 @@ import { } from '../../data/constants'; import { DiscussionContext } from '../common/context'; import { selectUserHasModerationPrivileges, selectUserIsStaff } from '../data/selectors'; +import { usePostList } from '../posts/data/hooks'; import { - selectAllThreads, + selectAllThreadsIds, selectThreadNextPage, threadsLoadingStatus, } from '../posts/data/selectors'; import { clearPostsPages } from '../posts/data/slices'; import NoResults from '../posts/NoResults'; import { PostLink } from '../posts/post'; -import { discussionsPath, filterPosts } from '../utils'; +import { discussionsPath } from '../utils'; import { fetchUserPosts } from './data/thunks'; import LearnerPostFilterBar from './learner-post-filter-bar/LearnerPostFilterBar'; import messages from './messages'; -function LearnerPostsView({ intl }) { +const LearnerPostsView = () => { + const intl = useIntl(); const location = useLocation(); const history = useHistory(); const dispatch = useDispatch(); - const posts = useSelector(selectAllThreads); + const postsIds = useSelector(selectAllThreadsIds); const loadingStatus = useSelector(threadsLoadingStatus()); const postFilter = useSelector(state => state.learners.postFilter); const { courseId, learnerUsername: username } = useContext(DiscussionContext); const nextPage = useSelector(selectThreadNextPage()); const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges); const userIsStaff = useSelector(selectUserIsStaff); + const sortedPostsIds = usePostList(postsIds); - const loadMorePosts = (pageNum = undefined) => { + const loadMorePosts = useCallback((pageNum = undefined) => { const params = { author: username, page: pageNum, @@ -54,29 +57,24 @@ function LearnerPostsView({ intl }) { }; dispatch(fetchUserPosts(courseId, params)); - }; + }, [courseId, postFilter, username, userHasModerationPrivileges, userIsStaff]); + + const postInstances = useMemo(() => ( + sortedPostsIds?.map((postId, idx) => ( + + )) + ), [sortedPostsIds]); useEffect(() => { dispatch(clearPostsPages()); loadMorePosts(); }, [courseId, postFilter, username]); - const checkIsSelected = (id) => window.location.pathname.includes(id); - const pinnedPosts = useMemo(() => filterPosts(posts, 'pinned'), [posts]); - const unpinnedPosts = useMemo(() => filterPosts(posts, 'unpinned'), [posts]); - - const postInstances = useCallback((sortedPosts) => ( - sortedPosts.map((post, idx) => ( - - )) - ), []); - return (
@@ -97,9 +95,8 @@ function LearnerPostsView({ intl }) {
- {postInstances(pinnedPosts)} - {postInstances(unpinnedPosts)} - {loadingStatus !== RequestStatus.IN_PROGRESS && posts?.length === 0 && } + {postInstances} + {loadingStatus !== RequestStatus.IN_PROGRESS && sortedPostsIds?.length === 0 && } {loadingStatus === RequestStatus.IN_PROGRESS ? (
@@ -114,10 +111,6 @@ function LearnerPostsView({ intl }) {
); -} - -LearnerPostsView.propTypes = { - intl: intlShape.isRequired, }; -export default injectIntl(LearnerPostsView); +export default LearnerPostsView; diff --git a/src/discussions/learners/LearnersView.jsx b/src/discussions/learners/LearnersView.jsx index 8973d2af..71a4491e 100644 --- a/src/discussions/learners/LearnersView.jsx +++ b/src/discussions/learners/LearnersView.jsx @@ -1,11 +1,11 @@ -import React, { useEffect } from 'react'; +import React, { useCallback, useEffect, useMemo } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { Redirect, useLocation, useParams, } from 'react-router'; -import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { useIntl } from '@edx/frontend-platform/i18n'; import { Button, Spinner } from '@edx/paragon'; import SearchInfo from '../../components/SearchInfo'; @@ -24,7 +24,8 @@ import { fetchLearners } from './data/thunks'; import { LearnerCard, LearnerFilterBar } from './learner'; import messages from './messages'; -function LearnersView({ intl }) { +const LearnersView = () => { + const intl = useIntl(); const { courseId } = useParams(); const location = useLocation(); const dispatch = useDispatch(); @@ -46,7 +47,7 @@ function LearnersView({ intl }) { } }, [courseId, orderBy, learnersTabEnabled, usernameSearch]); - const loadPage = async () => { + const loadPage = useCallback(async () => { if (nextPage) { dispatch(fetchLearners(courseId, { orderBy, @@ -54,7 +55,19 @@ function LearnersView({ intl }) { usernameSearch, })); } - }; + }, [courseId, orderBy, nextPage, usernameSearch]); + + const handleOnClear = useCallback(() => { + dispatch(setUsernameSearch('')); + }, []); + + const renderLearnersList = useMemo(() => ( + ( + courseConfigLoadingStatus === RequestStatus.SUCCESSFUL && learnersTabEnabled && learners.map((learner) => ( + + )) + ) || <> + ), [courseConfigLoadingStatus, learnersTabEnabled, learners]); return (
@@ -65,7 +78,7 @@ function LearnersView({ intl }) { text={usernameSearch} count={learners.length} loadingStatus={loadingStatus} - onClear={() => dispatch(setUsernameSearch(''))} + onClear={handleOnClear} /> )}
@@ -77,12 +90,7 @@ function LearnersView({ intl }) { }} /> )} - {courseConfigLoadingStatus === RequestStatus.SUCCESSFUL - && learnersTabEnabled - && learners.map((learner, index) => ( - // eslint-disable-next-line react/no-array-index-key - - ))} + {renderLearnersList} {loadingStatus === RequestStatus.IN_PROGRESS ? (
@@ -98,10 +106,6 @@ function LearnersView({ intl }) {
); -} - -LearnersView.propTypes = { - intl: intlShape.isRequired, }; -export default injectIntl(LearnersView); +export default LearnersView; diff --git a/src/discussions/learners/learner/LearnerAvatar.jsx b/src/discussions/learners/learner/LearnerAvatar.jsx index f980c2d6..7f1e0feb 100644 --- a/src/discussions/learners/learner/LearnerAvatar.jsx +++ b/src/discussions/learners/learner/LearnerAvatar.jsx @@ -1,26 +1,23 @@ import React from 'react'; +import PropTypes from 'prop-types'; import { Avatar } from '@edx/paragon'; -import { learnerShape } from './proptypes'; - -function LearnerAvatar({ learner }) { - return ( -
- -
- ); -} +const LearnerAvatar = ({ username }) => ( +
+ +
+); LearnerAvatar.propTypes = { - learner: learnerShape.isRequired, + username: PropTypes.string.isRequired, }; -export default LearnerAvatar; +export default React.memo(LearnerAvatar); diff --git a/src/discussions/learners/learner/LearnerCard.jsx b/src/discussions/learners/learner/LearnerCard.jsx index ea59f090..97acb808 100644 --- a/src/discussions/learners/learner/LearnerCard.jsx +++ b/src/discussions/learners/learner/LearnerCard.jsx @@ -1,10 +1,7 @@ import React, { useContext } from 'react'; -import PropTypes from 'prop-types'; import { Link } from 'react-router-dom'; -import { injectIntl } from '@edx/frontend-platform/i18n'; - import { Routes } from '../../../data/constants'; import { DiscussionContext } from '../../common/context'; import { discussionsPath } from '../../utils'; @@ -12,11 +9,11 @@ import LearnerAvatar from './LearnerAvatar'; import LearnerFooter from './LearnerFooter'; import { learnerShape } from './proptypes'; -function LearnerCard({ - learner, - courseId, -}) { - const { enableInContextSidebar, learnerUsername } = useContext(DiscussionContext); +const LearnerCard = ({ learner }) => { + const { + username, threads, inactiveFlags, activeFlags, responses, replies, + } = learner; + const { enableInContextSidebar, learnerUsername, courseId } = useContext(DiscussionContext); const linkUrl = discussionsPath(Routes.LEARNERS.POSTS, { 0: enableInContextSidebar ? 'in-context' : undefined, learnerUsername: learner.username, @@ -30,32 +27,40 @@ function LearnerCard({ >
- +
- {learner.username} + {username}
- {learner.threads === null ? null : } + {threads !== null && ( + + )}
); -} +}; LearnerCard.propTypes = { learner: learnerShape.isRequired, - courseId: PropTypes.string.isRequired, }; -export default injectIntl(LearnerCard); +export default React.memo(LearnerCard); diff --git a/src/discussions/learners/learner/LearnerFilterBar.jsx b/src/discussions/learners/learner/LearnerFilterBar.jsx index 62f7cdec..3afe6ccb 100644 --- a/src/discussions/learners/learner/LearnerFilterBar.jsx +++ b/src/discussions/learners/learner/LearnerFilterBar.jsx @@ -1,11 +1,11 @@ -import React, { useState } from 'react'; +import React, { useCallback, useState } from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; import { useDispatch, useSelector } from 'react-redux'; import { sendTrackEvent } from '@edx/frontend-platform/analytics'; -import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { useIntl } from '@edx/frontend-platform/i18n'; import { Collapsible, Form, Icon } from '@edx/paragon'; import { Check, Tune } from '@edx/paragon/icons'; @@ -15,7 +15,7 @@ import { setSortedBy } from '../data'; import { selectLearnerSorting } from '../data/selectors'; import messages from '../messages'; -const ActionItem = ({ +const ActionItem = React.memo(({ id, label, value, @@ -38,7 +38,7 @@ const ActionItem = ({ {label} -); +)); ActionItem.propTypes = { id: PropTypes.string.isRequired, @@ -47,16 +47,15 @@ ActionItem.propTypes = { selected: PropTypes.string.isRequired, }; -function LearnerFilterBar({ - intl, -}) { +const LearnerFilterBar = () => { + const intl = useIntl(); const dispatch = useDispatch(); const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges); const userIsGroupTa = useSelector(selectUserIsGroupTa); const currentSorting = useSelector(selectLearnerSorting()); const [isOpen, setOpen] = useState(false); - const handleSortFilterChange = (event) => { + const handleSortFilterChange = useCallback((event) => { const { name, value } = event.currentTarget; if (name === 'sort') { @@ -68,12 +67,16 @@ function LearnerFilterBar({ }, ); } - }; + }, []); + + const handleOnToggle = useCallback(() => { + setOpen(!isOpen); + }, [isOpen]); return ( setOpen(!isOpen)} + onToggle={handleOnToggle} className="filter-bar collapsible-card-lg border-0" > @@ -124,10 +127,6 @@ function LearnerFilterBar({ ); -} - -LearnerFilterBar.propTypes = { - intl: intlShape.isRequired, }; -export default injectIntl(LearnerFilterBar); +export default LearnerFilterBar; diff --git a/src/discussions/learners/learner/LearnerFooter.jsx b/src/discussions/learners/learner/LearnerFooter.jsx index abd6d1a2..2ee5bd5b 100644 --- a/src/discussions/learners/learner/LearnerFooter.jsx +++ b/src/discussions/learners/learner/LearnerFooter.jsx @@ -1,24 +1,22 @@ import React from 'react'; +import PropTypes from 'prop-types'; import { useSelector } from 'react-redux'; -import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { useIntl } from '@edx/frontend-platform/i18n'; import { Icon, OverlayTrigger, Tooltip } from '@edx/paragon'; import { Edit, Report, ReportGmailerrorred } from '@edx/paragon/icons'; import { QuestionAnswerOutline } from '../../../components/icons'; import { selectUserHasModerationPrivileges, selectUserIsGroupTa } from '../../data/selectors'; import messages from '../messages'; -import { learnerShape } from './proptypes'; -function LearnerFooter({ - learner, - intl, -}) { +const LearnerFooter = ({ + inactiveFlags, activeFlags, threads, responses, replies, username, +}) => { + const intl = useIntl(); const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges); const userIsGroupTa = useSelector(selectUserIsGroupTa); - const { inactiveFlags } = learner; - const { activeFlags } = learner; const canSeeLearnerReportedStats = (activeFlags || inactiveFlags) && (userHasModerationPrivileges || userIsGroupTa); return ( @@ -35,7 +33,7 @@ function LearnerFooter({ >
- {learner.threads + learner.responses + learner.replies} + {threads + responses + replies}
- {learner.threads} + {threads}
{Boolean(canSeeLearnerReportedStats) && ( +
{Boolean(activeFlags) && ( @@ -83,11 +81,24 @@ function LearnerFooter({ )}
); -} - -LearnerFooter.propTypes = { - intl: intlShape.isRequired, - learner: learnerShape.isRequired, }; -export default injectIntl(LearnerFooter); +LearnerFooter.propTypes = { + inactiveFlags: PropTypes.number, + activeFlags: PropTypes.number, + threads: PropTypes.number, + responses: PropTypes.number, + replies: PropTypes.number, + username: PropTypes.string, +}; + +LearnerFooter.defaultProps = { + inactiveFlags: 0, + activeFlags: 0, + threads: 0, + responses: 0, + replies: 0, + username: '', +}; + +export default React.memo(LearnerFooter); diff --git a/src/discussions/navigation/breadcrumb-menu/BreadcrumbDropdown.jsx b/src/discussions/navigation/breadcrumb-menu/BreadcrumbDropdown.jsx index 70886178..8c55dd0f 100644 --- a/src/discussions/navigation/breadcrumb-menu/BreadcrumbDropdown.jsx +++ b/src/discussions/navigation/breadcrumb-menu/BreadcrumbDropdown.jsx @@ -3,22 +3,23 @@ import PropTypes from 'prop-types'; import { Link } from 'react-router-dom'; -import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { useIntl } from '@edx/frontend-platform/i18n'; import { Dropdown, DropdownButton } from '@edx/paragon'; import messages from './messages'; -function BreadcrumbDropdown({ +const BreadcrumbDropdown = ({ currentItem, - intl, showAllPath, items, itemPathFunc, itemLabelFunc, itemActiveFunc, itemFilterFunc, -}) { +}) => { + const intl = useIntl(); const showAllMsg = intl.formatMessage(messages.showAll); + return ( ); -} +}; BreadcrumbDropdown.propTypes = { // eslint-disable-next-line react/forbid-prop-types currentItem: PropTypes.any, - intl: intlShape.isRequired, showAllPath: PropTypes.func.isRequired, // eslint-disable-next-line react/forbid-prop-types items: PropTypes.array.isRequired, @@ -60,9 +60,10 @@ BreadcrumbDropdown.propTypes = { itemActiveFunc: PropTypes.func.isRequired, itemFilterFunc: PropTypes.func, }; + BreadcrumbDropdown.defaultProps = { currentItem: null, itemFilterFunc: null, }; -export default injectIntl(BreadcrumbDropdown); +export default React.memo(BreadcrumbDropdown); diff --git a/src/discussions/navigation/breadcrumb-menu/LegacyBreadcrumbMenu.jsx b/src/discussions/navigation/breadcrumb-menu/LegacyBreadcrumbMenu.jsx index d5afd9ff..f9ad455e 100644 --- a/src/discussions/navigation/breadcrumb-menu/LegacyBreadcrumbMenu.jsx +++ b/src/discussions/navigation/breadcrumb-menu/LegacyBreadcrumbMenu.jsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useCallback } from 'react'; import { useSelector } from 'react-redux'; import { useRouteMatch } from 'react-router'; @@ -13,7 +13,7 @@ import { import { discussionsPath } from '../../utils'; import BreadcrumbDropdown from './BreadcrumbDropdown'; -function LegacyBreadcrumbMenu() { +const LegacyBreadcrumbMenu = () => { const { params: { courseId, @@ -21,7 +21,6 @@ function LegacyBreadcrumbMenu() { topicId: currentTopicId, }, } = useRouteMatch([Routes.TOPICS.CATEGORY, Routes.TOPICS.TOPIC]); - const currentTopic = useSelector(selectTopic(currentTopicId)); const currentCategory = category || currentTopic?.categoryId; const decodedCurrentCategory = String(currentCategory).replace('%23', '#'); @@ -30,31 +29,68 @@ function LegacyBreadcrumbMenu() { const categories = useSelector(selectCategories); const isNonCoursewareTopic = currentTopic && !currentCategory; + const nonCoursewareItemLabel = useCallback((item) => ( + item?.name + ), []); + + const nonCoursewareActive = useCallback((topic) => ( + topic?.id === currentTopicId + ), [currentTopicId]); + + const nonCoursewareItemPath = useCallback((topic) => ( + discussionsPath(Routes.TOPICS.TOPIC, { + courseId, + topicId: topic.id, + }) + ), [courseId]); + + const coursewareItemLabel = useCallback((catId) => ( + catId + ), []); + + const coursewareActive = useCallback((catId) => ( + catId === currentCategory + ), [currentTopicId]); + + const coursewareItemPath = useCallback((catId) => ( + discussionsPath(Routes.TOPICS.CATEGORY, { + courseId, + category: catId, + }) + ), [courseId]); + + const categoryItemLabel = useCallback((item) => item?.name, []); + + const categoryActive = useCallback((topic) => ( + topic?.id === currentTopicId + ), [currentTopicId]); + + const categoryItemPath = useCallback((topic) => ( + discussionsPath(Routes.TOPICS.TOPIC, { + courseId, + topicId: topic.id, + }) + ), [courseId]); + return (
{isNonCoursewareTopic ? ( item?.name} - itemActiveFunc={(topic) => topic?.id === currentTopicId} + itemLabelFunc={nonCoursewareItemLabel} + itemActiveFunc={nonCoursewareActive} items={nonCoursewareTopics} showAllPath={discussionsPath(Routes.TOPICS.ALL, { courseId })} - itemPathFunc={(topic) => discussionsPath(Routes.TOPICS.TOPIC, { - courseId, - topicId: topic.id, - })} + itemPathFunc={nonCoursewareItemPath} /> ) : ( catId} - itemActiveFunc={(catId) => catId === currentCategory} + itemLabelFunc={coursewareItemLabel} + itemActiveFunc={coursewareActive} items={categories} showAllPath={discussionsPath(Routes.TOPICS.ALL, { courseId })} - itemPathFunc={(catId) => discussionsPath(Routes.TOPICS.CATEGORY, { - courseId, - category: catId, - })} + itemPathFunc={coursewareItemPath} /> )} {currentCategory && ( @@ -62,24 +98,19 @@ function LegacyBreadcrumbMenu() {
/
item?.name} - itemActiveFunc={(topic) => topic?.id === currentTopicId} + itemLabelFunc={categoryItemLabel} + itemActiveFunc={categoryActive} items={topicsInCategory} showAllPath={discussionsPath(Routes.TOPICS.CATEGORY, { courseId, category: currentCategory, })} - itemPathFunc={(topic) => discussionsPath(Routes.TOPICS.TOPIC, { - courseId, - topicId: topic.id, - })} + itemPathFunc={categoryItemPath} /> )}
); -} - -LegacyBreadcrumbMenu.propTypes = {}; +}; export default LegacyBreadcrumbMenu; diff --git a/src/discussions/navigation/navigation-bar/NavigationBar.jsx b/src/discussions/navigation/navigation-bar/NavigationBar.jsx index ee99beb8..dd7ea886 100644 --- a/src/discussions/navigation/navigation-bar/NavigationBar.jsx +++ b/src/discussions/navigation/navigation-bar/NavigationBar.jsx @@ -1,21 +1,23 @@ -import React from 'react'; +import React, { useContext, useMemo } from 'react'; -import { matchPath, useParams } from 'react-router'; +import { matchPath } from 'react-router'; import { NavLink } from 'react-router-dom'; -import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { useIntl } from '@edx/frontend-platform/i18n'; import { Nav } from '@edx/paragon'; import { Routes } from '../../../data/constants'; +import { DiscussionContext } from '../../common/context'; import { useShowLearnersTab } from '../../data/hooks'; import { discussionsPath } from '../../utils'; import messages from './messages'; -function NavigationBar({ intl }) { - const { courseId } = useParams(); +const NavigationBar = () => { + const intl = useIntl(); + const { courseId } = useContext(DiscussionContext); const showLearnersTab = useShowLearnersTab(); - const navLinks = [ + const navLinks = useMemo(() => ([ { route: Routes.POSTS.MY_POSTS, labelMessage: messages.myPosts, @@ -29,19 +31,23 @@ function NavigationBar({ intl }) { isActive: (match, location) => Boolean(matchPath(location.pathname, { path: Routes.TOPICS.PATH })), labelMessage: messages.allTopics, }, - ]; - if (showLearnersTab) { - navLinks.push({ - route: Routes.LEARNERS.PATH, - labelMessage: messages.learners, - }); - } + ]), []); + + useMemo(() => { + if (showLearnersTab) { + navLinks.push({ + route: Routes.LEARNERS.PATH, + labelMessage: messages.learners, + }); + } + }, [showLearnersTab]); return ( -
); -} - -CommentSortDropdown.propTypes = { - intl: intlShape.isRequired, - }; -export default injectIntl(CommentSortDropdown); +export default CommentSortDropdown; diff --git a/src/discussions/post-comments/comments/CommentsView.jsx b/src/discussions/post-comments/comments/CommentsView.jsx index 6647ebce..b7319f19 100644 --- a/src/discussions/post-comments/comments/CommentsView.jsx +++ b/src/discussions/post-comments/comments/CommentsView.jsx @@ -1,36 +1,39 @@ -import React, { useMemo, useState } from 'react'; +import React, { useCallback, useContext, useState } from 'react'; import PropTypes from 'prop-types'; -import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { useIntl } from '@edx/frontend-platform/i18n'; import { Button, Spinner } from '@edx/paragon'; import { EndorsementStatus } from '../../../data/constants'; import { useUserCanAddThreadInBlackoutDate } from '../../data/hooks'; -import { filterPosts, isLastElementOfList } from '../../utils'; +import { isLastElementOfList } from '../../utils'; import { usePostComments } from '../data/hooks'; import messages from '../messages'; +import { PostCommentsContext } from '../postCommentsContext'; import { Comment, ResponseEditor } from './comment'; -function CommentsView({ - postType, - postId, - intl, - endorsed, - isClosed, -}) { +const CommentsView = ({ endorsed }) => { + const intl = useIntl(); + const [addingResponse, setAddingResponse] = useState(false); + const { isClosed } = useContext(PostCommentsContext); + const userCanAddThreadInBlackoutDate = useUserCanAddThreadInBlackoutDate(); const { - comments, + endorsedCommentsIds, + unEndorsedCommentsIds, hasMorePages, isLoading, handleLoadMoreResponses, - } = usePostComments(postId, endorsed); + } = usePostComments(endorsed); - const endorsedComments = useMemo(() => [...filterPosts(comments, 'endorsed')], [comments]); - const unEndorsedComments = useMemo(() => [...filterPosts(comments, 'unendorsed')], [comments]); - const userCanAddThreadInBlackoutDate = useUserCanAddThreadInBlackoutDate(); - const [addingResponse, setAddingResponse] = useState(false); + const handleAddResponse = useCallback(() => { + setAddingResponse(true); + }, []); - const handleDefinition = (message, commentsLength) => ( + const handleCloseResponseEditor = useCallback(() => { + setAddingResponse(false); + }, []); + + const handleDefinition = useCallback((message, commentsLength) => (
{intl.formatMessage(message, { num: commentsLength })}
- ); + ), []); - const handleComments = (postComments, showLoadMoreResponses = false) => ( + const handleComments = useCallback((postCommentsIds, showLoadMoreResponses = false) => (
- {postComments.map((comment) => ( + {postCommentsIds.map((commentId) => ( ))} {hasMorePages && !isLoading && !showLoadMoreResponses && ( @@ -68,26 +69,26 @@ function CommentsView({
)}
- ); + ), [hasMorePages, isLoading, handleLoadMoreResponses]); return ( <> {((hasMorePages && isLoading) || !isLoading) && ( <> - {endorsedComments.length > 0 && ( + {endorsedCommentsIds.length > 0 && ( <> - {handleDefinition(messages.endorsedResponseCount, endorsedComments.length)} + {handleDefinition(messages.endorsedResponseCount, endorsedCommentsIds.length)} {endorsed === EndorsementStatus.DISCUSSION - ? handleComments(endorsedComments, true) - : handleComments(endorsedComments, false)} + ? handleComments(endorsedCommentsIds, true) + : handleComments(endorsedCommentsIds, false)} )} {endorsed !== EndorsementStatus.ENDORSED && ( <> - {handleDefinition(messages.responseCount, unEndorsedComments.length)} - {unEndorsedComments.length === 0 &&
} - {handleComments(unEndorsedComments, false)} - {(userCanAddThreadInBlackoutDate && !!unEndorsedComments.length && !isClosed) && ( + {handleDefinition(messages.responseCount, unEndorsedCommentsIds.length)} + {unEndorsedCommentsIds.length === 0 &&
} + {handleComments(unEndorsedCommentsIds, false)} + {(userCanAddThreadInBlackoutDate && !!unEndorsedCommentsIds.length && !isClosed) && (
{!addingResponse && ( )} setAddingResponse(false)} addWrappingDiv addingResponse={addingResponse} + handleCloseEditor={handleCloseResponseEditor} />
)} @@ -115,16 +115,12 @@ function CommentsView({ )} ); -} +}; CommentsView.propTypes = { - postId: PropTypes.string.isRequired, - postType: PropTypes.string.isRequired, - isClosed: PropTypes.bool.isRequired, - intl: intlShape.isRequired, endorsed: PropTypes.oneOf([ EndorsementStatus.ENDORSED, EndorsementStatus.UNENDORSED, EndorsementStatus.DISCUSSION, ]).isRequired, }; -export default injectIntl(CommentsView); +export default React.memo(CommentsView); diff --git a/src/discussions/post-comments/comments/comment/Comment.jsx b/src/discussions/post-comments/comments/comment/Comment.jsx index 841f8d08..93733793 100644 --- a/src/discussions/post-comments/comments/comment/Comment.jsx +++ b/src/discussions/post-comments/comments/comment/Comment.jsx @@ -1,13 +1,12 @@ import React, { - useCallback, - useContext, useEffect, useMemo, useState, + useCallback, useContext, useEffect, useMemo, useState, } from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; import { useDispatch, useSelector } from 'react-redux'; -import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { useIntl } from '@edx/frontend-platform/i18n'; import { Button, useToggle } from '@edx/paragon'; import HTMLLoader from '../../../../components/HTMLLoader'; @@ -15,6 +14,7 @@ import { ContentActions, EndorsementStatus } from '../../../../data/constants'; import { AlertBanner, Confirmation, EndorsedAlertBanner } from '../../../common'; import { DiscussionContext } from '../../../common/context'; import HoverCard from '../../../common/HoverCard'; +import { ContentTypes } from '../../../data/constants'; import { useUserCanAddThreadInBlackoutDate } from '../../../data/hooks'; import { fetchThread } from '../../../posts/data/thunks'; import LikeButton from '../../../posts/post/LikeButton'; @@ -22,88 +22,123 @@ import { useActions } from '../../../utils'; import { selectCommentCurrentPage, selectCommentHasMorePages, + selectCommentOrResponseById, selectCommentResponses, + selectCommentResponsesIds, selectCommentSortOrder, } from '../../data/selectors'; import { editComment, fetchCommentResponses, removeComment } from '../../data/thunks'; import messages from '../../messages'; +import { PostCommentsContext } from '../../postCommentsContext'; import CommentEditor from './CommentEditor'; import CommentHeader from './CommentHeader'; -import { commentShape } from './proptypes'; import Reply from './Reply'; -function Comment({ - postType, - comment, - showFullThread = true, - isClosedPost, - intl, +const Comment = ({ + commentId, marginBottom, -}) { + showFullThread = true, +}) => { + const comment = useSelector(selectCommentOrResponseById(commentId)); + const { + id, parentId, childCount, abuseFlagged, endorsed, threadId, endorsedAt, endorsedBy, endorsedByLabel, renderedBody, + voted, following, voteCount, authorLabel, author, createdAt, lastEdit, rawBody, closed, closedBy, closeReason, + editByLabel, closedByLabel, + } = comment; + const intl = useIntl(); + const hasChildren = childCount > 0; + const isNested = Boolean(parentId); const dispatch = useDispatch(); - const hasChildren = comment.childCount > 0; - const isNested = Boolean(comment.parentId); - const inlineReplies = useSelector(selectCommentResponses(comment.id)); + const { courseId } = useContext(DiscussionContext); + const { isClosed } = useContext(PostCommentsContext); const [isEditing, setEditing] = useState(false); + const [isReplying, setReplying] = useState(false); const [isDeleting, showDeleteConfirmation, hideDeleteConfirmation] = useToggle(false); const [isReporting, showReportConfirmation, hideReportConfirmation] = useToggle(false); - const [isReplying, setReplying] = useState(false); - const hasMorePages = useSelector(selectCommentHasMorePages(comment.id)); - const currentPage = useSelector(selectCommentCurrentPage(comment.id)); - const userCanAddThreadInBlackoutDate = useUserCanAddThreadInBlackoutDate(); - const { courseId } = useContext(DiscussionContext); + const inlineReplies = useSelector(selectCommentResponses(id)); + const inlineRepliesIds = useSelector(selectCommentResponsesIds(id)); + const hasMorePages = useSelector(selectCommentHasMorePages(id)); + const currentPage = useSelector(selectCommentCurrentPage(id)); const sortedOrder = useSelector(selectCommentSortOrder); + const actions = useActions(ContentTypes.COMMENT, id); + const userCanAddThreadInBlackoutDate = useUserCanAddThreadInBlackoutDate(); useEffect(() => { // If the comment has a parent comment, it won't have any children, so don't fetch them. if (hasChildren && showFullThread) { - dispatch(fetchCommentResponses(comment.id, { + dispatch(fetchCommentResponses(id, { page: 1, reverseOrder: sortedOrder, })); } - }, [comment.id, sortedOrder]); + }, [id, sortedOrder]); - const actions = useActions({ - ...comment, - postType, - }); - const endorseIcons = actions.find(({ action }) => action === EndorsementStatus.ENDORSED); + const endorseIcons = useMemo(() => ( + actions.find(({ action }) => action === EndorsementStatus.ENDORSED) + ), [actions]); + + const handleEditContent = useCallback(() => { + setEditing(true); + }, []); + + const handleCommentEndorse = useCallback(async () => { + await dispatch(editComment(id, { endorsed: !endorsed }, ContentActions.ENDORSE)); + await dispatch(fetchThread(threadId, courseId)); + }, [id, endorsed, threadId]); const handleAbusedFlag = useCallback(() => { - if (comment.abuseFlagged) { - dispatch(editComment(comment.id, { flagged: !comment.abuseFlagged })); + if (abuseFlagged) { + dispatch(editComment(id, { flagged: !abuseFlagged })); } else { showReportConfirmation(); } - }, [comment.abuseFlagged, comment.id, dispatch, showReportConfirmation]); + }, [abuseFlagged, id, showReportConfirmation]); - const handleDeleteConfirmation = () => { - dispatch(removeComment(comment.id)); + const handleDeleteConfirmation = useCallback(() => { + dispatch(removeComment(id)); hideDeleteConfirmation(); - }; + }, [id, hideDeleteConfirmation]); - const handleReportConfirmation = () => { - dispatch(editComment(comment.id, { flagged: !comment.abuseFlagged })); + const handleReportConfirmation = useCallback(() => { + dispatch(editComment(id, { flagged: !abuseFlagged })); hideReportConfirmation(); - }; + }, [abuseFlagged, id, hideReportConfirmation]); const actionHandlers = useMemo(() => ({ - [ContentActions.EDIT_CONTENT]: () => setEditing(true), - [ContentActions.ENDORSE]: async () => { - await dispatch(editComment(comment.id, { endorsed: !comment.endorsed }, ContentActions.ENDORSE)); - await dispatch(fetchThread(comment.threadId, courseId)); - }, + [ContentActions.EDIT_CONTENT]: handleEditContent, + [ContentActions.ENDORSE]: handleCommentEndorse, [ContentActions.DELETE]: showDeleteConfirmation, - [ContentActions.REPORT]: () => handleAbusedFlag(), - }), [showDeleteConfirmation, dispatch, comment.id, comment.endorsed, comment.threadId, courseId, handleAbusedFlag]); + [ContentActions.REPORT]: handleAbusedFlag, + }), [handleEditContent, handleCommentEndorse, showDeleteConfirmation, handleAbusedFlag]); - const handleLoadMoreComments = () => ( - dispatch(fetchCommentResponses(comment.id, { + const handleLoadMoreComments = useCallback(() => ( + dispatch(fetchCommentResponses(id, { page: currentPage + 1, reverseOrder: sortedOrder, })) - ); + ), [id, currentPage, sortedOrder]); + + const handleAddCommentButton = useCallback(() => { + if (userCanAddThreadInBlackoutDate) { + setReplying(true); + } + }, [userCanAddThreadInBlackoutDate]); + + const handleCommentLike = useCallback(async () => { + await dispatch(editComment(id, { voted: !voted })); + }, [id, voted]); + + const handleCloseEditor = useCallback(() => { + setEditing(false); + }, []); + + const handleAddCommentReply = useCallback(() => { + setReplying(true); + }, []); + + const handleCloseReplyEditor = useCallback(() => { + setReplying(false); + }, []); return (
@@ -111,7 +146,7 @@ function Comment({
- {!comment.abuseFlagged && ( + {!abuseFlagged && ( )} - +
setReplying(true)} - onLike={() => dispatch(editComment(comment.id, { voted: !comment.voted }))} + handleResponseCommentButton={handleAddCommentButton} addResponseCommentButtonMessage={intl.formatMessage(messages.addComment)} - isClosedPost={isClosedPost} + onLike={handleCommentLike} + voted={voted} + following={following} endorseIcons={endorseIcons} /> - - - {isEditing - ? ( - setEditing(false)} formClasses="pt-3" /> - ) - : ( - - )} - {comment.voted && ( + + + {isEditing ? ( + + ) : ( + + )} + {voted && (
dispatch(editComment(comment.id, { voted: !comment.voted }))} - voted={comment.voted} + count={voteCount} + onClick={handleCommentLike} + voted={voted} />
)} - {inlineReplies.length > 0 && ( + {inlineRepliesIds.length > 0 && (
- {/* Pass along intl since component used here is the one before it's injected with `injectIntl` */} - {inlineReplies.map(inlineReply => ( + {inlineRepliesIds.map(replyId => ( ))}
@@ -195,46 +259,39 @@ function Comment({ isReplying ? (
setReplying(false)} + onCloseEditor={handleCloseReplyEditor} />
) : ( - <> - {!isClosedPost && userCanAddThreadInBlackoutDate && (inlineReplies.length >= 5) - && ( - - )} - + !isClosed && userCanAddThreadInBlackoutDate && (inlineReplies.length >= 5) && ( + + ) ) )}
); -} +}; Comment.propTypes = { - postType: PropTypes.oneOf(['discussion', 'question']).isRequired, - comment: commentShape.isRequired, - showFullThread: PropTypes.bool, - isClosedPost: PropTypes.bool, - intl: intlShape.isRequired, + commentId: PropTypes.string.isRequired, marginBottom: PropTypes.bool, + showFullThread: PropTypes.bool, }; Comment.defaultProps = { + marginBottom: false, showFullThread: true, - isClosedPost: false, - marginBottom: true, }; -export default injectIntl(Comment); +export default React.memo(Comment); diff --git a/src/discussions/post-comments/comments/comment/CommentEditor.jsx b/src/discussions/post-comments/comments/comment/CommentEditor.jsx index 422dab6b..ed921ee7 100644 --- a/src/discussions/post-comments/comments/comment/CommentEditor.jsx +++ b/src/discussions/post-comments/comments/comment/CommentEditor.jsx @@ -1,11 +1,11 @@ -import React, { useContext, useRef } from 'react'; +import React, { useCallback, useContext, useRef } from 'react'; import PropTypes from 'prop-types'; import { Formik } from 'formik'; import { useSelector } from 'react-redux'; import * as Yup from 'yup'; -import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { useIntl } from '@edx/frontend-platform/i18n'; import { AppContext } from '@edx/frontend-platform/react'; import { Button, Form, StatefulButton } from '@edx/paragon'; @@ -25,12 +25,15 @@ import { addComment, editComment } from '../../data/thunks'; import messages from '../../messages'; function CommentEditor({ - intl, comment, - onCloseEditor, edit, formClasses, + onCloseEditor, }) { + const { + id, threadId, parentId, rawBody, author, lastEdit, + } = comment; + const intl = useIntl(); const editorRef = useRef(null); const { authenticatedUser } = useContext(AppContext); const { enableInContextSidebar } = useContext(DiscussionContext); @@ -42,7 +45,7 @@ function CommentEditor({ const canDisplayEditReason = (reasonCodesEnabled && edit && (userHasModerationPrivileges || userIsGroupTa || userIsStaff) - && comment?.author !== authenticatedUser.username + && author !== authenticatedUser.username ); const editReasonCodeValidation = canDisplayEditReason && { @@ -56,34 +59,34 @@ function CommentEditor({ }); const initialValues = { - comment: comment.rawBody, - editReasonCode: comment?.lastEdit?.reasonCode || (userIsStaff ? 'violates-guidelines' : ''), + comment: rawBody, + editReasonCode: lastEdit?.reasonCode || (userIsStaff ? 'violates-guidelines' : ''), }; - const handleCloseEditor = (resetForm) => { + const handleCloseEditor = useCallback((resetForm) => { resetForm({ values: initialValues }); onCloseEditor(); - }; + }, [onCloseEditor, initialValues]); - const saveUpdatedComment = async (values, { resetForm }) => { - if (comment.id) { + const saveUpdatedComment = useCallback(async (values, { resetForm }) => { + if (id) { const payload = { ...values, editReasonCode: values.editReasonCode || undefined, }; - await dispatch(editComment(comment.id, payload)); + await dispatch(editComment(id, payload)); } else { - await dispatch(addComment(values.comment, comment.threadId, comment.parentId, enableInContextSidebar)); + await dispatch(addComment(values.comment, threadId, parentId, enableInContextSidebar)); } /* istanbul ignore if: TinyMCE is mocked so this cannot be easily tested */ if (editorRef.current) { editorRef.current.plugins.autosave.removeDraft(); } handleCloseEditor(resetForm); - }; + }, [id, threadId, parentId, enableInContextSidebar, handleCloseEditor]); // The editorId is used to autosave contents to localstorage. This format means that the autosave is scoped to // the current comment id, or the current comment parent or the curren thread. - const editorId = `comment-editor-${comment.id || comment.parentId || comment.threadId}`; + const editorId = `comment-editor-${id || parentId || threadId}`; return ( { + const colorClass = AvatarOutlineAndLabelColors[authorLabel]; + const hasAnyAlert = useAlertBannerVisible({ + author, + abuseFlagged, + lastEdit, + closed, + }); return (
); -} - -CommentHeader.propTypes = { - comment: commentShape.isRequired, }; -export default injectIntl(CommentHeader); +CommentHeader.propTypes = { + author: PropTypes.string.isRequired, + authorLabel: PropTypes.string, + abuseFlagged: PropTypes.bool.isRequired, + closed: PropTypes.bool, + createdAt: PropTypes.string.isRequired, + lastEdit: PropTypes.shape({ + editorUsername: PropTypes.string, + reason: PropTypes.string, + }), +}; + +CommentHeader.defaultProps = { + authorLabel: null, + closed: undefined, + lastEdit: null, +}; + +export default React.memo(CommentHeader); diff --git a/src/discussions/post-comments/comments/comment/Reply.jsx b/src/discussions/post-comments/comments/comment/Reply.jsx index d0fcc366..aa59683f 100644 --- a/src/discussions/post-comments/comments/comment/Reply.jsx +++ b/src/discussions/post-comments/comments/comment/Reply.jsx @@ -1,10 +1,10 @@ import React, { useCallback, useMemo, useState } from 'react'; import PropTypes from 'prop-types'; -import { useDispatch } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import * as timeago from 'timeago.js'; -import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { useIntl } from '@edx/frontend-platform/i18n'; import { Avatar, useToggle } from '@edx/paragon'; import HTMLLoader from '../../../../components/HTMLLoader'; @@ -13,57 +13,71 @@ import { ActionsDropdown, AlertBanner, AuthorLabel, Confirmation, } from '../../../common'; import timeLocale from '../../../common/time-locale'; +import { ContentTypes } from '../../../data/constants'; import { useAlertBannerVisible } from '../../../data/hooks'; +import { selectCommentOrResponseById } from '../../data/selectors'; import { editComment, removeComment } from '../../data/thunks'; import messages from '../../messages'; import CommentEditor from './CommentEditor'; -import { commentShape } from './proptypes'; -function Reply({ - reply, - postType, - intl, -}) { +const Reply = ({ responseId }) => { timeago.register('time-locale', timeLocale); + const { + id, abuseFlagged, author, authorLabel, endorsed, lastEdit, closed, closedBy, + closeReason, createdAt, threadId, parentId, rawBody, renderedBody, editByLabel, closedByLabel, + } = useSelector(selectCommentOrResponseById(responseId)); + const intl = useIntl(); const dispatch = useDispatch(); const [isEditing, setEditing] = useState(false); const [isDeleting, showDeleteConfirmation, hideDeleteConfirmation] = useToggle(false); const [isReporting, showReportConfirmation, hideReportConfirmation] = useToggle(false); + const colorClass = AvatarOutlineAndLabelColors[authorLabel]; + const hasAnyAlert = useAlertBannerVisible({ + author, + abuseFlagged, + lastEdit, + closed, + }); + + const handleDeleteConfirmation = useCallback(() => { + dispatch(removeComment(id)); + hideDeleteConfirmation(); + }, [id, hideDeleteConfirmation]); + + const handleReportConfirmation = useCallback(() => { + dispatch(editComment(id, { flagged: !abuseFlagged })); + hideReportConfirmation(); + }, [abuseFlagged, id, hideReportConfirmation]); + + const handleEditContent = useCallback(() => { + setEditing(true); + }, []); + + const handleReplyEndorse = useCallback(() => { + dispatch(editComment(id, { endorsed: !endorsed }, ContentActions.ENDORSE)); + }, [endorsed, id]); const handleAbusedFlag = useCallback(() => { - if (reply.abuseFlagged) { - dispatch(editComment(reply.id, { flagged: !reply.abuseFlagged })); + if (abuseFlagged) { + dispatch(editComment(id, { flagged: !abuseFlagged })); } else { showReportConfirmation(); } - }, [dispatch, reply.abuseFlagged, reply.id, showReportConfirmation]); + }, [abuseFlagged, id, showReportConfirmation]); - const handleDeleteConfirmation = () => { - dispatch(removeComment(reply.id)); - hideDeleteConfirmation(); - }; - - const handleReportConfirmation = () => { - dispatch(editComment(reply.id, { flagged: !reply.abuseFlagged })); - hideReportConfirmation(); - }; + const handleCloseEditor = useCallback(() => { + setEditing(false); + }, []); const actionHandlers = useMemo(() => ({ - [ContentActions.EDIT_CONTENT]: () => setEditing(true), - [ContentActions.ENDORSE]: () => dispatch(editComment( - reply.id, - { endorsed: !reply.endorsed }, - ContentActions.ENDORSE, - )), + [ContentActions.EDIT_CONTENT]: handleEditContent, + [ContentActions.ENDORSE]: handleReplyEndorse, [ContentActions.DELETE]: showDeleteConfirmation, - [ContentActions.REPORT]: () => handleAbusedFlag(), - }), [dispatch, handleAbusedFlag, reply.endorsed, reply.id, showDeleteConfirmation]); - - const colorClass = AvatarOutlineAndLabelColors[reply.authorLabel]; - const hasAnyAlert = useAlertBannerVisible(reply); + [ContentActions.REPORT]: handleAbusedFlag, + }), [handleEditContent, handleReplyEndorse, showDeleteConfirmation, handleAbusedFlag]); return ( -
+
- {!reply.abuseFlagged && ( + {!abuseFlagged && (
- +
)} -
- {isEditing - ? setEditing(false)} /> - : ( - - )} + {isEditing ? ( + + ) : ( + + )}
); -} -Reply.propTypes = { - postType: PropTypes.oneOf(['discussion', 'question']).isRequired, - reply: commentShape.isRequired, - intl: intlShape.isRequired, }; -export default injectIntl(Reply); + +Reply.propTypes = { + responseId: PropTypes.string.isRequired, +}; + +export default React.memo(Reply); diff --git a/src/discussions/post-comments/comments/comment/ResponseEditor.jsx b/src/discussions/post-comments/comments/comment/ResponseEditor.jsx index aae380a9..33714fed 100644 --- a/src/discussions/post-comments/comments/comment/ResponseEditor.jsx +++ b/src/discussions/post-comments/comments/comment/ResponseEditor.jsx @@ -1,36 +1,34 @@ -import React, { useEffect } from 'react'; +import React, { useContext, useEffect } from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; -import { injectIntl } from '@edx/frontend-platform/i18n'; - +import { DiscussionContext } from '../../../common/context'; import CommentEditor from './CommentEditor'; -function ResponseEditor({ - postId, +const ResponseEditor = ({ addWrappingDiv, handleCloseEditor, addingResponse, -}) { +}) => { + const { postId } = useContext(DiscussionContext); + useEffect(() => { handleCloseEditor(); }, [postId]); - return addingResponse - && ( -
- -
- ); -} + return addingResponse && ( +
+ +
+ ); +}; ResponseEditor.propTypes = { - postId: PropTypes.string.isRequired, addWrappingDiv: PropTypes.bool, handleCloseEditor: PropTypes.func.isRequired, addingResponse: PropTypes.bool.isRequired, @@ -40,4 +38,4 @@ ResponseEditor.defaultProps = { addWrappingDiv: false, }; -export default injectIntl(ResponseEditor); +export default React.memo(ResponseEditor); diff --git a/src/discussions/post-comments/data/api.js b/src/discussions/post-comments/data/api.js index 999abf5f..09b8acca 100644 --- a/src/discussions/post-comments/data/api.js +++ b/src/discussions/post-comments/data/api.js @@ -27,6 +27,7 @@ export async function getThreadComments( pageSize, reverseOrder, enableInContextSidebar = false, + signal, } = {}, ) { const params = snakeCaseObject({ @@ -40,7 +41,7 @@ export async function getThreadComments( }); const { data } = await getAuthenticatedHttpClient() - .get(getCommentsApiUrl(), { params }); + .get(getCommentsApiUrl(), { params: { ...params, signal } }); return data; } diff --git a/src/discussions/post-comments/data/hooks.js b/src/discussions/post-comments/data/hooks.js index 5cb072b0..75fe0371 100644 --- a/src/discussions/post-comments/data/hooks.js +++ b/src/discussions/post-comments/data/hooks.js @@ -1,4 +1,6 @@ -import { useContext, useEffect } from 'react'; +import { + useCallback, useContext, useEffect, useMemo, +} from 'react'; import { useDispatch, useSelector } from 'react-redux'; @@ -9,20 +11,21 @@ import { useDispatchWithState } from '../../../data/hooks'; import { DiscussionContext } from '../../common/context'; import { selectThread } from '../../posts/data/selectors'; import { markThreadAsRead } from '../../posts/data/thunks'; +import { filterPosts } from '../../utils'; import { selectCommentSortOrder, selectThreadComments, selectThreadCurrentPage, selectThreadHasMorePages, } from './selectors'; import { fetchThreadComments } from './thunks'; -function trackLoadMoreEvent(postId, params) { +const trackLoadMoreEvent = (postId, params) => ( sendTrackEvent( 'edx.forum.responses.loadMore', { postId, params, }, - ); -} + ) +); export function usePost(postId) { const dispatch = useDispatch(); @@ -34,18 +37,26 @@ export function usePost(postId) { } }, [postId]); - return thread; + return thread || {}; } -export function usePostComments(postId, endorsed = null) { +export function usePostComments(endorsed = null) { + const { enableInContextSidebar, postId } = useContext(DiscussionContext); const [isLoading, dispatch] = useDispatchWithState(); const comments = useSelector(selectThreadComments(postId, endorsed)); const reverseOrder = useSelector(selectCommentSortOrder); const hasMorePages = useSelector(selectThreadHasMorePages(postId, endorsed)); const currentPage = useSelector(selectThreadCurrentPage(postId, endorsed)); - const { enableInContextSidebar } = useContext(DiscussionContext); - const handleLoadMoreResponses = async () => { + const endorsedCommentsIds = useMemo(() => ( + [...filterPosts(comments, 'endorsed')].map(comment => comment.id) + ), [comments]); + + const unEndorsedCommentsIds = useMemo(() => ( + [...filterPosts(comments, 'unendorsed')].map(comment => comment.id) + ), [comments]); + + const handleLoadMoreResponses = useCallback(async () => { const params = { endorsed, page: currentPage + 1, @@ -53,19 +64,27 @@ export function usePostComments(postId, endorsed = null) { }; await dispatch(fetchThreadComments(postId, params)); trackLoadMoreEvent(postId, params); - }; + }, [currentPage, endorsed, postId, reverseOrder]); useEffect(() => { + const abortController = new AbortController(); + dispatch(fetchThreadComments(postId, { endorsed, page: 1, reverseOrder, enableInContextSidebar, + signal: abortController.signal, })); - }, [postId, reverseOrder]); + + return () => { + abortController.abort(); + }; + }, [postId, endorsed, reverseOrder, enableInContextSidebar]); return { - comments, + endorsedCommentsIds, + unEndorsedCommentsIds, hasMorePages, isLoading, handleLoadMoreResponses, @@ -77,5 +96,9 @@ export function useCommentsCount(postId) { const endorsedQuestions = useSelector(selectThreadComments(postId, EndorsementStatus.ENDORSED)); const unendorsedQuestions = useSelector(selectThreadComments(postId, EndorsementStatus.UNENDORSED)); - return [...discussions, ...endorsedQuestions, ...unendorsedQuestions].length; + const commentsLength = useMemo(() => ( + [...discussions, ...endorsedQuestions, ...unendorsedQuestions].length + ), [discussions, endorsedQuestions, unendorsedQuestions]); + + return commentsLength; } diff --git a/src/discussions/post-comments/data/selectors.js b/src/discussions/post-comments/data/selectors.js index c8769b71..960cb8ac 100644 --- a/src/discussions/post-comments/data/selectors.js +++ b/src/discussions/post-comments/data/selectors.js @@ -4,6 +4,11 @@ import { createSelector } from '@reduxjs/toolkit'; const selectCommentsById = state => state.comments.commentsById; const mapIdToComment = (ids, comments) => ids.map(id => comments[id]); +export const selectCommentOrResponseById = commentOrResponseId => createSelector( + selectCommentsById, + comments => comments[commentOrResponseId], +); + export const selectThreadComments = (threadId, endorsed = null) => createSelector( [ state => state.comments.commentsInThreads[threadId]?.[endorsed] || [], @@ -12,6 +17,10 @@ export const selectThreadComments = (threadId, endorsed = null) => createSelecto mapIdToComment, ); +export const selectCommentResponsesIds = commentId => ( + state => state.comments.commentsInComments[commentId] || [] +); + export const selectCommentResponses = commentId => createSelector( [ state => state.comments.commentsInComments[commentId] || [], diff --git a/src/discussions/post-comments/data/thunks.js b/src/discussions/post-comments/data/thunks.js index 92f9cefb..3cb22227 100644 --- a/src/discussions/post-comments/data/thunks.js +++ b/src/discussions/post-comments/data/thunks.js @@ -81,13 +81,14 @@ export function fetchThreadComments( reverseOrder, endorsed = EndorsementStatus.DISCUSSION, enableInContextSidebar, + signal, } = {}, ) { return async (dispatch) => { try { dispatch(fetchCommentsRequest()); const data = await getThreadComments(threadId, { - page, reverseOrder, endorsed, enableInContextSidebar, + page, reverseOrder, endorsed, enableInContextSidebar, signal, }); dispatch(fetchCommentsSuccess({ ...normaliseComments(camelCaseObject(data)), diff --git a/src/discussions/post-comments/postCommentsContext.js b/src/discussions/post-comments/postCommentsContext.js new file mode 100644 index 00000000..89be6d70 --- /dev/null +++ b/src/discussions/post-comments/postCommentsContext.js @@ -0,0 +1,8 @@ +/* eslint-disable import/prefer-default-export */ +import React from 'react'; + +export const PostCommentsContext = React.createContext({ + isClosed: false, + postType: 'discussion', + postId: '', +}); diff --git a/src/discussions/posts/NoResults.jsx b/src/discussions/posts/NoResults.jsx index fbc99a50..73654d0d 100644 --- a/src/discussions/posts/NoResults.jsx +++ b/src/discussions/posts/NoResults.jsx @@ -1,13 +1,16 @@ +import React from 'react'; + import classNames from 'classnames'; import { useSelector } from 'react-redux'; -import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { useIntl } from '@edx/frontend-platform/i18n'; import { selectAreThreadsFiltered } from '../data/selectors'; import { selectTopicFilter } from '../in-context-topics/data/selectors'; import messages from '../messages'; -function NoResults({ intl }) { +const NoResults = () => { + const intl = useIntl(); const postsFiltered = useSelector(selectAreThreadsFiltered); const inContextTopicsFilter = useSelector(selectTopicFilter); const topicsFilter = useSelector(({ topics }) => topics.filter); @@ -17,6 +20,7 @@ function NoResults({ intl }) { || (learnersFilter !== null) || (inContextTopicsFilter !== ''); let helpMessage = messages.removeFilters; + if (!isFiltered) { return null; } if (filters.search || learnersFilter) { @@ -24,6 +28,7 @@ function NoResults({ intl }) { } if (topicsFilter || inContextTopicsFilter) { helpMessage = messages.removeKeywordsOnly; } + const titleCssClasses = classNames( { 'font-weight-normal text-primary-500': topicsFilter || learnersFilter }, ); @@ -37,10 +42,6 @@ function NoResults({ intl }) { {intl.formatMessage(helpMessage)} ); -} - -NoResults.propTypes = { - intl: intlShape.isRequired, }; -export default injectIntl(NoResults); +export default NoResults; diff --git a/src/discussions/posts/PostsList.jsx b/src/discussions/posts/PostsList.jsx index e0ecbff0..28c30c49 100644 --- a/src/discussions/posts/PostsList.jsx +++ b/src/discussions/posts/PostsList.jsx @@ -5,7 +5,7 @@ import PropTypes from 'prop-types'; import { useDispatch, useSelector } from 'react-redux'; -import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { useIntl } from '@edx/frontend-platform/i18n'; import { AppContext } from '@edx/frontend-platform/react'; import { Button, Spinner } from '@edx/paragon'; @@ -14,7 +14,7 @@ import { DiscussionContext } from '../common/context'; import { selectconfigLoadingStatus, selectUserHasModerationPrivileges, selectUserIsStaff } from '../data/selectors'; import { fetchUserPosts } from '../learners/data/thunks'; import messages from '../messages'; -import { filterPosts } from '../utils'; +import { usePostList } from './data/hooks'; import { selectThreadFilters, selectThreadNextPage, selectThreadSorting, threadsLoadingStatus, } from './data/selectors'; @@ -22,25 +22,24 @@ import { fetchThreads } from './data/thunks'; import NoResults from './NoResults'; import { PostLink } from './post'; -function PostsList({ - posts, topics, intl, isTopicTab, parentIsLoading, -}) { +const PostsList = ({ + postsIds, topicsIds, isTopicTab, parentIsLoading, +}) => { + const intl = useIntl(); const dispatch = useDispatch(); - const { - courseId, - page, - } = useContext(DiscussionContext); - const loadingStatus = useSelector(threadsLoadingStatus()); const { authenticatedUser } = useContext(AppContext); + const { courseId, page } = useContext(DiscussionContext); + const loadingStatus = useSelector(threadsLoadingStatus()); const orderBy = useSelector(selectThreadSorting()); const filters = useSelector(selectThreadFilters()); const nextPage = useSelector(selectThreadNextPage()); - const showOwnPosts = page === 'my-posts'; const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges); const userIsStaff = useSelector(selectUserIsStaff); const configStatus = useSelector(selectconfigLoadingStatus); + const sortedPostsIds = usePostList(postsIds); + const showOwnPosts = page === 'my-posts'; - const loadThreads = (topicIds, pageNum = undefined, isFilterChanged = false) => { + const loadThreads = useCallback((topicIds, pageNum = undefined, isFilterChanged = false) => { const params = { orderBy, filters, @@ -50,75 +49,68 @@ function PostsList({ topicIds, isFilterChanged, }; + if (showOwnPosts && filters.search === '') { dispatch(fetchUserPosts(courseId, params)); } else { dispatch(fetchThreads(courseId, params)); } - }; + }, [courseId, orderBy, filters, showOwnPosts, authenticatedUser.username, userHasModerationPrivileges, userIsStaff]); useEffect(() => { - if (topics !== undefined && configStatus === RequestStatus.SUCCESSFUL) { - loadThreads(topics); + if (topicsIds !== undefined && configStatus === RequestStatus.SUCCESSFUL) { + loadThreads(topicsIds); } - }, [courseId, filters, orderBy, page, JSON.stringify(topics), configStatus]); + }, [courseId, filters, orderBy, page, JSON.stringify(topicsIds), configStatus]); useEffect(() => { - if (isTopicTab) { loadThreads(topics, 1, true); } + if (isTopicTab) { + loadThreads(topicsIds, 1, true); + } }, [filters]); - const checkIsSelected = (id) => window.location.pathname.includes(id); - const pinnedPosts = useMemo(() => filterPosts(posts, 'pinned'), [posts]); - const unpinnedPosts = useMemo(() => filterPosts(posts, 'unpinned'), [posts]); - - const postInstances = useCallback((sortedPosts) => ( - sortedPosts.map((post, idx) => ( + const postInstances = useMemo(() => ( + sortedPostsIds?.map((postId, idx) => ( )) - ), []); + ), [sortedPostsIds]); return ( <> - {!parentIsLoading && postInstances(pinnedPosts)} - {!parentIsLoading && postInstances(unpinnedPosts)} - {posts?.length === 0 && loadingStatus === RequestStatus.SUCCESSFUL && } + {!parentIsLoading && postInstances} + {sortedPostsIds?.length === 0 && loadingStatus === RequestStatus.SUCCESSFUL && } {loadingStatus === RequestStatus.IN_PROGRESS || parentIsLoading ? (
) : ( nextPage && loadingStatus === RequestStatus.SUCCESSFUL && ( - ) )} ); -} +}; PostsList.propTypes = { - posts: PropTypes.arrayOf(PropTypes.shape({ - pinned: PropTypes.bool.isRequired, - id: PropTypes.string.isRequired, - })), - topics: PropTypes.arrayOf(PropTypes.string), + postsIds: PropTypes.arrayOf(PropTypes.string), + topicsIds: PropTypes.arrayOf(PropTypes.string), isTopicTab: PropTypes.bool, parentIsLoading: PropTypes.bool, - intl: intlShape.isRequired, }; PostsList.defaultProps = { - posts: [], - topics: undefined, + postsIds: [], + topicsIds: undefined, isTopicTab: false, parentIsLoading: undefined, }; -export default injectIntl(PostsList); +export default React.memo(PostsList); diff --git a/src/discussions/posts/PostsView.jsx b/src/discussions/posts/PostsView.jsx index fe050bcc..883d5052 100644 --- a/src/discussions/posts/PostsView.jsx +++ b/src/discussions/posts/PostsView.jsx @@ -1,4 +1,6 @@ -import React, { useContext, useEffect } from 'react'; +import React, { + useCallback, useContext, useEffect, useMemo, +} from 'react'; import PropTypes from 'prop-types'; import isEmpty from 'lodash/isEmpty'; @@ -13,39 +15,42 @@ import { fetchCourseTopicsV3 } from '../in-context-topics/data/thunks'; import { selectTopics } from '../topics/data/selectors'; import { fetchCourseTopics } from '../topics/data/thunks'; import { handleKeyDown } from '../utils'; -import { selectAllThreads, selectTopicThreads } from './data/selectors'; +import { selectAllThreadsIds, selectTopicThreadsIds } from './data/selectors'; import { setSearchQuery } from './data/slices'; import PostFilterBar from './post-filter-bar/PostFilterBar'; import PostsList from './PostsList'; -function AllPostsList() { - const posts = useSelector(selectAllThreads); - return ; -} +const AllPostsList = () => { + const postsIds = useSelector(selectAllThreadsIds); -function TopicPostsList({ topicId }) { - const posts = useSelector(selectTopicThreads([topicId])); - return ; -} + return ; +}; + +const TopicPostsList = React.memo(({ topicId }) => { + const postsIds = useSelector(selectTopicThreadsIds([topicId])); + + return ; +}); TopicPostsList.propTypes = { topicId: PropTypes.string.isRequired, }; -function CategoryPostsList({ category }) { +const CategoryPostsList = React.memo(({ category }) => { const { enableInContextSidebar } = useContext(DiscussionContext); const groupedCategory = useSelector(selectCurrentCategoryGrouping)(category); // If grouping at subsection is enabled, only apply it when browsing discussions in context in the learning MFE. const topicIds = useSelector(selectTopicsUnderCategory)(enableInContextSidebar ? groupedCategory : category); - const posts = useSelector(enableInContextSidebar ? selectAllThreads : selectTopicThreads(topicIds)); - return ; -} + const postsIds = useSelector(enableInContextSidebar ? selectAllThreadsIds : selectTopicThreadsIds(topicIds)); + + return ; +}); CategoryPostsList.propTypes = { category: PropTypes.string.isRequired, }; -function PostsView() { +const PostsView = () => { const { topicId, category, @@ -68,15 +73,19 @@ function PostsView() { } }, [topics]); - let postsListComponent; + const handleOnClear = useCallback(() => { + dispatch(setSearchQuery('')); + }, []); - if (topicId) { - postsListComponent = ; - } else if (category) { - postsListComponent = ; - } else { - postsListComponent = ; - } + const postsListComponent = useMemo(() => { + if (topicId) { + return ; + } + if (category) { + return ; + } + return ; + }, [topicId, category]); return (
@@ -85,7 +94,7 @@ function PostsView() { count={resultsFound} text={searchString} loadingStatus={loadingStatus} - onClear={() => dispatch(setSearchQuery(''))} + onClear={handleOnClear} textSearchRewrite={textSearchRewrite} /> )} @@ -96,9 +105,6 @@ function PostsView() {
); -} - -PostsView.propTypes = { }; export default PostsView; diff --git a/src/discussions/posts/data/hooks.js b/src/discussions/posts/data/hooks.js new file mode 100644 index 00000000..8837f9a0 --- /dev/null +++ b/src/discussions/posts/data/hooks.js @@ -0,0 +1,26 @@ +/* eslint-disable import/prefer-default-export */ +import { useMemo } from 'react'; + +import { useSelector } from 'react-redux'; + +import { selectThreadsByIds } from './selectors'; + +export const usePostList = (ids) => { + const posts = useSelector(selectThreadsByIds(ids)); + const pinnedPostsIds = []; + const unpinnedPostsIds = []; + + const sortedIds = useMemo(() => { + posts.forEach((post) => { + if (post.pinned) { + pinnedPostsIds.push(post.id); + } else { + unpinnedPostsIds.push(post.id); + } + }); + + return [...pinnedPostsIds, ...unpinnedPostsIds]; + }, [posts]); + + return sortedIds; +}; diff --git a/src/discussions/posts/data/selectors.js b/src/discussions/posts/data/selectors.js index 9f0fd55a..9693dd12 100644 --- a/src/discussions/posts/data/selectors.js +++ b/src/discussions/posts/data/selectors.js @@ -16,6 +16,15 @@ export const selectTopicThreads = topicIds => createSelector( mapIdsToThreads, ); +export const selectTopicThreadsIds = topicIds => state => ( + (topicIds || []).flatMap(topicId => state.threads.threadsInTopic[topicId] || []) +); + +export const selectThreadsByIds = ids => createSelector( + [selectThreads], + (threads) => mapIdsToThreads(ids, threads), +); + export const selectThread = threadId => createSelector( [selectThreads], (threads) => threads?.[threadId], @@ -37,6 +46,11 @@ export const selectAllThreads = createSelector( (pages, threads) => pages.flatMap(ids => mapIdsToThreads(ids, threads)), ); +export const selectAllThreadsIds = createSelector( + [state => state.threads.pages], + pages => pages.flatMap(ids => ids), +); + export const threadsLoadingStatus = () => state => state.threads.status; export const selectThreadSorting = () => state => state.threads.sortedBy; diff --git a/src/discussions/posts/data/slices.js b/src/discussions/posts/data/slices.js index 754dfa3d..33ce00d4 100644 --- a/src/discussions/posts/data/slices.js +++ b/src/discussions/posts/data/slices.js @@ -15,7 +15,10 @@ const mergeThreadsInTopics = (dataFromState, dataFromPayload) => { const values = Object.values(obj); keys.forEach((key, index) => { if (!acc[key]) { acc[key] = []; } - if (Array.isArray(acc[key])) { acc[key] = acc[key].concat(values[index]); } else { acc[key].push(values[index]); } + if (Array.isArray(acc[key])) { + const uniqueValues = [...new Set(acc[key].concat(values[index]))]; + acc[key] = uniqueValues; + } else { acc[key].push(values[index]); } return acc; }); return acc; @@ -68,29 +71,29 @@ const threadsSlice = createSlice({ state.status = RequestStatus.IN_PROGRESS; }, fetchThreadsSuccess: (state, { payload }) => { - if (state.author !== payload.author) { + const { + author, page, ids, threadsById, isFilterChanged, threadsInTopic, avatars, pagination, textSearchRewrite, + } = payload; + + if (state.author !== author) { state.pages = []; - state.author = payload.author; + state.author = author; } - if (state.pages[payload.page - 1]) { - state.pages[payload.page - 1] = [...state.pages[payload.page - 1], ...payload.ids]; + if (!state.pages[page - 1]) { + state.pages[page - 1] = ids; } else { - state.pages[payload.page - 1] = payload.ids; + state.pages[page - 1] = [...new Set([...state.pages[page - 1], ...ids])]; } state.status = RequestStatus.SUCCESSFUL; - state.threadsById = { ...state.threadsById, ...payload.threadsById }; - // filter - if (payload.isFilterChanged) { - state.threadsInTopic = { ...payload.threadsInTopic }; - } else { - state.threadsInTopic = mergeThreadsInTopics(state.threadsInTopic, payload.threadsInTopic); - } - - state.avatars = { ...state.avatars, ...payload.avatars }; - state.nextPage = (payload.page < payload.pagination.numPages) ? payload.page + 1 : null; - state.totalPages = payload.pagination.numPages; - state.totalThreads = payload.pagination.count; - state.textSearchRewrite = payload.textSearchRewrite; + state.threadsById = { ...state.threadsById, ...threadsById }; + state.threadsInTopic = (isFilterChanged || page === 1) + ? { ...threadsInTopic } + : mergeThreadsInTopics(state.threadsInTopic, threadsInTopic); + state.avatars = { ...state.avatars, ...avatars }; + state.nextPage = (page < pagination.numPages) ? page + 1 : null; + state.totalPages = pagination.numPages; + state.totalThreads = pagination.count; + state.textSearchRewrite = textSearchRewrite; }, fetchThreadsFailed: (state) => { state.status = RequestStatus.FAILED; diff --git a/src/discussions/posts/post-actions-bar/PostActionsBar.jsx b/src/discussions/posts/post-actions-bar/PostActionsBar.jsx index d5dbe05a..7aac9f98 100644 --- a/src/discussions/posts/post-actions-bar/PostActionsBar.jsx +++ b/src/discussions/posts/post-actions-bar/PostActionsBar.jsx @@ -1,9 +1,9 @@ -import React, { useContext } from 'react'; +import React, { useCallback, useContext } from 'react'; import classNames from 'classnames'; import { useDispatch, useSelector } from 'react-redux'; -import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { useIntl } from '@edx/frontend-platform/i18n'; import { Button, Icon, IconButton, } from '@edx/paragon'; @@ -21,18 +21,21 @@ import messages from './messages'; import './actionBar.scss'; -function PostActionsBar({ - intl, -}) { +const PostActionsBar = () => { + const intl = useIntl(); const dispatch = useDispatch(); const loadingStatus = useSelector(selectconfigLoadingStatus); const enableInContext = useSelector(selectEnableInContext); const userCanAddThreadInBlackoutDate = useUserCanAddThreadInBlackoutDate(); const { enableInContextSidebar, page } = useContext(DiscussionContext); - const handleCloseInContext = () => { + const handleCloseInContext = useCallback(() => { postMessageToParent('learning.events.sidebar.close'); - }; + }, []); + + const handleAddPost = useCallback(() => { + dispatch(showPostEditor()); + }, []); return (
@@ -53,7 +56,7 @@ function PostActionsBar({ variant={enableInContextSidebar ? 'plain' : 'brand'} className={classNames('my-0 font-style border-0 line-height-24', { 'px-3 py-10px border-0': enableInContextSidebar })} - onClick={() => dispatch(showPostEditor())} + onClick={handleAddPost} size={enableInContextSidebar ? 'md' : 'sm'} > {intl.formatMessage(messages.addAPost)} @@ -77,10 +80,6 @@ function PostActionsBar({ )}
); -} - -PostActionsBar.propTypes = { - intl: intlShape.isRequired, }; -export default injectIntl(PostActionsBar); +export default PostActionsBar; diff --git a/src/discussions/posts/post-editor/PostEditor.jsx b/src/discussions/posts/post-editor/PostEditor.jsx index b4c67e45..26f97b21 100644 --- a/src/discussions/posts/post-editor/PostEditor.jsx +++ b/src/discussions/posts/post-editor/PostEditor.jsx @@ -1,9 +1,8 @@ import React, { - useContext, useEffect, useRef, + useCallback, useContext, useEffect, useRef, } from 'react'; import PropTypes from 'prop-types'; -import classNames from 'classnames'; import { Formik } from 'formik'; import { isEmpty } from 'lodash'; import { useDispatch, useSelector } from 'react-redux'; @@ -13,7 +12,7 @@ import * as Yup from 'yup'; import { useIntl } from '@edx/frontend-platform/i18n'; import { AppContext } from '@edx/frontend-platform/react'; import { - Button, Card, Form, Spinner, StatefulButton, + Button, Form, Spinner, StatefulButton, } from '@edx/paragon'; import { Help, Post } from '@edx/paragon/icons'; @@ -49,58 +48,22 @@ import { hidePostEditor } from '../data'; import { selectThread } from '../data/selectors'; import { createNewThread, fetchThread, updateExistingThread } from '../data/thunks'; import messages from './messages'; +import PostTypeCard from './PostTypeCard'; -function DiscussionPostType({ - value, - type, - selected, - icon, -}) { - const { enableInContextSidebar } = useContext(DiscussionContext); - // Need to use regular label since Form.Label doesn't support overriding htmlFor - return ( - - ); -} - -DiscussionPostType.propTypes = { - value: PropTypes.string.isRequired, - type: PropTypes.string.isRequired, - selected: PropTypes.bool.isRequired, - icon: PropTypes.element.isRequired, -}; - -function PostEditor({ +const PostEditor = ({ editExisting, -}) { +}) => { const intl = useIntl(); - const { authenticatedUser } = useContext(AppContext); - const dispatch = useDispatch(); - const editorRef = useRef(null); - const [submitting, dispatchSubmit] = useDispatchWithState(); const history = useHistory(); const location = useLocation(); - const commentsPagePath = useCommentsPagePath(); - const { - courseId, - postId, - } = useParams(); + const dispatch = useDispatch(); + const editorRef = useRef(null); + const { courseId, postId } = useParams(); + const { authenticatedUser } = useContext(AppContext); const { category, enableInContextSidebar } = useContext(DiscussionContext); const topicId = useCurrentDiscussionTopic(); + const commentsPagePath = useCommentsPagePath(); + const [submitting, dispatchSubmit] = useDispatchWithState(); const enableInContext = useSelector(selectEnableInContext); const nonCoursewareTopics = useSelector(enableInContext ? inContextNonCourseware : selectNonCoursewareTopics); const nonCoursewareIds = useSelector(enableInContext ? inContextCoursewareIds : selectNonCoursewareIds); @@ -114,6 +77,7 @@ function PostEditor({ const { reasonCodesEnabled, editReasons } = useSelector(selectModerationSettings); const userIsStaff = useSelector(selectUserIsStaff); const archivedTopics = useSelector(selectArchivedTopics); + const postEditorId = `post-editor-${editExisting ? postId : 'new'}`; const canDisplayEditReason = (reasonCodesEnabled && editExisting && (userHasModerationPrivileges || userIsGroupTa || userIsStaff) @@ -124,7 +88,7 @@ function PostEditor({ editReasonCode: Yup.string().required(intl.formatMessage(messages.editReasonCodeError)), }; - const canSelectCohort = (tId) => { + const canSelectCohort = useCallback((tId) => { // If the user isn't privileged, they can't edit the cohort. // If the topic is being edited the cohort can't be changed. if (!userHasModerationPrivileges) { @@ -135,7 +99,7 @@ function PostEditor({ } const isCohorting = settings.alwaysDivideInlineDiscussions || settings.dividedInlineDiscussions.includes(tId); return isCohorting; - }; + }, [nonCoursewareIds, settings, userHasModerationPrivileges]); const initialValues = { postType: post?.type || 'discussion', @@ -145,11 +109,13 @@ function PostEditor({ follow: isEmpty(post?.following) ? true : post?.following, anonymous: allowAnonymous ? false : undefined, anonymousToPeers: allowAnonymousToPeers ? false : undefined, - editReasonCode: post?.lastEdit?.reasonCode || (userIsStaff && canDisplayEditReason ? 'violates-guidelines' : undefined), cohort: post?.cohort || 'default', + editReasonCode: post?.lastEdit?.reasonCode || ( + userIsStaff && canDisplayEditReason ? 'violates-guidelines' : undefined + ), }; - const hideEditor = (resetForm) => { + const hideEditor = useCallback((resetForm) => { resetForm({ values: initialValues }); if (editExisting) { const newLocation = discussionsPath(commentsPagePath, { @@ -162,10 +128,14 @@ function PostEditor({ history.push(newLocation); } dispatch(hidePostEditor()); - }; + }, [postId, topicId, post?.author, category, editExisting, commentsPagePath, location]); + // null stands for no cohort restriction ("All learners" option) - const selectedCohort = (cohort) => (cohort === 'default' ? null : cohort); - const submitForm = async (values, { resetForm }) => { + const selectedCohort = useCallback((cohort) => ( + cohort === 'default' ? null : cohort), + []); + + const submitForm = useCallback(async (values, { resetForm }) => { if (editExisting) { await dispatchSubmit(updateExistingThread(postId, { topicId: values.topic, @@ -195,7 +165,10 @@ function PostEditor({ editorRef.current.plugins.autosave.removeDraft(); } hideEditor(resetForm); - }; + }, [ + allowAnonymous, allowAnonymousToPeers, canSelectCohort, editExisting, + enableInContextSidebar, hideEditor, postId, selectedCohort, topicId, + ]); useEffect(() => { if (userHasModerationPrivileges && isEmpty(cohorts)) { @@ -246,8 +219,6 @@ function PostEditor({ ...editReasonCodeValidation, }); - const postEditorId = `post-editor-${editExisting ? postId : 'new'}`; - const handleInContextSelectLabel = (section, subsection) => ( `${section.displayName} / ${subsection.displayName}` || intl.formatMessage(messages.unnamedTopics) ); @@ -258,66 +229,65 @@ function PostEditor({ initialValues={initialValues} validationSchema={validationSchema} onSubmit={submitForm} - >{ - ({ - values, - errors, - touched, - handleSubmit, - handleBlur, - handleChange, - resetForm, - }) => ( -
-

- {editExisting - ? intl.formatMessage(messages.editPostHeading) - : intl.formatMessage(messages.addPostHeading)} -

- - } - /> - } - /> - -
- - - {nonCoursewareTopics.map(topic => ( - - ))} - {enableInContext ? ( - <> - {coursewareTopics?.map(section => ( + >{({ + values, + errors, + touched, + handleSubmit, + handleBlur, + handleChange, + resetForm, + }) => ( + +

+ {editExisting + ? intl.formatMessage(messages.editPostHeading) + : intl.formatMessage(messages.addPostHeading)} +

+ + } + /> + } + /> + +
+ + + {nonCoursewareTopics.map(topic => ( + + ))} + {enableInContext ? ( + <> + {coursewareTopics?.map(section => ( section?.children?.map(subsection => ( )) + ))} + {(userIsStaff || userIsGroupTa || userHasModerationPrivileges) && ( + + {archivedTopics.map(topic => ( + ))} - {(userIsStaff || userIsGroupTa || userHasModerationPrivileges) && ( - - {archivedTopics.map(topic => ( - - ))} - - )} - - ) : ( - coursewareTopics.map(categoryObj => ( - - {categoryObj.topics.map(subtopic => ( - - ))} - - )) - )} - - - {canSelectCohort(values.topic) && ( - - - - {cohorts.map(cohort => ( - - ))} - - - )} -
- -
- + )} + + ) : ( + coursewareTopics.map(categoryObj => ( + + {categoryObj.topics.map(subtopic => ( + + ))} + + )) + )} + + + {canSelectCohort(values.topic) && ( + + - - - - {canDisplayEditReason && ( - - - - {editReasons.map(({ code, label }) => ( - - ))} - - - - )} -
-
- {intl.formatMessage(messages.cohortVisibilityAllLearners)} + {cohorts.map(cohort => ( + + ))} + + + )} +
+
+ + + + + {canDisplayEditReason && ( + + + + {editReasons.map(({ code, label }) => ( + + ))} + + + + )} +
+
+ { editorRef.current = editor; } } - id={postEditorId} - value={values.comment} - onEditorChange={formikCompatibleHandler(handleChange, 'comment')} - onBlur={formikCompatibleHandler(handleBlur, 'comment')} - /> - -
- - - -
- {!editExisting && ( - <> - - - - {intl.formatMessage(messages.followPost)} - - - - {allowAnonymousToPeers && ( - - - - {intl.formatMessage(messages.anonymousToPeersPost)} - - - - )} - + id={postEditorId} + value={values.comment} + onEditorChange={formikCompatibleHandler(handleChange, 'comment')} + onBlur={formikCompatibleHandler(handleBlur, 'comment')} + /> + +
+ +
+ {!editExisting && ( + <> + + + + {intl.formatMessage(messages.followPost)} + + + + {allowAnonymousToPeers && ( + + + + {intl.formatMessage(messages.anonymousToPeersPost)} + + + )} -
- -
- - -
- - ) - } + + )} +
+
+ + +
+ + )} ); -} +}; PostEditor.propTypes = { editExisting: PropTypes.bool, @@ -510,4 +475,4 @@ PostEditor.defaultProps = { editExisting: false, }; -export default PostEditor; +export default React.memo(PostEditor); diff --git a/src/discussions/posts/post-editor/PostTypeCard.jsx b/src/discussions/posts/post-editor/PostTypeCard.jsx new file mode 100644 index 00000000..b0a28094 --- /dev/null +++ b/src/discussions/posts/post-editor/PostTypeCard.jsx @@ -0,0 +1,44 @@ +import React, { useContext } from 'react'; +import PropTypes from 'prop-types'; + +import classNames from 'classnames'; + +import { Card, Form } from '@edx/paragon'; + +import { DiscussionContext } from '../../common/context'; + +const PostTypeCard = ({ + value, + type, + selected, + icon, +}) => { + const { enableInContextSidebar } = useContext(DiscussionContext); + // Need to use regular label since Form.Label doesn't support overriding htmlFor + return ( + + ); +}; + +PostTypeCard.propTypes = { + value: PropTypes.string.isRequired, + type: PropTypes.string.isRequired, + selected: PropTypes.bool.isRequired, + icon: PropTypes.element.isRequired, +}; + +export default React.memo(PostTypeCard); diff --git a/src/discussions/posts/post-filter-bar/PostFilterBar.jsx b/src/discussions/posts/post-filter-bar/PostFilterBar.jsx index d0cd89e0..3633096e 100644 --- a/src/discussions/posts/post-filter-bar/PostFilterBar.jsx +++ b/src/discussions/posts/post-filter-bar/PostFilterBar.jsx @@ -1,5 +1,5 @@ import React, { - useContext, useEffect, useMemo, useState, + useCallback, useContext, useEffect, useMemo, useState, } from 'react'; import PropTypes from 'prop-types'; @@ -9,7 +9,7 @@ import { useDispatch, useSelector } from 'react-redux'; import { useParams } from 'react-router-dom'; import { sendTrackEvent } from '@edx/frontend-platform/analytics'; -import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { useIntl } from '@edx/frontend-platform/i18n'; import { Collapsible, Form, Icon, Spinner, } from '@edx/paragon'; @@ -29,7 +29,7 @@ import { import { selectThreadFilters, selectThreadSorting } from '../data/selectors'; import messages from './messages'; -export const ActionItem = ({ +export const ActionItem = React.memo(({ id, label, value, @@ -52,7 +52,7 @@ export const ActionItem = ({ {label} -); +)); ActionItem.propTypes = { id: PropTypes.string.isRequired, @@ -61,9 +61,8 @@ ActionItem.propTypes = { selected: PropTypes.string.isRequired, }; -function PostFilterBar({ - intl, -}) { +const PostFilterBar = () => { + const intl = useIntl(); const dispatch = useDispatch(); const { courseId } = useParams(); const { page } = useContext(DiscussionContext); @@ -75,11 +74,13 @@ function PostFilterBar({ const cohorts = useSelector(selectCourseCohorts); const [isOpen, setOpen] = useState(false); - const selectedCohort = useMemo(() => cohorts.find(cohort => ( - toString(cohort.id) === currentFilters.cohort)), - [currentFilters.cohort]); + const selectedCohort = useMemo(() => ( + cohorts.find(cohort => ( + toString(cohort.id) === currentFilters.cohort + )) + ), [cohorts, currentFilters.cohort]); - const handleSortFilterChange = (event) => { + const handleSortFilterChange = useCallback((event) => { const currentType = currentFilters.postType; const currentStatus = currentFilters.status; const { @@ -93,6 +94,7 @@ function PostFilterBar({ cohortFilter: selectedCohort, triggeredBy: name, }; + if (name === 'type') { dispatch(setPostsTypeFilter(value)); if ( @@ -103,6 +105,7 @@ function PostFilterBar({ } filterContentEventProperties.threadTypeFilter = value; } + if (name === 'status') { dispatch(setStatusFilter(value)); if (value === PostsStatusFilter.UNANSWERED && currentType !== ThreadType.QUESTION) { @@ -115,16 +118,23 @@ function PostFilterBar({ } filterContentEventProperties.statusFilter = value; } + if (name === 'sort') { dispatch(setSortedBy(value)); filterContentEventProperties.sortFilter = value; } + if (name === 'cohort') { dispatch(setCohortFilter(value)); filterContentEventProperties.cohortFilter = value; } + sendTrackEvent('edx.forum.filter.content', filterContentEventProperties); - }; + }, [currentFilters, currentSorting, dispatch, selectedCohort]); + + const handleToggle = useCallback(() => { + setOpen(!isOpen); + }, [isOpen]); useEffect(() => { if (userHasModerationPrivileges && isEmpty(cohorts)) { @@ -132,10 +142,48 @@ function PostFilterBar({ } }, [courseId, userHasModerationPrivileges]); + const renderCohortFilter = useMemo(() => ( + userHasModerationPrivileges && ( + <> +
+ {status === RequestStatus.IN_PROGRESS ? ( +
+ +
+ ) : ( +
+ + + {cohorts.map(cohort => ( + + ))} + +
+ )} + + ) + ), [cohorts, currentFilters.cohort, handleSortFilterChange, status, userHasModerationPrivileges]); + return ( setOpen(!isOpen)} + onToggle={handleToggle} className="filter-bar collapsible-card-lg border-0" > @@ -157,7 +205,6 @@ function PostFilterBar({ -
@@ -260,49 +307,11 @@ function PostFilterBar({ />
- {userHasModerationPrivileges && ( - <> -
- {status === RequestStatus.IN_PROGRESS ? ( -
- -
- ) : ( -
- - - {cohorts.map(cohort => ( - - ))} - -
- )} - - )} + {renderCohortFilter} ); -} - -PostFilterBar.propTypes = { - intl: intlShape.isRequired, }; -export default injectIntl(PostFilterBar); +export default React.memo(PostFilterBar); diff --git a/src/discussions/posts/post/ClosePostReasonModal.jsx b/src/discussions/posts/post/ClosePostReasonModal.jsx index 9e287b32..d3678932 100644 --- a/src/discussions/posts/post/ClosePostReasonModal.jsx +++ b/src/discussions/posts/post/ClosePostReasonModal.jsx @@ -1,9 +1,11 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React, { + useCallback, useEffect, useRef, useState, +} from 'react'; import PropTypes from 'prop-types'; import { useSelector } from 'react-redux'; -import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { useIntl } from '@edx/frontend-platform/i18n'; import { ActionRow, Button, @@ -14,24 +16,23 @@ import { import { selectModerationSettings } from '../../data/selectors'; import messages from './messages'; -function ClosePostReasonModal({ - intl, +const ClosePostReasonModal = ({ isOpen, onCancel, onConfirm, -}) { +}) => { + const intl = useIntl(); const scrollTo = useRef(null); const [reasonCode, setReasonCode] = useState(null); - const { postCloseReasons } = useSelector(selectModerationSettings); - const onChange = event => { + const onChange = useCallback(event => { if (event.target.value) { setReasonCode(event.target.value); } else { setReasonCode(null); } - }; + }, []); useEffect(() => { /* istanbul ignore if: This API is not available in the test environment. */ @@ -87,13 +88,12 @@ function ClosePostReasonModal({ ); -} +}; ClosePostReasonModal.propTypes = { - intl: intlShape.isRequired, isOpen: PropTypes.bool.isRequired, onCancel: PropTypes.func.isRequired, onConfirm: PropTypes.func.isRequired, }; -export default injectIntl(ClosePostReasonModal); +export default React.memo(ClosePostReasonModal); diff --git a/src/discussions/posts/post/LikeButton.jsx b/src/discussions/posts/post/LikeButton.jsx index 2702f324..883b0389 100644 --- a/src/discussions/posts/post/LikeButton.jsx +++ b/src/discussions/posts/post/LikeButton.jsx @@ -1,7 +1,7 @@ -import React from 'react'; +import React, { useCallback } from 'react'; import PropTypes from 'prop-types'; -import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { useIntl } from '@edx/frontend-platform/i18n'; import { Icon, IconButton, OverlayTrigger, Tooltip, } from '@edx/paragon'; @@ -9,19 +9,16 @@ import { import { ThumbUpFilled, ThumbUpOutline } from '../../../components/icons'; import messages from './messages'; -function LikeButton({ - count, - intl, - onClick, - voted, -}) { - const handleClick = (e) => { +const LikeButton = ({ count, onClick, voted }) => { + const intl = useIntl(); + + const handleClick = useCallback((e) => { e.preventDefault(); if (onClick) { onClick(); } return false; - }; + }, []); return (
@@ -47,11 +44,10 @@ function LikeButton({
); -} +}; LikeButton.propTypes = { count: PropTypes.number.isRequired, - intl: intlShape.isRequired, onClick: PropTypes.func, voted: PropTypes.bool, }; @@ -61,4 +57,4 @@ LikeButton.defaultProps = { onClick: undefined, }; -export default injectIntl(LikeButton); +export default React.memo(LikeButton); diff --git a/src/discussions/posts/post/Post.jsx b/src/discussions/posts/post/Post.jsx index a4556b09..faff8636 100644 --- a/src/discussions/posts/post/Post.jsx +++ b/src/discussions/posts/post/Post.jsx @@ -2,11 +2,12 @@ import React, { useCallback, useContext, useMemo } from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; +import { toString } from 'lodash'; import { useDispatch, useSelector } from 'react-redux'; import { useHistory, useLocation } from 'react-router-dom'; import { getConfig } from '@edx/frontend-platform'; -import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { useIntl } from '@edx/frontend-platform/i18n'; import { Hyperlink, useToggle } from '@edx/paragon'; import HTMLLoader from '../../../components/HTMLLoader'; @@ -15,102 +16,119 @@ import { selectorForUnitSubsection, selectTopicContext } from '../../../data/sel import { AlertBanner, Confirmation } from '../../common'; import { DiscussionContext } from '../../common/context'; import HoverCard from '../../common/HoverCard'; +import { ContentTypes } from '../../data/constants'; import { selectModerationSettings, selectUserHasModerationPrivileges } from '../../data/selectors'; import { selectTopic } from '../../topics/data/selectors'; +import { selectThread } from '../data/selectors'; import { removeThread, updateExistingThread } from '../data/thunks'; import ClosePostReasonModal from './ClosePostReasonModal'; import messages from './messages'; import PostFooter from './PostFooter'; import PostHeader from './PostHeader'; -import { postShape } from './proptypes'; -function Post({ - post, - intl, - handleAddResponseButton, -}) { +const Post = ({ handleAddResponseButton }) => { + const { enableInContextSidebar, postId } = useContext(DiscussionContext); + const { + topicId, abuseFlagged, closed, pinned, voted, hasEndorsed, following, closedBy, voteCount, groupId, groupName, + closeReason, authorLabel, type: postType, author, title, createdAt, renderedBody, lastEdit, editByLabel, + closedByLabel, + } = useSelector(selectThread(postId)); + const intl = useIntl(); const location = useLocation(); const history = useHistory(); const dispatch = useDispatch(); - const { enableInContextSidebar } = useContext(DiscussionContext); const courseId = useSelector((state) => state.config.id); - const topic = useSelector(selectTopic(post.topicId)); + const topic = useSelector(selectTopic(topicId)); const getTopicSubsection = useSelector(selectorForUnitSubsection); - const topicContext = useSelector(selectTopicContext(post.topicId)); + const topicContext = useSelector(selectTopicContext(topicId)); const { reasonCodesEnabled } = useSelector(selectModerationSettings); const [isDeleting, showDeleteConfirmation, hideDeleteConfirmation] = useToggle(false); const [isReporting, showReportConfirmation, hideReportConfirmation] = useToggle(false); const [isClosing, showClosePostModal, hideClosePostModal] = useToggle(false); const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges); - const displayPostFooter = post.following || post.voteCount || post.closed - || (post.groupId && userHasModerationPrivileges); + const displayPostFooter = following || voteCount || closed || (groupId && userHasModerationPrivileges); - const handleAbusedFlag = useCallback(() => { - if (post.abuseFlagged) { - dispatch(updateExistingThread(post.id, { flagged: !post.abuseFlagged })); - } else { - showReportConfirmation(); - } - }, [dispatch, post.abuseFlagged, post.id, showReportConfirmation]); - - const handleDeleteConfirmation = async () => { - await dispatch(removeThread(post.id)); + const handleDeleteConfirmation = useCallback(async () => { + await dispatch(removeThread(postId)); history.push({ pathname: '.', search: enableInContextSidebar && '?inContextSidebar', }); hideDeleteConfirmation(); - }; + }, [enableInContextSidebar, postId, hideDeleteConfirmation]); - const handleReportConfirmation = () => { - dispatch(updateExistingThread(post.id, { flagged: !post.abuseFlagged })); + const handleReportConfirmation = useCallback(() => { + dispatch(updateExistingThread(postId, { flagged: !abuseFlagged })); hideReportConfirmation(); - }; + }, [abuseFlagged, postId, hideReportConfirmation]); + + const handlePostContentEdit = useCallback(() => history.push({ + ...location, + pathname: `${location.pathname}/edit`, + }), [location.pathname]); + + const handlePostClose = useCallback(() => { + if (closed) { + dispatch(updateExistingThread(postId, { closed: false })); + } else if (reasonCodesEnabled) { + showClosePostModal(); + } else { + dispatch(updateExistingThread(postId, { closed: true })); + } + }, [closed, postId, reasonCodesEnabled, showClosePostModal]); + + const handlePostCopyLink = useCallback(() => navigator.clipboard.writeText( + `${window.location.origin}/${courseId}/posts/${postId}`, + ), [window.location.origin, postId, courseId]); + + const handlePostPin = useCallback(() => dispatch(updateExistingThread( + postId, { pinned: !pinned }, + )), [postId, pinned]); + + const handlePostReport = useCallback(() => { + if (abuseFlagged) { + dispatch(updateExistingThread(postId, { flagged: !abuseFlagged })); + } else { + showReportConfirmation(); + } + }, [abuseFlagged, postId, showReportConfirmation]); const actionHandlers = useMemo(() => ({ - [ContentActions.EDIT_CONTENT]: () => history.push({ - ...location, - pathname: `${location.pathname}/edit`, - }), + [ContentActions.EDIT_CONTENT]: handlePostContentEdit, [ContentActions.DELETE]: showDeleteConfirmation, - [ContentActions.CLOSE]: () => { - if (post.closed) { - dispatch(updateExistingThread(post.id, { closed: false })); - } else if (reasonCodesEnabled) { - showClosePostModal(); - } else { - dispatch(updateExistingThread(post.id, { closed: true })); - } - }, - [ContentActions.COPY_LINK]: () => { navigator.clipboard.writeText(`${window.location.origin}/${courseId}/posts/${post.id}`); }, - [ContentActions.PIN]: () => dispatch(updateExistingThread(post.id, { pinned: !post.pinned })), - [ContentActions.REPORT]: () => handleAbusedFlag(), + [ContentActions.CLOSE]: handlePostClose, + [ContentActions.COPY_LINK]: handlePostCopyLink, + [ContentActions.PIN]: handlePostPin, + [ContentActions.REPORT]: handlePostReport, }), [ - showDeleteConfirmation, - history, - location, - post.closed, - post.id, - post.pinned, - reasonCodesEnabled, - dispatch, - showClosePostModal, - courseId, - handleAbusedFlag, + handlePostClose, handlePostContentEdit, handlePostCopyLink, handlePostPin, handlePostReport, showDeleteConfirmation, ]); - const getTopicCategoryName = topicData => ( - topicData.usageKey ? getTopicSubsection(topicData.usageKey)?.displayName : topicData.categoryId - ); + const handleClosePostConfirmation = useCallback((closeReasonCode) => { + dispatch(updateExistingThread(postId, { closed: true, closeReasonCode })); + hideClosePostModal(); + }, [postId, hideClosePostModal]); - const getTopicInfo = topicData => ( + const handlePostLike = useCallback(() => { + dispatch(updateExistingThread(postId, { voted: !voted })); + }, [postId, voted]); + + const handlePostFollow = useCallback(() => { + dispatch(updateExistingThread(postId, { following: !following })); + }, [postId, following]); + + const getTopicCategoryName = useCallback(topicData => ( + topicData.usageKey ? getTopicSubsection(topicData.usageKey)?.displayName : topicData.categoryId + ), [getTopicSubsection]); + + const getTopicInfo = useCallback(topicData => ( getTopicCategoryName(topicData) ? `${getTopicCategoryName(topicData)} / ${topicData.name}` : `${topicData.name}` - ); + ), [getTopicCategoryName]); return (
@@ -123,7 +141,7 @@ function Post({ closeButtonVaraint="tertiary" confirmButtonText={intl.formatMessage(messages.deleteConfirmationDelete)} /> - {!post.abuseFlagged && ( + {!abuseFlagged && ( )} dispatch(updateExistingThread(post.id, { voted: !post.voted }))} - onFollow={() => dispatch(updateExistingThread(post.id, { following: !post.following }))} - isClosedPost={post.closed} + onLike={handlePostLike} + onFollow={handlePostFollow} + voted={voted} + following={following} + /> + + - -
- +
{(topicContext || topic) && (
- {intl.formatMessage(messages.relatedTo)}{' '} + + {intl.formatMessage(messages.relatedTo)}{' '} + - {(topicContext && !topic) - ? ( - <> - {topicContext.chapterName} - / - {topicContext.verticalName} - / - {topicContext.unitName} - - ) - : getTopicInfo(topic)} + {(topicContext && !topic) ? ( + <> + {topicContext.chapterName} + / + {topicContext.verticalName} + / + {topicContext.unitName} + + ) : ( + getTopicInfo(topic) + )}
)} - {displayPostFooter && } + {displayPostFooter && ( + + )} { - dispatch(updateExistingThread(post.id, { closed: true, closeReasonCode })); - hideClosePostModal(); - }} + onConfirm={handleClosePostConfirmation} />
); -} +}; Post.propTypes = { - intl: intlShape.isRequired, - post: postShape.isRequired, handleAddResponseButton: PropTypes.func.isRequired, }; -export default injectIntl(Post); +export default React.memo(Post); diff --git a/src/discussions/posts/post/PostFooter.jsx b/src/discussions/posts/post/PostFooter.jsx index 13b93263..e57ac8bc 100644 --- a/src/discussions/posts/post/PostFooter.jsx +++ b/src/discussions/posts/post/PostFooter.jsx @@ -1,9 +1,9 @@ -import React from 'react'; +import React, { useCallback } from 'react'; import PropTypes from 'prop-types'; import { useDispatch } from 'react-redux'; -import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { useIntl } from '@edx/frontend-platform/i18n'; import { Icon, IconButton, OverlayTrigger, Tooltip, } from '@edx/paragon'; @@ -13,36 +13,46 @@ import { StarFilled, StarOutline } from '../../../components/icons'; import { updateExistingThread } from '../data/thunks'; import LikeButton from './LikeButton'; import messages from './messages'; -import { postShape } from './proptypes'; -function PostFooter({ - intl, - post, +const PostFooter = ({ + closed, + following, + groupId, + groupName, + id, userHasModerationPrivileges, -}) { + voted, + voteCount, +}) => { const dispatch = useDispatch(); + const intl = useIntl(); + + const handlePostLike = useCallback(() => { + dispatch(updateExistingThread(id, { voted: !voted })); + }, [id, voted]); + return (
- {post.voteCount !== 0 && ( + {voteCount !== 0 && ( dispatch(updateExistingThread(post.id, { voted: !post.voted }))} - voted={post.voted} + count={voteCount} + onClick={handlePostLike} + voted={voted} /> )} - {post.following && ( + {following && ( - {intl.formatMessage(post.following ? messages.unFollow : messages.follow)} + + {intl.formatMessage(following ? messages.unFollow : messages.follow)} )} > { e.preventDefault(); - dispatch(updateExistingThread(post.id, { following: !post.following })); + dispatch(updateExistingThread(id, { following: !following })); return true; }} iconAs={Icon} @@ -53,10 +63,10 @@ function PostFooter({ )}
- {post.groupId && userHasModerationPrivileges && ( + {groupId && userHasModerationPrivileges && ( {post.groupName} + {groupName} )} > @@ -71,36 +81,43 @@ function PostFooter({ )} - - {post.closed - && ( - - {intl.formatMessage(messages.postClosed)} - - )} - > - - - )} + {closed && ( + + {intl.formatMessage(messages.postClosed)} + + )} + > + + + )}
); -} +}; PostFooter.propTypes = { - intl: intlShape.isRequired, - post: postShape.isRequired, + voteCount: PropTypes.number.isRequired, + voted: PropTypes.bool.isRequired, + following: PropTypes.bool.isRequired, + id: PropTypes.string.isRequired, + groupId: PropTypes.string, + groupName: PropTypes.string, + closed: PropTypes.bool.isRequired, userHasModerationPrivileges: PropTypes.bool.isRequired, }; -export default injectIntl(PostFooter); +PostFooter.defaultProps = { + groupId: null, + groupName: null, +}; + +export default React.memo(PostFooter); diff --git a/src/discussions/posts/post/PostHeader.jsx b/src/discussions/posts/post/PostHeader.jsx index dea83486..5708dc51 100644 --- a/src/discussions/posts/post/PostHeader.jsx +++ b/src/discussions/posts/post/PostHeader.jsx @@ -3,7 +3,7 @@ import PropTypes from 'prop-types'; import classNames from 'classnames'; -import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { useIntl } from '@edx/frontend-platform/i18n'; import { Avatar, Badge, Icon } from '@edx/paragon'; import { Issue, Question } from '../../../components/icons'; @@ -11,36 +11,35 @@ import { AvatarOutlineAndLabelColors, ThreadType } from '../../../data/constants import { AuthorLabel } from '../../common'; import { useAlertBannerVisible } from '../../data/hooks'; import messages from './messages'; -import { postShape } from './proptypes'; -export function PostAvatar({ - post, authorLabel, fromPostLink, read, -}) { +export const PostAvatar = React.memo(({ + author, postType, authorLabel, fromPostLink, read, +}) => { const outlineColor = AvatarOutlineAndLabelColors[authorLabel]; const avatarSize = useMemo(() => { let size = '2rem'; - if (post.type === ThreadType.DISCUSSION && !fromPostLink) { + if (postType === ThreadType.DISCUSSION && !fromPostLink) { size = '2rem'; - } else if (post.type === ThreadType.QUESTION) { + } else if (postType === ThreadType.QUESTION) { size = '1.5rem'; } return size; - }, [post.type]); + }, [postType]); const avatarSpacing = useMemo(() => { let spacing = 'mr-3 '; - if (post.type === ThreadType.DISCUSSION && fromPostLink) { + if (postType === ThreadType.DISCUSSION && fromPostLink) { spacing += 'pt-2 ml-0.5'; - } else if (post.type === ThreadType.DISCUSSION) { + } else if (postType === ThreadType.DISCUSSION) { spacing += 'ml-0.5 mt-0.5'; } return spacing; - }, [post.type]); + }, [postType]); return (
- {post.type === ThreadType.QUESTION && ( + {postType === ThreadType.QUESTION && (
); -} +}); PostAvatar.propTypes = { - post: postShape.isRequired, + author: PropTypes.string.isRequired, + postType: PropTypes.string.isRequired, authorLabel: PropTypes.string, fromPostLink: PropTypes.bool, read: PropTypes.bool, @@ -78,65 +78,86 @@ PostAvatar.defaultProps = { read: false, }; -function PostHeader({ - intl, - post, +const PostHeader = ({ + abuseFlagged, + author, + authorLabel, + closed, + createdAt, + hasEndorsed, + lastEdit, + title, + postType, preview, -}) { - const showAnsweredBadge = preview && post.hasEndorsed && post.type === ThreadType.QUESTION; - const authorLabelColor = AvatarOutlineAndLabelColors[post.authorLabel]; - const hasAnyAlert = useAlertBannerVisible(post); +}) => { + const intl = useIntl(); + const showAnsweredBadge = preview && hasEndorsed && postType === ThreadType.QUESTION; + const authorLabelColor = AvatarOutlineAndLabelColors[authorLabel]; + const hasAnyAlert = useAlertBannerVisible({ + author, abuseFlagged, lastEdit, closed, + }); return (
- +
- {preview - ? ( -
-
- {post.title} -
- {showAnsweredBadge - && {intl.formatMessage(messages.answered)}} + {preview ? ( +
+
+ {title}
- ) - : ( -
- {post.title} -
- )} + {showAnsweredBadge + && {intl.formatMessage(messages.answered)}} +
+ ) : ( +
+ {title} +
+ )}
); -} +}; PostHeader.propTypes = { - intl: intlShape.isRequired, - post: postShape.isRequired, preview: PropTypes.bool, + hasEndorsed: PropTypes.bool.isRequired, + postType: PropTypes.string.isRequired, + authorLabel: PropTypes.string, + author: PropTypes.string.isRequired, + title: PropTypes.string.isRequired, + createdAt: PropTypes.string.isRequired, + abuseFlagged: PropTypes.bool, + lastEdit: PropTypes.shape({ + reason: PropTypes.string, + }), + closed: PropTypes.bool, }; PostHeader.defaultProps = { + authorLabel: null, preview: false, + abuseFlagged: false, + lastEdit: {}, + closed: false, }; -export default injectIntl(PostHeader); +export default React.memo(PostHeader); diff --git a/src/discussions/posts/post/PostLink.jsx b/src/discussions/posts/post/PostLink.jsx index 53248aa6..068145b3 100644 --- a/src/discussions/posts/post/PostLink.jsx +++ b/src/discussions/posts/post/PostLink.jsx @@ -1,8 +1,9 @@ /* eslint-disable react/no-unknown-property */ -import React, { useContext } from 'react'; +import React, { useContext, useMemo } from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; +import { useSelector } from 'react-redux'; import { Link } from 'react-router-dom'; import { useIntl } from '@edx/frontend-platform/i18n'; @@ -14,37 +15,45 @@ import { AvatarOutlineAndLabelColors, Routes, ThreadType } from '../../../data/c import AuthorLabel from '../../common/AuthorLabel'; import { DiscussionContext } from '../../common/context'; import { discussionsPath, isPostPreviewAvailable } from '../../utils'; +import { selectThread } from '../data/selectors'; import messages from './messages'; import { PostAvatar } from './PostHeader'; import PostSummaryFooter from './PostSummaryFooter'; -import { postShape } from './proptypes'; -function PostLink({ - post, - isSelected, - showDivider, +const PostLink = ({ idx, -}) { + postId, + showDivider, +}) => { const intl = useIntl(); const { + courseId, + postId: selectedPostId, page, - postId, enableInContextSidebar, category, learnerUsername, } = useContext(DiscussionContext); + const { + topicId, hasEndorsed, type, author, authorLabel, abuseFlagged, abuseFlaggedCount, read, commentCount, + unreadCommentCount, id, pinned, previewBody, title, voted, voteCount, following, groupId, groupName, createdAt, + } = useSelector(selectThread(postId)); const linkUrl = discussionsPath(Routes.COMMENTS.PAGES[page], { 0: enableInContextSidebar ? 'in-context' : undefined, - courseId: post.courseId, - topicId: post.topicId, - postId: post.id, + courseId, + topicId, + postId, category, learnerUsername, }); - const showAnsweredBadge = post.hasEndorsed && post.type === ThreadType.QUESTION; - const authorLabelColor = AvatarOutlineAndLabelColors[post.authorLabel]; - const canSeeReportedBadge = post.abuseFlagged || post.abuseFlaggedCount; - const read = post.read || (!post.read && post.commentCount !== post.unreadCommentCount); + const showAnsweredBadge = hasEndorsed && type === ThreadType.QUESTION; + const authorLabelColor = AvatarOutlineAndLabelColors[authorLabel]; + const canSeeReportedBadge = abuseFlagged || abuseFlaggedCount; + const isPostRead = read || (!read && commentCount !== unreadCommentCount); + + const checkIsSelected = useMemo(() => ( + window.location.pathname.includes(postId)), + [window.location.pathname]); return ( <> @@ -55,19 +64,24 @@ function PostLink({ }) } to={linkUrl} - onClick={() => isSelected(post.id)} - aria-current={isSelected(post.id) ? 'page' : undefined} + aria-current={checkIsSelected ? 'page' : undefined} role="option" - tabIndex={(isSelected(post.id) || idx === 0) ? 0 : -1} + tabIndex={(checkIsSelected || idx === 0) ? 0 : -1} >
- +
@@ -78,14 +92,14 @@ function PostLink({ { 'font-weight-bolder': !read }) } > - {post.title} + {title} - {isPostPreviewAvailable(post.previewBody) - ? post.previewBody + {isPostPreviewAvailable(previewBody) + ? previewBody : intl.formatMessage(messages.postWithoutPreview)} @@ -94,7 +108,6 @@ function PostLink({ {' '}answered )} - {canSeeReportedBadge && ( {' '}reported )} - - {post.pinned && ( + {pinned && (
- +
- {!showDivider && post.pinned &&
} + {!showDivider && pinned &&
} ); -} +}; PostLink.propTypes = { - post: postShape.isRequired, - isSelected: PropTypes.func.isRequired, - showDivider: PropTypes.bool, idx: PropTypes.number, + postId: PropTypes.string.isRequired, + showDivider: PropTypes.bool, }; PostLink.defaultProps = { - showDivider: true, idx: -1, + showDivider: true, }; -export default PostLink; +export default React.memo(PostLink); diff --git a/src/discussions/posts/post/PostSummaryFooter.jsx b/src/discussions/posts/post/PostSummaryFooter.jsx index f7459572..1c288add 100644 --- a/src/discussions/posts/post/PostSummaryFooter.jsx +++ b/src/discussions/posts/post/PostSummaryFooter.jsx @@ -4,7 +4,7 @@ import PropTypes from 'prop-types'; import { useSelector } from 'react-redux'; import * as timeago from 'timeago.js'; -import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { useIntl } from '@edx/frontend-platform/i18n'; import { Badge, Icon, OverlayTrigger, Tooltip, } from '@edx/paragon'; @@ -16,78 +16,86 @@ import { People, QuestionAnswer, QuestionAnswerOutline } from '../../../componen import timeLocale from '../../common/time-locale'; import { selectUserHasModerationPrivileges } from '../../data/selectors'; import messages from './messages'; -import { postShape } from './proptypes'; -function PostSummaryFooter({ - post, - intl, +const PostSummaryFooter = ({ + postId, + voted, + voteCount, + following, + commentCount, + unreadCommentCount, + groupId, + groupName, + createdAt, preview, showNewCountLabel, -}) { - const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges); +}) => { timeago.register('time-locale', timeLocale); + const intl = useIntl(); + const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges); + return (
- {intl.formatMessage(post.voted ? messages.likedPost : messages.postLikes)} + + {intl.formatMessage(voted ? messages.likedPost : messages.postLikes)} )} > - - {' '}{intl.formatMessage(post.voted ? messages.likedPost : messages.postLikes)} + + {' '}{intl.formatMessage(voted ? messages.likedPost : messages.postLikes)}
- {(post.voteCount && post.voteCount > 0) ? post.voteCount : null} + {(voteCount && voteCount > 0) ? voteCount : null}
- {intl.formatMessage(post.following ? messages.followed : messages.notFollowed)} + + {intl.formatMessage(following ? messages.followed : messages.notFollowed)} )} > - + - {' '}{intl.formatMessage(post.following ? messages.srOnlyFollowDescription : messages.srOnlyUnFollowDescription)} + {' '}{intl.formatMessage(following ? messages.srOnlyFollowDescription : messages.srOnlyUnFollowDescription)} - {preview && post.commentCount > 1 && ( + {preview && commentCount > 1 && (
+ {intl.formatMessage(messages.activity)} )} > {' '} {intl.formatMessage(messages.activity)} - {post.commentCount} + {commentCount}
)} - {showNewCountLabel && preview && post?.unreadCommentCount > 0 && post.commentCount > 1 && ( + {showNewCountLabel && preview && unreadCommentCount > 0 && commentCount > 1 && ( - {intl.formatMessage(messages.newLabel, { count: post.unreadCommentCount })} + {intl.formatMessage(messages.newLabel, { count: unreadCommentCount })} )}
- {post.groupId && userHasModerationPrivileges && ( + {groupId && userHasModerationPrivileges && ( {post.groupName} + {groupName} )} > @@ -98,17 +106,24 @@ function PostSummaryFooter({ )} - - {timeago.format(post.createdAt, 'time-locale')} + + {timeago.format(createdAt, 'time-locale')}
); -} +}; PostSummaryFooter.propTypes = { - intl: intlShape.isRequired, - post: postShape.isRequired, + postId: PropTypes.string.isRequired, + voted: PropTypes.bool.isRequired, + voteCount: PropTypes.number.isRequired, + following: PropTypes.bool.isRequired, + commentCount: PropTypes.number.isRequired, + unreadCommentCount: PropTypes.number.isRequired, + groupId: PropTypes.number, + groupName: PropTypes.string, + createdAt: PropTypes.string.isRequired, preview: PropTypes.bool, showNewCountLabel: PropTypes.bool, }; @@ -116,6 +131,8 @@ PostSummaryFooter.propTypes = { PostSummaryFooter.defaultProps = { preview: false, showNewCountLabel: false, + groupId: null, + groupName: null, }; -export default injectIntl(PostSummaryFooter); +export default React.memo(PostSummaryFooter); diff --git a/src/discussions/topics/TopicsView.jsx b/src/discussions/topics/TopicsView.jsx index fd0fbe62..61cabebb 100644 --- a/src/discussions/topics/TopicsView.jsx +++ b/src/discussions/topics/TopicsView.jsx @@ -1,4 +1,6 @@ -import React, { useContext, useEffect } from 'react'; +import React, { + useCallback, useContext, useEffect, useMemo, +} from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { useParams } from 'react-router'; @@ -16,49 +18,57 @@ import LegacyTopicGroup from './topic-group/LegacyTopicGroup'; import Topic from './topic-group/topic/Topic'; import countFilteredTopics from './utils'; -function CourseWideTopics() { +const CourseWideTopics = () => { const { category } = useParams(); const filter = useSelector(selectTopicFilter); const nonCoursewareTopics = useSelector(selectNonCoursewareTopics); - const filteredNonCoursewareTopics = nonCoursewareTopics.filter(item => (filter - ? item.name.toLowerCase().includes(filter) - : true - )); + + const filteredNonCoursewareTopics = useMemo(() => ( + nonCoursewareTopics.filter(item => ( + filter ? item.name.toLowerCase().includes(filter) : true + ))), [nonCoursewareTopics, filter]); return (nonCoursewareTopics && category === undefined) && filteredNonCoursewareTopics.map((topic, index) => ( )); -} +}; -function LegacyCoursewareTopics() { +const LegacyCoursewareTopics = () => { const { category } = useParams(); - const categories = useSelector(selectCategories) - .filter(cat => (category ? cat === category : true)); - return categories?.map( - topicGroup => ( + const categories = useSelector(selectCategories); + + const filteredCategories = useMemo(() => ( + categories.filter(cat => (category ? cat === category : true)) + ), [categories, category]); + + return filteredCategories?.map( + categoryId => ( ), ); -} +}; -function TopicsView() { +const TopicsView = () => { + const dispatch = useDispatch(); const provider = useSelector(selectDiscussionProvider); const topicFilter = useSelector(selectTopicFilter); const topicsSelector = useSelector(({ topics }) => topics); const filteredTopicsCount = useSelector(({ topics }) => topics.results.count); const loadingStatus = useSelector(({ topics }) => topics.status); const { courseId } = useContext(DiscussionContext); - const dispatch = useDispatch(); + + const handleOnClear = useCallback(() => { + dispatch(setFilter('')); + }, []); useEffect(() => { // Don't load till the provider information is available @@ -79,7 +89,7 @@ function TopicsView() { text={topicFilter} count={filteredTopicsCount} loadingStatus={loadingStatus} - onClear={() => dispatch(setFilter(''))} + onClear={handleOnClear} /> )}
handleKeyDown(e)}> @@ -94,8 +104,6 @@ function TopicsView() { }
); -} - -TopicsView.propTypes = {}; +}; export default TopicsView; diff --git a/src/discussions/topics/data/selectors.js b/src/discussions/topics/data/selectors.js index 58ea1ce0..3093ccde 100644 --- a/src/discussions/topics/data/selectors.js +++ b/src/discussions/topics/data/selectors.js @@ -13,6 +13,10 @@ export const selectTopicsInCategory = (categoryId) => state => ( state.topics.topicsInCategory[categoryId]?.map(id => state.topics.topics[id]) || [] ); +export const selectTopicsInCategoryIds = (categoryId) => state => ( + state.topics.topicsInCategory[categoryId] || [] +); + export const selectTopics = state => state.topics.topics; export const selectCoursewareTopics = createSelector( selectCategories, diff --git a/src/discussions/topics/topic-group/LegacyTopicGroup.jsx b/src/discussions/topics/topic-group/LegacyTopicGroup.jsx index bab0774d..e94da24e 100644 --- a/src/discussions/topics/topic-group/LegacyTopicGroup.jsx +++ b/src/discussions/topics/topic-group/LegacyTopicGroup.jsx @@ -3,27 +3,23 @@ import PropTypes from 'prop-types'; import { useSelector } from 'react-redux'; -import { selectTopicsInCategory } from '../data/selectors'; +import { selectTopicsInCategoryIds } from '../data/selectors'; import TopicGroupBase from './TopicGroupBase'; -function LegacyTopicGroup({ - id, - category, -}) { - const topics = useSelector(selectTopicsInCategory(category)); +const LegacyTopicGroup = ({ categoryId }) => { + const topicsIds = useSelector(selectTopicsInCategoryIds(categoryId)); + return ( - + ); -} +}; LegacyTopicGroup.propTypes = { - id: PropTypes.string, - category: PropTypes.string, + categoryId: PropTypes.string, }; LegacyTopicGroup.defaultProps = { - id: null, - category: null, + categoryId: null, }; -export default LegacyTopicGroup; +export default React.memo(LegacyTopicGroup); diff --git a/src/discussions/topics/topic-group/TopicGroupBase.jsx b/src/discussions/topics/topic-group/TopicGroupBase.jsx index 337dcad6..b36df877 100644 --- a/src/discussions/topics/topic-group/TopicGroupBase.jsx +++ b/src/discussions/topics/topic-group/TopicGroupBase.jsx @@ -1,43 +1,63 @@ -import React, { useContext } from 'react'; +import React, { useContext, useMemo } from 'react'; import PropTypes from 'prop-types'; import { useSelector } from 'react-redux'; import { Link } from 'react-router-dom'; -import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { useIntl } from '@edx/frontend-platform/i18n'; import { Routes } from '../../../data/constants'; import { DiscussionContext } from '../../common/context'; import { discussionsPath } from '../../utils'; -import { selectTopicFilter } from '../data/selectors'; +import { selectTopicFilter, selectTopicsById } from '../data/selectors'; import messages from '../messages'; -import Topic, { topicShape } from './topic/Topic'; +import Topic from './topic/Topic'; -function TopicGroupBase({ +const TopicGroupBase = ({ groupId, groupTitle, linkToGroup, - topics, - intl, -}) { + topicsIds, +}) => { + const intl = useIntl(); const { courseId } = useContext(DiscussionContext); const filter = useSelector(selectTopicFilter); + const topics = useSelector(selectTopicsById(topicsIds)); const hasTopics = topics.length > 0; - const matchesFilter = filter - ? groupTitle?.toLowerCase().includes(filter) - : true; - const filteredTopicElements = topics.filter( - topic => (filter - ? (topic.name.toLowerCase().includes(filter) || matchesFilter) - : true - ), - ); + const matchesFilter = useMemo(() => ( + filter ? groupTitle?.toLowerCase().includes(filter) : true + ), [filter, groupTitle]); + + const filteredTopicElements = useMemo(() => ( + topics.filter(topic => ( + filter ? (topic.name.toLowerCase().includes(filter) || matchesFilter) : true + )) + ), [topics, filter, matchesFilter]); const hasFilteredSubtopics = (filteredTopicElements.length > 0); + + const renderFilteredTopics = useMemo(() => { + if (!hasFilteredSubtopics) { + return <>; + } + + return ( + filteredTopicElements.map((topic, index) => ( + + )) + ); + }, [filteredTopicElements]); + if (!hasTopics || (!matchesFilter && !hasFilteredSubtopics)) { return null; } + return (
- {linkToGroup && groupId - ? ( - - {groupTitle} - - ) : ( - groupTitle || intl.formatMessage(messages.unnamedTopicCategories) - )} + {linkToGroup && groupId ? ( + + {groupTitle} + + ) : ( + groupTitle || intl.formatMessage(messages.unnamedTopicCategories) + )}
- {filteredTopicElements.map((topic, index) => ( - - ))} + {renderFilteredTopics}
); -} +}; TopicGroupBase.propTypes = { groupId: PropTypes.string.isRequired, groupTitle: PropTypes.string.isRequired, - topics: PropTypes.arrayOf(topicShape).isRequired, + topicsIds: PropTypes.arrayOf(PropTypes.string).isRequired, linkToGroup: PropTypes.bool, - intl: intlShape.isRequired, }; TopicGroupBase.defaultProps = { linkToGroup: true, }; -export default injectIntl(TopicGroupBase); +export default React.memo(TopicGroupBase); diff --git a/src/discussions/topics/topic-group/topic/Topic.jsx b/src/discussions/topics/topic-group/topic/Topic.jsx index 47a1d246..af5ee705 100644 --- a/src/discussions/topics/topic-group/topic/Topic.jsx +++ b/src/discussions/topics/topic-group/topic/Topic.jsx @@ -1,5 +1,5 @@ /* eslint-disable no-unused-vars, react/forbid-prop-types */ -import React from 'react'; +import React, { useCallback } from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; @@ -7,31 +7,31 @@ import { useSelector } from 'react-redux'; import { useParams } from 'react-router'; import { Link } from 'react-router-dom'; -import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { useIntl } from '@edx/frontend-platform/i18n'; import { Icon, OverlayTrigger, Tooltip } from '@edx/paragon'; import { HelpOutline, PostOutline, Report } from '@edx/paragon/icons'; import { Routes } from '../../../../data/constants'; import { selectUserHasModerationPrivileges, selectUserIsGroupTa } from '../../../data/selectors'; import { discussionsPath } from '../../../utils'; +import { selectTopic } from '../../data/selectors'; import messages from '../../messages'; -function Topic({ - topic, - showDivider, - index, - intl, -}) { +const Topic = ({ topicId, showDivider, index }) => { + const intl = useIntl(); const { courseId } = useParams(); - const topicUrl = discussionsPath(Routes.TOPICS.TOPIC, { - courseId, - topicId: topic.id, - }); + const topic = useSelector(selectTopic(topicId)); + const { + id, inactiveFlags, activeFlags, name, threadCounts, + } = topic; const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges); const userIsGroupTa = useSelector(selectUserIsGroupTa); - const { inactiveFlags, activeFlags } = topic; const canSeeReportedStats = (activeFlags || inactiveFlags) && (userHasModerationPrivileges || userIsGroupTa); - const isSelected = (id) => window.location.pathname.includes(id); + const topicUrl = discussionsPath(Routes.TOPICS.TOPIC, { courseId, topicId }); + + const isSelected = useCallback((selectedId) => ( + window.location.pathname.includes(selectedId) + ), []); return ( isSelected(topic.id)} - aria-current={isSelected(topic.id) ? 'page' : undefined} + onClick={() => isSelected(id)} + aria-current={isSelected(id) ? 'page' : undefined} role="option" - tabIndex={(isSelected(topic.id) || index === 0) ? 0 : -1} + tabIndex={(isSelected(id) || index === 0) ? 0 : -1} >
- {topic.name || intl.formatMessage(messages.unnamedTopicSubCategories)} + {name || intl.formatMessage(messages.unnamedTopicSubCategories)}
@@ -61,7 +61,7 @@ function Topic({
{intl.formatMessage(messages.discussions, { - count: topic.threadCounts?.discussion || 0, + count: threadCounts?.discussion || 0, })}
@@ -69,7 +69,7 @@ function Topic({ >
- {topic.threadCounts?.discussion || 0} + {threadCounts?.discussion || 0}
{intl.formatMessage(messages.questions, { - count: topic.threadCounts?.question || 0, + count: threadCounts?.question || 0, })}
@@ -86,7 +86,7 @@ function Topic({ >
- {topic.threadCounts?.question || 0} + {threadCounts?.question || 0}
{Boolean(canSeeReportedStats) && ( @@ -121,7 +121,7 @@ function Topic({ {!showDivider &&
} ); -} +}; export const topicShape = PropTypes.shape({ name: PropTypes.string, @@ -130,9 +130,9 @@ export const topicShape = PropTypes.shape({ discussions: PropTypes.number, flags: PropTypes.number, }); + Topic.propTypes = { - intl: intlShape.isRequired, - topic: topicShape.isRequired, + topicId: PropTypes.string.isRequired, showDivider: PropTypes.bool, index: PropTypes.number, }; @@ -142,4 +142,4 @@ Topic.defaultProps = { index: -1, }; -export default injectIntl(Topic); +export default React.memo(Topic); diff --git a/src/discussions/tours/DiscussionsProductTour.jsx b/src/discussions/tours/DiscussionsProductTour.jsx index bd236285..804e39ac 100644 --- a/src/discussions/tours/DiscussionsProductTour.jsx +++ b/src/discussions/tours/DiscussionsProductTour.jsx @@ -1,17 +1,17 @@ -import { useEffect } from 'react'; +import React, { useEffect } from 'react'; import isEmpty from 'lodash/isEmpty'; import { useDispatch } from 'react-redux'; -import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { ProductTour } from '@edx/paragon'; import { useTourConfiguration } from '../data/hooks'; import { fetchDiscussionTours } from './data/thunks'; -function DiscussionsProductTour({ intl }) { +const DiscussionsProductTour = () => { const dispatch = useDispatch(); - const config = useTourConfiguration(intl); + const config = useTourConfiguration(); + useEffect(() => { dispatch(fetchDiscussionTours()); }, []); @@ -25,10 +25,6 @@ function DiscussionsProductTour({ intl }) { )} ); -} - -DiscussionsProductTour.propTypes = { - intl: intlShape.isRequired, }; -export default injectIntl(DiscussionsProductTour); +export default DiscussionsProductTour; diff --git a/src/discussions/utils.js b/src/discussions/utils.js index 2c724fbd..aa40f2b8 100644 --- a/src/discussions/utils.js +++ b/src/discussions/utils.js @@ -1,6 +1,8 @@ -/* eslint-disable import/prefer-default-export */ +import { useCallback, useContext, useMemo } from 'react'; + import { getIn } from 'formik'; import { uniqBy } from 'lodash'; +import { useSelector } from 'react-redux'; import { generatePath, useRouteMatch } from 'react-router'; import { getConfig } from '@edx/frontend-platform'; @@ -10,6 +12,8 @@ import { import { InsertLink } from '../components/icons'; import { ContentActions, Routes, ThreadType } from '../data/constants'; +import { ContentSelectors } from './data/constants'; +import { PostCommentsContext } from './post-comments/postCommentsContext'; import messages from './messages'; /** @@ -175,20 +179,26 @@ export const ACTIONS_LIST = [ }, ]; -export function useActions(content) { - const checkConditions = (item, conditions) => ( +export function useActions(contentType, id) { + const postType = useContext(PostCommentsContext); + const content = { ...useSelector(ContentSelectors[contentType](id)), postType }; + + const checkConditions = useCallback((item, conditions) => ( conditions ? Object.keys(conditions) .map(key => item[key] === conditions[key]) .every(condition => condition === true) : true - ); - return ACTIONS_LIST.filter( + ), []); + + const actions = useMemo(() => ACTIONS_LIST.filter( ({ action, conditions = null, }) => checkPermissions(content, action) && checkConditions(content, conditions), - ); + ), [content]); + + return actions; } export const formikCompatibleHandler = (formikHandler, name) => (value) => formikHandler({ diff --git a/src/index.scss b/src/index.scss index 2c05ee21..4ec1bfd5 100755 --- a/src/index.scss +++ b/src/index.scss @@ -178,18 +178,18 @@ $fa-font-path: "~font-awesome/fonts"; background-color: unset !important; } -.learner>a:hover { +.learner > a:hover { background-color: #F2F0EF; } .py-10px { - padding-top: 10px; - padding-bottom: 10px; + padding-top: 10px !important; + padding-bottom: 10px !important; } .py-8px { - padding-top: 8px; - padding-bottom: 8px; + padding-top: 8px !important; + padding-bottom: 8px !important; } .pb-10px { @@ -530,3 +530,10 @@ header { position: relative; background-color: #fff; } + +.spinner-container { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); +}