diff --git a/package-lock.json b/package-lock.json index cdbf32d2..01ce8c43 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "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", @@ -22326,6 +22327,19 @@ "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", @@ -43835,6 +43849,14 @@ "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 b5ca161d..8300ea7d 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 ad17305a..0f61f4ff 100644 --- a/src/components/FormikErrorFeedback.jsx +++ b/src/components/FormikErrorFeedback.jsx @@ -5,26 +5,31 @@ import { getIn, useFormikContext } from 'formik'; import { Form, TransitionReplace } from '@edx/paragon'; -const FormikErrorFeedback = ({ name }) => { - const { touched, errors } = useFormikContext(); +function 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 React.memo(FormikErrorFeedback); +export default FormikErrorFeedback; diff --git a/src/components/HTMLLoader.jsx b/src/components/HTMLLoader.jsx index d5beb12b..c8cd38df 100644 --- a/src/components/HTMLLoader.jsx +++ b/src/components/HTMLLoader.jsx @@ -12,9 +12,9 @@ const defaultSanitizeOptions = { ADD_ATTR: ['columnalign'], }; -const HTMLLoader = ({ +function 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 @@ const HTMLLoader = ({ return (
); -}; +} HTMLLoader.propTypes = { htmlNode: PropTypes.node, @@ -63,4 +63,4 @@ HTMLLoader.defaultProps = { delay: 0, }; -export default React.memo(HTMLLoader); +export default HTMLLoader; diff --git a/src/components/NavigationBar/CourseTabsNavigation.jsx b/src/components/NavigationBar/CourseTabsNavigation.jsx index 6a1c6fa3..026bd8fc 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 { useIntl } from '@edx/frontend-platform/i18n'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { fetchTab } from './data/thunks'; import Tabs from './tabs/Tabs'; @@ -12,13 +12,12 @@ import messages from './messages'; import './navBar.scss'; -const CourseTabsNavigation = ({ - activeTab, className, courseId, rootSlug, -}) => { +function CourseTabsNavigation({ + activeTab, className, intl, courseId, rootSlug, +}) { const dispatch = useDispatch(); - const intl = useIntl(); - const tabs = useSelector(state => state.courseTabs.tabs); + const tabs = useSelector(state => state.courseTabs.tabs); useEffect(() => { dispatch(fetchTab(courseId, rootSlug)); }, [courseId]); @@ -26,7 +25,8 @@ const 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 = { @@ -60,4 +61,4 @@ CourseTabsNavigation.defaultProps = { rootSlug: 'outline', }; -export default React.memo(CourseTabsNavigation); +export default injectIntl(CourseTabsNavigation); diff --git a/src/components/PostPreviewPanel.jsx b/src/components/PostPreviewPanel.jsx index 4aef8dc8..85f18f73 100644 --- a/src/components/PostPreviewPanel.jsx +++ b/src/components/PostPreviewPanel.jsx @@ -1,17 +1,16 @@ import React, { useState } from 'react'; import PropTypes from 'prop-types'; -import { useIntl } from '@edx/frontend-platform/i18n'; +import { injectIntl, intlShape } 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'; -const PostPreviewPanel = ({ - htmlNode, isPost, editExisting, -}) => { - const intl = useIntl(); +function PostPreviewPanel({ + htmlNode, intl, isPost, editExisting, +}) { const [showPreviewPane, setShowPreviewPane] = useState(false); return ( @@ -31,15 +30,13 @@ const PostPreviewPanel = ({ iconClassNames="icon-size" data-testid="hide-preview-button" /> - {htmlNode && ( - - )} +
)}
@@ -58,18 +55,18 @@ const PostPreviewPanel = ({
); -}; +} PostPreviewPanel.propTypes = { - htmlNode: PropTypes.node, + intl: intlShape.isRequired, + htmlNode: PropTypes.node.isRequired, isPost: PropTypes.bool, editExisting: PropTypes.bool, }; PostPreviewPanel.defaultProps = { - htmlNode: '', isPost: false, editExisting: false, }; -export default React.memo(PostPreviewPanel); +export default injectIntl(PostPreviewPanel); diff --git a/src/components/Search.jsx b/src/components/Search.jsx index 5370ae97..8f415960 100644 --- a/src/components/Search.jsx +++ b/src/components/Search.jsx @@ -1,11 +1,9 @@ -import React, { - useCallback, useContext, useEffect, useState, -} from 'react'; +import React, { useContext, useEffect } from 'react'; import camelCase from 'lodash/camelCase'; import { useDispatch, useSelector } from 'react-redux'; -import { useIntl } from '@edx/frontend-platform/i18n'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { Icon, SearchField } from '@edx/paragon'; import { Search as SearchIcon } from '@edx/paragon/icons'; @@ -15,8 +13,7 @@ import { setSearchQuery } from '../discussions/posts/data'; import postsMessages from '../discussions/posts/post-actions-bar/messages'; import { setFilter as setTopicFilter } from '../discussions/topics/data/slices'; -const Search = () => { - const intl = useIntl(); +function Search({ intl }) { const dispatch = useDispatch(); const { page } = useContext(DiscussionContext); const postSearch = useSelector(({ threads }) => threads.filters.search); @@ -24,9 +21,8 @@ const Search = () => { const learnerSearch = useSelector(({ learners }) => learners.usernameSearch); const isPostSearch = ['posts', 'my-posts'].includes(page); const isTopicSearch = 'topics'.includes(page); - const [searchValue, setSearchValue] = useState(''); + let searchValue = ''; let currentValue = ''; - if (isPostSearch) { currentValue = postSearch; } else if (isTopicSearch) { @@ -35,21 +31,20 @@ const Search = () => { currentValue = learnerSearch; } - const onClear = useCallback(() => { + const onClear = () => { dispatch(setSearchQuery('')); dispatch(setTopicFilter('')); dispatch(setUsernameSearch('')); - }, []); + }; - const onChange = useCallback((query) => { - setSearchValue(query); - }, []); + const onChange = (query) => { + searchValue = query; + }; - const onSubmit = useCallback((query) => { + const onSubmit = (query) => { if (query === '') { return; } - if (isPostSearch) { dispatch(setSearchQuery(query)); } else if (page === 'topics') { @@ -57,36 +52,36 @@ const Search = () => { } 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, }; -export default React.memo(Search); +export default injectIntl(Search); diff --git a/src/components/SearchInfo.jsx b/src/components/SearchInfo.jsx index e92d05f8..3fd628b8 100644 --- a/src/components/SearchInfo.jsx +++ b/src/components/SearchInfo.jsx @@ -1,36 +1,32 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { useIntl } from '@edx/frontend-platform/i18n'; +import { injectIntl, intlShape } 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'; -const SearchInfo = ({ +function SearchInfo({ + intl, count, text, loadingStatus, onClear, textSearchRewrite, -}) => { - const intl = useIntl(); - +}) { return (
@@ -39,9 +35,10 @@ const SearchInfo = ({
); -}; +} SearchInfo.propTypes = { + intl: intlShape.isRequired, count: PropTypes.number.isRequired, text: PropTypes.string.isRequired, loadingStatus: PropTypes.string.isRequired, @@ -54,4 +51,4 @@ SearchInfo.defaultProps = { textSearchRewrite: null, }; -export default React.memo(SearchInfo); +export default injectIntl(SearchInfo); diff --git a/src/components/Spinner.jsx b/src/components/Spinner.jsx deleted file mode 100644 index a301021a..00000000 --- a/src/components/Spinner.jsx +++ /dev/null @@ -1,11 +0,0 @@ -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 a61199ca..ff1c4499 100644 --- a/src/components/TinyMCEEditor.jsx +++ b/src/components/TinyMCEEditor.jsx @@ -1,4 +1,4 @@ -import React, { useCallback, useState } from 'react'; +import React, { useState } from 'react'; import { Editor } from '@tinymce/tinymce-react'; import { useParams } from 'react-router'; @@ -42,31 +42,30 @@ 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 */ -function TinyMCEEditor(props) { +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) { // 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(); - - /* 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) => { + const uploadHandler = async (blobInfo, success, failure) => { try { const blob = blobInfo.blob(); const imageSize = blobInfo.blob().size / 1024; @@ -77,7 +76,7 @@ function TinyMCEEditor(props) { const filename = blobInfo.filename(); const { location } = await uploadFile(blob, filename, courseId, postId || 'root'); const img = new Image(); - img.onload = () => { + img.onload = function () { if (img.height > 999 || img.width > 999) { setShowImageWarning(true); } }; img.src = location; @@ -85,11 +84,7 @@ 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. @@ -136,22 +131,21 @@ function TinyMCEEditor(props) { setShowImageWarning(false)} isBlocking footerNode={( - - )} + )} >

{intl.formatMessage(messages.imageWarningMessage)}

+ ); } - -export default React.memo(TinyMCEEditor); diff --git a/src/components/TopicStats.jsx b/src/components/TopicStats.jsx index 7f7bb725..416ea828 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 { useIntl } from '@edx/frontend-platform/i18n'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { Icon, OverlayTrigger, Tooltip } from '@edx/paragon'; import { HelpOutline, PostOutline, Report } from '@edx/paragon/icons'; @@ -14,16 +14,15 @@ import { } from '../discussions/data/selectors'; import messages from '../discussions/in-context-topics/messages'; -const TopicStats = ({ +function TopicStats({ threadCounts, activeFlags, inactiveFlags, -}) => { - const intl = useIntl(); + intl, +}) { const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges); const userIsGroupTa = useSelector(selectUserIsGroupTa); const canSeeReportedStats = (activeFlags || inactiveFlags) && (userHasModerationPrivileges || userIsGroupTa); - return (
); -}; +} TopicStats.propTypes = { threadCounts: PropTypes.shape({ @@ -97,6 +96,7 @@ TopicStats.propTypes = { }), activeFlags: PropTypes.number, inactiveFlags: PropTypes.number, + intl: intlShape.isRequired, }; TopicStats.defaultProps = { @@ -108,4 +108,4 @@ TopicStats.defaultProps = { inactiveFlags: null, }; -export default React.memo(TopicStats); +export default injectIntl(TopicStats); diff --git a/src/components/index.js b/src/components/index.js index fe0ee488..10908904 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -1,5 +1,4 @@ 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 cf836495..bc5fce84 100644 --- a/src/data/hooks.js +++ b/src/data/hooks.js @@ -18,13 +18,11 @@ 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 ec9bd03b..8e42cd97 100644 --- a/src/discussions/common/ActionsDropdown.jsx +++ b/src/discussions/common/ActionsDropdown.jsx @@ -1,11 +1,9 @@ -import React, { - useCallback, useMemo, useRef, useState, -} from 'react'; +import React, { useCallback, useRef, useState } from 'react'; import PropTypes from 'prop-types'; import { useSelector } from 'react-redux'; -import { useIntl } from '@edx/frontend-platform/i18n'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { logError } from '@edx/frontend-platform/logging'; import { Button, Dropdown, Icon, IconButton, ModalPopup, useToggle, @@ -15,22 +13,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({ - actionHandlers, - contentType, + intl, + commentOrPost, disabled, - dropDownIconSize, + actionHandlers, iconSize, - id, + dropDownIconSize, }) { const buttonRef = useRef(); - const intl = useIntl(); const [isOpen, open, close] = useToggle(false); const [target, setTarget] = useState(null); - const blackoutDateRange = useSelector(selectBlackoutDate); - const actions = useActions(contentType, id); + const actions = useActions(commentOrPost); const handleActions = useCallback((action) => { const actionFunction = actionHandlers[action]; @@ -41,12 +39,11 @@ function ActionsDropdown({ } }, [actionHandlers]); + const blackoutDateRange = useSelector(selectBlackoutDate); // Find and remove edit action if in blackout date range. - useMemo(() => { - if (inBlackoutDateRange(blackoutDateRange)) { - actions.splice(actions.findIndex(action => action.id === 'edit'), 1); - } - }, [actions, blackoutDateRange]); + if (inBlackoutDateRange(blackoutDateRange)) { + actions.splice(actions.findIndex(action => action.id === 'edit'), 1); + } const onClickButton = useCallback(() => { setTarget(buttonRef.current); @@ -83,7 +80,9 @@ function ActionsDropdown({ > {actions.map(action => ( - {(action.action === ContentActions.DELETE) && } + {(action.action === ContentActions.DELETE) + && } + { - const intl = useIntl(); +function AlertBanner({ + intl, + content, +}) { const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges); const userIsGroupTa = useSelector(selectUserIsGroupTa); const userIsGlobalStaff = useSelector(selectUserIsStaff); const { reasonCodesEnabled } = useSelector(selectModerationSettings); - const userIsContentAuthor = getAuthenticatedUser().username === author; - const canSeeReportedBanner = abuseFlagged; + const userIsContentAuthor = getAuthenticatedUser().username === content.author; + const canSeeReportedBanner = content?.abuseFlagged; const canSeeLastEditOrClosedAlert = (userHasModerationPrivileges || userIsGroupTa || userIsGlobalStaff || userIsContentAuthor ); - const editByLabelColor = AvatarOutlineAndLabelColors[editByLabel]; - const closedByLabelColor = AvatarOutlineAndLabelColors[closedByLabel]; + const editByLabelColor = AvatarOutlineAndLabelColors[content.editByLabel]; + const closedByLabelColor = AvatarOutlineAndLabelColors[content.closedByLabel]; return ( <> @@ -47,52 +42,33 @@ const AlertBanner = ({ )} {reasonCodesEnabled && canSeeLastEditOrClosedAlert && ( <> - {lastEdit?.reason && ( + {content.lastEdit?.reason && ( )} - {closed && ( - + {content.closed && ( + )} )} ); -}; +} 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, - }), + intl: intlShape.isRequired, + content: PropTypes.oneOfType([commentShape.isRequired, postShape.isRequired]).isRequired, }; -AlertBanner.defaultProps = { - abuseFlagged: false, - closed: undefined, - closedBy: undefined, - closedByLabel: undefined, - closeReason: undefined, - editByLabel: undefined, - lastEdit: {}, -}; - -export default React.memo(AlertBanner); +export default injectIntl(AlertBanner); diff --git a/src/discussions/common/AlertBar.jsx b/src/discussions/common/AlertBar.jsx index a5c51db6..b989e352 100644 --- a/src/discussions/common/AlertBar.jsx +++ b/src/discussions/common/AlertBar.jsx @@ -1,25 +1,24 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { useIntl } from '@edx/frontend-platform/i18n'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { Alert } from '@edx/paragon'; import messages from '../post-comments/messages'; import AuthorLabel from './AuthorLabel'; -const AlertBar = ({ +function AlertBar({ + intl, message, author, authorLabel, labelColor, reason, -}) => { - const intl = useIntl(); - +}) { return (
- {message} + {intl.formatMessage(message)} ); -}; +} AlertBar.propTypes = { + intl: intlShape.isRequired, message: PropTypes.string, author: PropTypes.string, authorLabel: PropTypes.string, @@ -57,4 +57,4 @@ AlertBar.defaultProps = { reason: '', }; -export default React.memo(AlertBar); +export default injectIntl(AlertBar); diff --git a/src/discussions/common/AuthorLabel.jsx b/src/discussions/common/AuthorLabel.jsx index cd70f1dd..37b674cb 100644 --- a/src/discussions/common/AuthorLabel.jsx +++ b/src/discussions/common/AuthorLabel.jsx @@ -1,22 +1,23 @@ -import React, { useContext, useMemo } from 'react'; +import React, { useContext } from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; -import { generatePath } from 'react-router'; -import { Link } from 'react-router-dom'; +import { Link, useLocation } from 'react-router-dom'; import * as timeago from 'timeago.js'; -import { useIntl } from '@edx/frontend-platform/i18n'; +import { injectIntl, intlShape } 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'; -const AuthorLabel = ({ +function AuthorLabel({ + intl, author, authorLabel, linkToProfile, @@ -25,18 +26,17 @@ const AuthorLabel = ({ postCreatedAt, authorToolTip, postOrComment, -}) => { - timeago.register('time-locale', timeLocale); - const intl = useIntl(); +}) { + const location = useLocation(); 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 @@ const AuthorLabel = ({ const showUserNameAsLink = useShowLearnersTab() && linkToProfile && author && author !== intl.formatMessage(messages.anonymous); - const authorName = useMemo(() => ( + const authorName = ( {isRetiredUser ? '[Deactivated]' : author} - ), [author, authorLabelMessage, isRetiredUser]); - - const labelContents = useMemo(() => ( + ); + const labelContents = ( <> )} - ), [author, authorLabelMessage, authorToolTip, icon, isRetiredUser, postCreatedAt, showTextPrimary, alert]); + ); return showUserNameAsLink ? ( @@ -118,7 +117,7 @@ const AuthorLabel = ({ @@ -128,9 +127,10 @@ const 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 React.memo(AuthorLabel); +export default injectIntl(AuthorLabel); diff --git a/src/discussions/common/Confirmation.jsx b/src/discussions/common/Confirmation.jsx index 59106a1a..06f84b31 100644 --- a/src/discussions/common/Confirmation.jsx +++ b/src/discussions/common/Confirmation.jsx @@ -1,12 +1,13 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { useIntl } from '@edx/frontend-platform/i18n'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { ActionRow, Button, ModalDialog } from '@edx/paragon'; import messages from '../messages'; function Confirmation({ + intl, isOpen, title, description, @@ -16,8 +17,6 @@ function Confirmation({ confirmButtonVariant, confirmButtonText, }) { - const intl = useIntl(); - return ( @@ -43,6 +42,7 @@ 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 React.memo(Confirmation); +export default injectIntl(Confirmation); diff --git a/src/discussions/common/EndorsedAlertBanner.jsx b/src/discussions/common/EndorsedAlertBanner.jsx index e92279c1..e2fcf125 100644 --- a/src/discussions/common/EndorsedAlertBanner.jsx +++ b/src/discussions/common/EndorsedAlertBanner.jsx @@ -1,34 +1,30 @@ -import React, { useContext } from 'react'; +import React from 'react'; import PropTypes from 'prop-types'; import * as timeago from 'timeago.js'; -import { useIntl } from '@edx/frontend-platform/i18n'; +import { injectIntl, intlShape } 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({ - endorsed, - endorsedAt, - endorsedBy, - endorsedByLabel, + intl, + content, + postType, }) { 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 ( - endorsed && ( + content.endorsed && ( @@ -65,16 +61,13 @@ function EndorsedAlertBanner({ } EndorsedAlertBanner.propTypes = { - endorsed: PropTypes.bool.isRequired, - endorsedAt: PropTypes.string, - endorsedBy: PropTypes.string, - endorsedByLabel: PropTypes.string, + intl: intlShape.isRequired, + content: PropTypes.oneOfType([commentShape.isRequired]).isRequired, + postType: PropTypes.string, }; EndorsedAlertBanner.defaultProps = { - endorsedAt: null, - endorsedBy: null, - endorsedByLabel: null, + postType: null, }; -export default React.memo(EndorsedAlertBanner); +export default injectIntl(EndorsedAlertBanner); diff --git a/src/discussions/common/HoverCard.jsx b/src/discussions/common/HoverCard.jsx index 46a72a33..cd8ae29f 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 { useIntl } from '@edx/frontend-platform/i18n'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { Button, Icon, IconButton, OverlayTrigger, Tooltip, } from '@edx/paragon'; @@ -12,32 +12,29 @@ import { StarFilled, StarOutline, ThumbUpFilled, ThumbUpOutline, } from '../../components/icons'; import { useUserCanAddThreadInBlackoutDate } from '../data/hooks'; -import { PostCommentsContext } from '../post-comments/postCommentsContext'; +import { commentShape } from '../post-comments/comments/comment/proptypes'; +import { postShape } from '../posts/post/proptypes'; import ActionsDropdown from './ActionsDropdown'; import { DiscussionContext } from './context'; -const HoverCard = ({ - id, - contentType, +function HoverCard({ + intl, + commentOrPost, actionHandlers, handleResponseCommentButton, addResponseCommentButtonMessage, onLike, onFollow, - voted, - following, + isClosedPost, endorseIcons, -}) => { - const intl = useIntl(); +}) { const { enableInContextSidebar } = useContext(DiscussionContext); - const { isClosed } = useContext(PostCommentsContext); const userCanAddThreadInBlackoutDate = useUserCanAddThreadInBlackoutDate(); - return (
{userCanAddThreadInBlackoutDate && (
@@ -46,7 +43,7 @@ const HoverCard = ({ className={classNames('px-2.5 py-2 border-0 font-style text-gray-700 font-size-12', { 'w-100': enableInContextSidebar })} onClick={() => handleResponseCommentButton()} - disabled={isClosed} + disabled={isClosedPost} style={{ lineHeight: '20px' }} > {addResponseCommentButtonMessage} @@ -79,7 +76,7 @@ const HoverCard = ({ )}
- {following !== undefined && ( + {commentOrPost.following !== undefined && (
)}
- +
); -}; +} HoverCard.propTypes = { - id: PropTypes.string.isRequired, - contentType: PropTypes.string.isRequired, + intl: intlShape.isRequired, + commentOrPost: PropTypes.oneOfType([commentShape, postShape]).isRequired, actionHandlers: PropTypes.objectOf(PropTypes.func).isRequired, handleResponseCommentButton: PropTypes.func.isRequired, - addResponseCommentButtonMessage: PropTypes.string.isRequired, onLike: PropTypes.func.isRequired, - voted: PropTypes.bool.isRequired, - endorseIcons: PropTypes.objectOf(PropTypes.any), onFollow: PropTypes.func, - following: PropTypes.bool, + addResponseCommentButtonMessage: PropTypes.string.isRequired, + isClosedPost: PropTypes.bool.isRequired, + endorseIcons: PropTypes.objectOf(PropTypes.any), }; HoverCard.defaultProps = { onFollow: () => null, endorseIcons: null, - following: undefined, }; -export default React.memo(HoverCard); +export default injectIntl(HoverCard); diff --git a/src/discussions/data/constants.js b/src/discussions/data/constants.js deleted file mode 100644 index d8f434f3..00000000 --- a/src/discussions/data/constants.js +++ /dev/null @@ -1,12 +0,0 @@ -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 2e486bfc..3d96de9d 100644 --- a/src/discussions/data/hooks.js +++ b/src/discussions/data/hooks.js @@ -1,13 +1,15 @@ +/* eslint-disable import/prefer-default-export */ import { - useCallback, - useContext, useEffect, useMemo, useRef, useState, + useContext, + useEffect, + 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'; @@ -40,14 +42,16 @@ import { fetchCourseConfig } from './thunks'; export function useTotalTopicThreadCount() { const topics = useSelector(selectTopics); - const count = useMemo(() => ( - Object.keys(topics)?.reduce((total, topicId) => { + + if (!topics) { + return 0; + } + + return Object.keys(topics) + .reduce((total, topicId) => { const topic = topics[topicId]; return total + topic.threadCounts.discussion + topic.threadCounts.question; - }, 0)), - []); - - return count; + }, 0); } export const useSidebarVisible = () => { @@ -83,14 +87,13 @@ export function useCourseDiscussionData(courseId) { export function useRedirectToThread(courseId, enableInContextSidebar) { const dispatch = useDispatch(); - const history = useHistory(); - const location = useLocation(); - const redirectToThread = useSelector( (state) => state.threads.redirectToThread, ); + const history = useHistory(); + const location = useLocation(); - useEffect(() => { + return 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) { @@ -150,20 +153,17 @@ export function useContainerSize(refContainer) { return height; } -export const useAlertBannerVisible = ( - { - author, abuseFlagged, lastEdit, closed, - } = {}, -) => { +export const useAlertBannerVisible = (content) => { const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges); const userIsGroupTa = useSelector(selectUserIsGroupTa); const { reasonCodesEnabled } = useSelector(selectModerationSettings); - const userIsContentAuthor = getAuthenticatedUser().username === author; + const userIsContentAuthor = getAuthenticatedUser().username === content.author; const canSeeLastEditOrClosedAlert = (userHasModerationPrivileges || userIsContentAuthor || userIsGroupTa); - const canSeeReportedBanner = abuseFlagged; + const canSeeReportedBanner = content.abuseFlagged; return ( - (reasonCodesEnabled && canSeeLastEditOrClosedAlert && (lastEdit?.reason || closed)) || (canSeeReportedBanner) + (reasonCodesEnabled && canSeeLastEditOrClosedAlert && (content.lastEdit?.reason || content.closed)) + || (content.abuseFlagged && canSeeReportedBanner) ); }; @@ -193,50 +193,38 @@ export const useCurrentDiscussionTopic = () => { export const useUserCanAddThreadInBlackoutDate = () => { const blackoutDateRange = useSelector(selectBlackoutDate); const isUserAdmin = useSelector(selectUserIsStaff); - const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges); + const userHasModerationPrivilages = useSelector(selectUserHasModerationPrivileges); const isUserGroupTA = useSelector(selectUserIsGroupTa); const isCourseAdmin = useSelector(selectIsCourseAdmin); const isCourseStaff = useSelector(selectIsCourseStaff); - const isPrivileged = isUserAdmin || userHasModerationPrivileges || isUserGroupTA || isCourseAdmin || isCourseStaff; - const isInBlackoutDateRange = useMemo(() => inBlackoutDateRange(blackoutDateRange), [blackoutDateRange]); + const isInBlackoutDateRange = inBlackoutDateRange(blackoutDateRange); - return (!(isInBlackoutDateRange) || (isPrivileged)); + return (!(isInBlackoutDateRange) + || (isUserAdmin || userHasModerationPrivilages || isUserGroupTA || isCourseAdmin || isCourseStaff)); }; function camelToConstant(string) { return string.replace(/[A-Z]/g, (match) => `_${match}`).toUpperCase(); } -export const useTourConfiguration = () => { - const intl = useIntl(); +export const useTourConfiguration = (intl) => { const dispatch = useDispatch(); + const { enableInContextSidebar } = useContext(DiscussionContext); const tours = useSelector(selectTours); - 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; + 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)], + } + )); }; export const useDebounce = (value, delay) => { diff --git a/src/discussions/discussions-home/BlackoutInformationBanner.jsx b/src/discussions/discussions-home/BlackoutInformationBanner.jsx index 301a460b..bd8aea78 100644 --- a/src/discussions/discussions-home/BlackoutInformationBanner.jsx +++ b/src/discussions/discussions-home/BlackoutInformationBanner.jsx @@ -1,39 +1,36 @@ -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useState } from 'react'; import { useSelector } from 'react-redux'; -import { useIntl } from '@edx/frontend-platform/i18n'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { PageBanner } from '@edx/paragon'; import { selectBlackoutDate } from '../data/selectors'; import messages from '../messages'; import { inBlackoutDateRange } from '../utils'; -const BlackoutInformationBanner = () => { - const intl = useIntl(); - const blackoutDate = useSelector(selectBlackoutDate); +function BlackoutInformationBanner({ + intl, +}) { + const isDiscussionsBlackout = inBlackoutDateRange(useSelector(selectBlackoutDate)); const [showBanner, setShowBanner] = useState(true); - const isDiscussionsBlackout = useMemo(() => ( - inBlackoutDateRange(blackoutDate) - ), [blackoutDate]); - - const handleDismiss = useCallback(() => { - setShowBanner(false); - }, []); - return ( setShowBanner(false)} >
{intl.formatMessage(messages.blackoutDiscussionInformation)}
); +} + +BlackoutInformationBanner.propTypes = { + intl: intlShape.isRequired, }; -export default BlackoutInformationBanner; +export default injectIntl(BlackoutInformationBanner); diff --git a/src/discussions/discussions-home/DiscussionContent.jsx b/src/discussions/discussions-home/DiscussionContent.jsx index ca3e668c..3d2764d2 100644 --- a/src/discussions/discussions-home/DiscussionContent.jsx +++ b/src/discussions/discussions-home/DiscussionContent.jsx @@ -1,39 +1,37 @@ -import React, { lazy, Suspense } from 'react'; +import React from 'react'; import { useSelector } from 'react-redux'; import { Route, Switch } from 'react-router'; -import Spinner from '../../components/Spinner'; +import { injectIntl } from '@edx/frontend-platform/i18n'; + import { Routes } from '../../data/constants'; +import { PostCommentsView } from '../post-comments'; +import { PostEditor } from '../posts'; -const PostEditor = lazy(() => import('../posts/post-editor/PostEditor')); -const PostCommentsView = lazy(() => import('../post-comments/PostCommentsView')); - -const DiscussionContent = () => { +function DiscussionContent() { const postEditorVisible = useSelector((state) => state.threads.postEditorVisible); return (
- )}> - {postEditorVisible ? ( - - + {postEditorVisible ? ( + + + + ) : ( + + + - ) : ( - - - - - - - - - )} - + + + + + )}
); -}; +} -export default DiscussionContent; +export default injectIntl(DiscussionContent); diff --git a/src/discussions/discussions-home/DiscussionSidebar.jsx b/src/discussions/discussions-home/DiscussionSidebar.jsx index bece2172..59332d07 100644 --- a/src/discussions/discussions-home/DiscussionSidebar.jsx +++ b/src/discussions/discussions-home/DiscussionSidebar.jsx @@ -1,6 +1,4 @@ -import React, { - lazy, Suspense, useContext, useEffect, useRef, -} from 'react'; +import React, { useContext, useEffect, useRef } from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; @@ -11,22 +9,18 @@ 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'; -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 }) => { +export default function DiscussionSidebar({ displaySidebar, postActionBarRef }) { const location = useLocation(); const isOnDesktop = useIsOnDesktop(); const isOnXLDesktop = useIsOnXLDesktop(); @@ -61,16 +55,15 @@ const DiscussionSidebar = ({ displaySidebar, postActionBarRef }) => { })} data-testid="sidebar" > - )}> - - {enableInContext && !enableInContextSidebar && ( + + {enableInContext && !enableInContextSidebar && ( - )} - {enableInContext && !enableInContextSidebar && ( + )} + {enableInContext && !enableInContextSidebar && ( { component={TopicPostsView} exact /> - )} - - - {redirectToLearnersTab && ( + )} + + + {redirectToLearnersTab && ( - )} - {redirectToLearnersTab && ( + )} + {redirectToLearnersTab && ( - )} - {configStatus === RequestStatus.SUCCESSFUL && ( + )} + {configStatus === RequestStatus.SUCCESSFUL && ( { pathname: Routes.POSTS.ALL_POSTS, }} /> - )} - - + )} +
); +} + +DiscussionSidebar.defaultProps = { + displaySidebar: false, + postActionBarRef: null, }; DiscussionSidebar.propTypes = { @@ -115,10 +112,3 @@ 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 8950a9e7..b0ed0668 100644 --- a/src/discussions/discussions-home/DiscussionsHome.jsx +++ b/src/discussions/discussions-home/DiscussionsHome.jsx @@ -1,4 +1,4 @@ -import React, { lazy, Suspense, useRef } from 'react'; +import React, { useEffect, useRef } from 'react'; import classNames from 'classnames'; import { useSelector } from 'react-redux'; @@ -6,10 +6,12 @@ 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 { Spinner } from '../../components'; +import { PostActionsBar } from '../../components'; +import { CourseTabsNavigation } from '../../components/NavigationBar'; import { selectCourseTabs } from '../../components/NavigationBar/data/selectors'; import { ALL_ROUTES, DiscussionProvider, Routes } from '../../data/constants'; import { DiscussionContext } from '../common/context'; @@ -20,21 +22,17 @@ 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'; -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 = () => { +export default function DiscussionsHome() { const location = useLocation(); const postActionBarRef = useRef(null); const postEditorVisible = useSelector(selectPostEditorVisible); @@ -42,6 +40,7 @@ const 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(); @@ -61,60 +60,54 @@ const 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 &&
} +
+ {!enableInContextSidebar && } +
-
- {!enableInContextSidebar && ( - - )} - -
- {isFeedbackBannerVisible && } - + {!enableInContextSidebar && } +
- {provider === DiscussionProvider.LEGACY && ( - )}> - - - )} -
- )}> - - - {displayContentArea && ( - )}> - - - )} - {!displayContentArea && ( + {isFeedbackBannerVisible && } + +
+ {provider === DiscussionProvider.LEGACY && ( + + )} + +
+ + {displayContentArea && } + {!displayContentArea && ( { /> {isRedirectToLearners && } - )} -
- {!enableInContextSidebar && ( - )} -
- {!enableInContextSidebar &&
} - - +
+ {!enableInContextSidebar && } + + {!enableInContextSidebar &&
); -}; +} EmptyPage.propTypes = { title: propTypes.string.isRequired, @@ -50,4 +50,4 @@ EmptyPage.defaultProps = { actionText: null, }; -export default React.memo(EmptyPage); +export default EmptyPage; diff --git a/src/discussions/empty-posts/EmptyPosts.jsx b/src/discussions/empty-posts/EmptyPosts.jsx index 26be9bce..4e62215f 100644 --- a/src/discussions/empty-posts/EmptyPosts.jsx +++ b/src/discussions/empty-posts/EmptyPosts.jsx @@ -1,9 +1,9 @@ -import React, { useCallback } from 'react'; +import React from 'react'; import propTypes from 'prop-types'; import { useDispatch, useSelector } from 'react-redux'; -import { useIntl } from '@edx/frontend-platform/i18n'; +import { injectIntl, intlShape } 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'; -const EmptyPosts = ({ subTitleMessage }) => { - const intl = useIntl(); +function EmptyPosts({ intl, subTitleMessage }) { const dispatch = useDispatch(); - const isOnDesktop = useIsOnDesktop(); + const isFiltered = useSelector(selectAreThreadsFiltered); const totalThreads = useSelector(selectPostThreadCount); + const isOnDesktop = useIsOnDesktop(); - const addPost = useCallback(() => ( - dispatch(showPostEditor()) - ), []); + function addPost() { + return dispatch(showPostEditor()); + } let title = messages.noPostSelected; let subTitle = null; @@ -49,7 +49,7 @@ const EmptyPosts = ({ subTitleMessage }) => { fullWidth={fullWidth} /> ); -}; +} EmptyPosts.propTypes = { subTitleMessage: propTypes.shape({ @@ -57,6 +57,7 @@ EmptyPosts.propTypes = { defaultMessage: propTypes.string, description: propTypes.string, }).isRequired, + intl: intlShape.isRequired, }; -export default React.memo(EmptyPosts); +export default injectIntl(EmptyPosts); diff --git a/src/discussions/empty-posts/EmptyTopics.jsx b/src/discussions/empty-posts/EmptyTopics.jsx index b728242f..5a7aa0bb 100644 --- a/src/discussions/empty-posts/EmptyTopics.jsx +++ b/src/discussions/empty-posts/EmptyTopics.jsx @@ -1,9 +1,9 @@ -import React, { useCallback } from 'react'; +import React from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { useRouteMatch } from 'react-router'; -import { useIntl } from '@edx/frontend-platform/i18n'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { ALL_ROUTES } from '../../data/constants'; import { useIsOnDesktop, useTotalTopicThreadCount } from '../data/hooks'; @@ -12,17 +12,18 @@ import messages from '../messages'; import { messages as postMessages, showPostEditor } from '../posts'; import EmptyPage from './EmptyPage'; -const EmptyTopics = () => { - const intl = useIntl(); +function EmptyTopics({ intl }) { const match = useRouteMatch(ALL_ROUTES); const dispatch = useDispatch(); - const isOnDesktop = useIsOnDesktop(); + const hasGlobalThreads = useTotalTopicThreadCount() > 0; const topicThreadCount = useSelector(selectTopicThreadCount(match.params.topicId)); - const addPost = useCallback(() => ( - dispatch(showPostEditor()) - ), []); + function addPost() { + return dispatch(showPostEditor()); + } + + const isOnDesktop = useIsOnDesktop(); let title = messages.emptyTitle; let fullWidth = false; @@ -61,6 +62,10 @@ const EmptyTopics = () => { fullWidth={fullWidth} /> ); +} + +EmptyTopics.propTypes = { + intl: intlShape.isRequired, }; -export default EmptyTopics; +export default injectIntl(EmptyTopics); diff --git a/src/discussions/in-context-topics/TopicPostsView.jsx b/src/discussions/in-context-topics/TopicPostsView.jsx index 9fc050a6..494b68a3 100644 --- a/src/discussions/in-context-topics/TopicPostsView.jsx +++ b/src/discussions/in-context-topics/TopicPostsView.jsx @@ -1,17 +1,15 @@ -import React, { - useCallback, useContext, useEffect, useMemo, -} from 'react'; +import React, { useContext, useEffect } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { useLocation } from 'react-router-dom'; -import { useIntl } from '@edx/frontend-platform/i18n'; +import { injectIntl, intlShape } 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 { selectTopicThreadsIds } from '../posts/data/selectors'; +import { selectTopicThreads } from '../posts/data/selectors'; import PostsList from '../posts/PostsList'; import { discussionsPath, handleKeyDown } from '../utils'; import { @@ -23,34 +21,19 @@ import { BackButton, NoResults } from './components'; import messages from './messages'; import { Topic } from './topic'; -const TopicPostsView = () => { - const intl = useIntl(); +function TopicPostsView({ intl }) { const location = useLocation(); const dispatch = useDispatch(); const { courseId, topicId, category } = useContext(DiscussionContext); const provider = useSelector(selectDiscussionProvider); const topicsStatus = useSelector(selectLoadingStatus); - const postsIds = useSelector(selectTopicThreadsIds([topicId])); + const topicsInProgress = topicsStatus === RequestStatus.IN_PROGRESS; + const posts = useSelector(selectTopicThreads([topicId])); const selectedSubsectionUnits = useSelector(selectSubsectionUnits(category)); const selectedSubsection = useSelector(selectSubsection(category)); - const units = useSelector(selectUnits); - const nonCoursewareTopics = useSelector(selectNonCoursewareTopics); + const selectedUnit = useSelector(selectUnits)?.find(unit => unit.id === topicId); + const selectedNonCoursewareTopic = useSelector(selectNonCoursewareTopics)?.find(topic => topic.id === topicId); 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) { @@ -58,6 +41,12 @@ const TopicPostsView = () => { } }, [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 ? ( @@ -78,8 +67,8 @@ const TopicPostsView = () => {
handleKeyDown(e)}> {topicId ? ( ) : ( @@ -101,6 +90,10 @@ const TopicPostsView = () => {
); +} + +TopicPostsView.propTypes = { + intl: intlShape.isRequired, }; -export default React.memo(TopicPostsView); +export default injectIntl(TopicPostsView); diff --git a/src/discussions/in-context-topics/TopicsView.jsx b/src/discussions/in-context-topics/TopicsView.jsx index 0c84d088..b62dc037 100644 --- a/src/discussions/in-context-topics/TopicsView.jsx +++ b/src/discussions/in-context-topics/TopicsView.jsx @@ -1,6 +1,4 @@ -import React, { - useCallback, useContext, useEffect, useMemo, -} from 'react'; +import React, { useContext, useEffect } from 'react'; import classNames from 'classnames'; import isEmpty from 'lodash/isEmpty'; @@ -23,38 +21,30 @@ import { setFilter } from './data/slices'; import { fetchCourseTopicsV3 } from './data/thunks'; import { ArchivedBaseGroup, SectionBaseGroup, Topic } from './topic'; -const TopicsList = () => { +function 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 ( <> - {renderNonCoursewareTopics} - {renderCoursewareTopics} + {nonCoursewareTopics?.map((topic, index) => ( + + ))} + {coursewareTopics?.map((topic, index) => ( + + ))} {!isEmpty(archivedTopics) && ( { )} ); -}; +} -const TopicsView = () => { +function TopicsView() { const dispatch = useDispatch(); const { courseId } = useContext(DiscussionContext); const provider = useSelector(selectDiscussionProvider); @@ -93,10 +83,6 @@ const TopicsView = () => { } }, [isPostsFiltered]); - const handleOnClear = useCallback(() => { - dispatch(setFilter('')); - }, []); - return (
{topicFilter && ( @@ -105,7 +91,7 @@ const TopicsView = () => { text={topicFilter} count={filteredTopics.length} loadingStatus={loadingStatus} - onClear={handleOnClear} + onClear={() => dispatch(setFilter(''))} /> {filteredTopics.length === 0 && loadingStatus === RequestStatus.SUCCESSFUL && } @@ -130,6 +116,6 @@ const 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 ef23c80c..9d667839 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, { useCallback, useContext } from 'react'; +import React, { useContext } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { useRouteMatch } from 'react-router'; -import { useIntl } from '@edx/frontend-platform/i18n'; +import { injectIntl, intlShape } 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'; -const EmptyTopics = () => { - const intl = useIntl(); +function EmptyTopics({ intl }) { 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; - const addPost = useCallback(() => ( - dispatch(showPostEditor()) - ), []); + function addPost() { + return dispatch(showPostEditor()); + } + + const isOnDesktop = useIsOnDesktop(); let title = messages.emptyTitle; let fullWidth = false; @@ -74,6 +74,10 @@ const EmptyTopics = () => { fullWidth={fullWidth} /> ); +} + +EmptyTopics.propTypes = { + intl: intlShape.isRequired, }; -export default EmptyTopics; +export default injectIntl(EmptyTopics); diff --git a/src/discussions/in-context-topics/components/NoResults.jsx b/src/discussions/in-context-topics/components/NoResults.jsx index a2086a96..f213a2f9 100644 --- a/src/discussions/in-context-topics/components/NoResults.jsx +++ b/src/discussions/in-context-topics/components/NoResults.jsx @@ -1,14 +1,11 @@ -import React from 'react'; - import { useSelector } from 'react-redux'; -import { useIntl } from '@edx/frontend-platform/i18n'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { selectTopics } from '../data/selectors'; import messages from '../messages'; -const NoResults = () => { - const intl = useIntl(); +function NoResults({ intl }) { const topics = useSelector(selectTopics); const title = messages.nothingHere; @@ -23,6 +20,10 @@ const NoResults = () => { { helpMessage && {intl.formatMessage(helpMessage)}} ); +} + +NoResults.propTypes = { + intl: intlShape.isRequired, }; -export default NoResults; +export default injectIntl(NoResults); diff --git a/src/discussions/in-context-topics/topic-search/TopicSearchBar.jsx b/src/discussions/in-context-topics/topic-search/TopicSearchBar.jsx index 2f4ff84b..6766bb40 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, { useCallback, useContext, useEffect } from 'react'; +import React, { useContext, useEffect } from 'react'; import { useDispatch, useSelector } from 'react-redux'; -import { useIntl } from '@edx/frontend-platform/i18n'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { Icon, SearchField } from '@edx/paragon'; import { Search as SearchIcon } from '@edx/paragon/icons'; @@ -10,51 +10,56 @@ import { DiscussionContext } from '../../common/context'; import postsMessages from '../../posts/post-actions-bar/messages'; import { setFilter as setTopicFilter } from '../data/slices'; -const TopicSearchBar = () => { - const intl = useIntl(); +function TopicSearchBar({ intl }) { const dispatch = useDispatch(); const { page } = useContext(DiscussionContext); const topicSearch = useSelector(({ inContextTopics }) => inContextTopics.filter); let searchValue = ''; - const onClear = useCallback(() => { + const onClear = () => { dispatch(setTopicFilter('')); - }, []); + }; - const onChange = useCallback((query) => { + const onChange = (query) => { searchValue = query; - }, []); + }; - const onSubmit = useCallback((query) => { + const onSubmit = (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 TopicSearchBar; +export default injectIntl(TopicSearchBar); diff --git a/src/discussions/in-context-topics/topic/ArchivedBaseGroup.jsx b/src/discussions/in-context-topics/topic/ArchivedBaseGroup.jsx index 856f7361..eef9fcd0 100644 --- a/src/discussions/in-context-topics/topic/ArchivedBaseGroup.jsx +++ b/src/discussions/in-context-topics/topic/ArchivedBaseGroup.jsx @@ -1,27 +1,16 @@ -import React, { useMemo } from 'react'; +import React from 'react'; import PropTypes from 'prop-types'; -import { useIntl } from '@edx/frontend-platform/i18n'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import messages from '../messages'; import Topic, { topicShape } from './Topic'; -const ArchivedBaseGroup = ({ +function ArchivedBaseGroup({ archivedTopics, showDivider, -}) => { - const intl = useIntl(); - - const renderArchivedTopics = useMemo(() => ( - archivedTopics?.map((topic, index) => ( - - )) - ), [archivedTopics]); - + intl, +}) { return ( <> {showDivider && ( @@ -35,18 +24,25 @@ const ArchivedBaseGroup = ({ data-testid="archived-group" >
{intl.formatMessage(messages.archivedTopics)}
- {renderArchivedTopics} + {archivedTopics?.map((topic, index) => ( + + ))} ); -}; +} ArchivedBaseGroup.propTypes = { archivedTopics: PropTypes.arrayOf(topicShape).isRequired, showDivider: PropTypes.bool, + intl: intlShape.isRequired, }; ArchivedBaseGroup.defaultProps = { showDivider: false, }; -export default React.memo(ArchivedBaseGroup); +export default injectIntl(ArchivedBaseGroup); diff --git a/src/discussions/in-context-topics/topic/SectionBaseGroup.jsx b/src/discussions/in-context-topics/topic/SectionBaseGroup.jsx index 667eeeee..a783c248 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, { useCallback, useMemo } from 'react'; +import React from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; import { useParams } from 'react-router'; import { Link } from 'react-router-dom'; -import { useIntl } from '@edx/frontend-platform/i18n'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import TopicStats from '../../../components/TopicStats'; import { Routes } from '../../../data/constants'; @@ -13,52 +13,19 @@ import { discussionsPath } from '../../utils'; import messages from '../messages'; import { topicShape } from './Topic'; -const SectionBaseGroup = ({ +function SectionBaseGroup({ section, sectionTitle, sectionId, showDivider, -}) => { - const intl = useIntl(); + intl, +}) { const { courseId } = useParams(); - - const isSelected = useCallback((id) => ( - window.location.pathname.includes(id) - ), []); - - const sectionUrl = useCallback((id) => discussionsPath(Routes.TOPICS.CATEGORY, { + const isSelected = (id) => window.location.pathname.includes(id); + const sectionUrl = (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)}
- {renderSection} + {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)} +
+ +
+
+
+ + ))} {showDivider && ( <>
@@ -78,7 +70,7 @@ const SectionBaseGroup = ({ )}
); -}; +} SectionBaseGroup.propTypes = { section: PropTypes.arrayOf(PropTypes.shape({ @@ -94,6 +86,7 @@ SectionBaseGroup.propTypes = { sectionTitle: PropTypes.string.isRequired, sectionId: PropTypes.string.isRequired, showDivider: PropTypes.bool.isRequired, + intl: intlShape.isRequired, }; -export default React.memo(SectionBaseGroup); +export default injectIntl(SectionBaseGroup); diff --git a/src/discussions/in-context-topics/topic/Topic.jsx b/src/discussions/in-context-topics/topic/Topic.jsx index b8c163b2..927b1494 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 { useIntl } from '@edx/frontend-platform/i18n'; +import { injectIntl, intlShape } 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'; -const Topic = ({ +function Topic({ topic, showDivider, index, -}) => { - const intl = useIntl(); + intl, +}) { const { courseId } = useParams(); const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges); const userIsGroupTa = useSelector(selectUserIsGroupTa); @@ -70,7 +70,7 @@ const Topic = ({ )} ); -}; +} export const topicShape = PropTypes.shape({ id: PropTypes.string, @@ -85,6 +85,7 @@ export const topicShape = PropTypes.shape({ }); Topic.propTypes = { + intl: intlShape.isRequired, topic: topicShape, showDivider: PropTypes.bool, index: PropTypes.number, @@ -98,4 +99,4 @@ Topic.defaultProps = { }, }; -export default React.memo(Topic); +export default injectIntl(Topic); diff --git a/src/discussions/learners/LearnerPostsView.jsx b/src/discussions/learners/LearnerPostsView.jsx index a4989009..44ca9be7 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 { useIntl } from '@edx/frontend-platform/i18n'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { Button, Icon, IconButton, Spinner, } from '@edx/paragon'; @@ -18,36 +18,33 @@ import { } from '../../data/constants'; import { DiscussionContext } from '../common/context'; import { selectUserHasModerationPrivileges, selectUserIsStaff } from '../data/selectors'; -import { usePostList } from '../posts/data/hooks'; import { - selectAllThreadsIds, + selectAllThreads, selectThreadNextPage, threadsLoadingStatus, } from '../posts/data/selectors'; import { clearPostsPages } from '../posts/data/slices'; import NoResults from '../posts/NoResults'; import { PostLink } from '../posts/post'; -import { discussionsPath } from '../utils'; +import { discussionsPath, filterPosts } from '../utils'; import { fetchUserPosts } from './data/thunks'; import LearnerPostFilterBar from './learner-post-filter-bar/LearnerPostFilterBar'; import messages from './messages'; -const LearnerPostsView = () => { - const intl = useIntl(); +function LearnerPostsView({ intl }) { const location = useLocation(); const history = useHistory(); const dispatch = useDispatch(); - const postsIds = useSelector(selectAllThreadsIds); + const posts = useSelector(selectAllThreads); 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 = useCallback((pageNum = undefined) => { + const loadMorePosts = (pageNum = undefined) => { const params = { author: username, page: pageNum, @@ -57,24 +54,29 @@ const LearnerPostsView = () => { }; 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 (
@@ -95,8 +97,9 @@ const LearnerPostsView = () => {
- {postInstances} - {loadingStatus !== RequestStatus.IN_PROGRESS && sortedPostsIds?.length === 0 && } + {postInstances(pinnedPosts)} + {postInstances(unpinnedPosts)} + {loadingStatus !== RequestStatus.IN_PROGRESS && posts?.length === 0 && } {loadingStatus === RequestStatus.IN_PROGRESS ? (
@@ -111,6 +114,10 @@ const LearnerPostsView = () => {
); +} + +LearnerPostsView.propTypes = { + intl: intlShape.isRequired, }; -export default LearnerPostsView; +export default injectIntl(LearnerPostsView); diff --git a/src/discussions/learners/LearnersView.jsx b/src/discussions/learners/LearnersView.jsx index 71a4491e..8973d2af 100644 --- a/src/discussions/learners/LearnersView.jsx +++ b/src/discussions/learners/LearnersView.jsx @@ -1,11 +1,11 @@ -import React, { useCallback, useEffect, useMemo } from 'react'; +import React, { useEffect } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { Redirect, useLocation, useParams, } from 'react-router'; -import { useIntl } from '@edx/frontend-platform/i18n'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { Button, Spinner } from '@edx/paragon'; import SearchInfo from '../../components/SearchInfo'; @@ -24,8 +24,7 @@ import { fetchLearners } from './data/thunks'; import { LearnerCard, LearnerFilterBar } from './learner'; import messages from './messages'; -const LearnersView = () => { - const intl = useIntl(); +function LearnersView({ intl }) { const { courseId } = useParams(); const location = useLocation(); const dispatch = useDispatch(); @@ -47,7 +46,7 @@ const LearnersView = () => { } }, [courseId, orderBy, learnersTabEnabled, usernameSearch]); - const loadPage = useCallback(async () => { + const loadPage = async () => { if (nextPage) { dispatch(fetchLearners(courseId, { orderBy, @@ -55,19 +54,7 @@ const LearnersView = () => { usernameSearch, })); } - }, [courseId, orderBy, nextPage, usernameSearch]); - - const handleOnClear = useCallback(() => { - dispatch(setUsernameSearch('')); - }, []); - - const renderLearnersList = useMemo(() => ( - ( - courseConfigLoadingStatus === RequestStatus.SUCCESSFUL && learnersTabEnabled && learners.map((learner) => ( - - )) - ) || <> - ), [courseConfigLoadingStatus, learnersTabEnabled, learners]); + }; return (
@@ -78,7 +65,7 @@ const LearnersView = () => { text={usernameSearch} count={learners.length} loadingStatus={loadingStatus} - onClear={handleOnClear} + onClear={() => dispatch(setUsernameSearch(''))} /> )}
@@ -90,7 +77,12 @@ const LearnersView = () => { }} /> )} - {renderLearnersList} + {courseConfigLoadingStatus === RequestStatus.SUCCESSFUL + && learnersTabEnabled + && learners.map((learner, index) => ( + // eslint-disable-next-line react/no-array-index-key + + ))} {loadingStatus === RequestStatus.IN_PROGRESS ? (
@@ -106,6 +98,10 @@ const LearnersView = () => {
); +} + +LearnersView.propTypes = { + intl: intlShape.isRequired, }; -export default LearnersView; +export default injectIntl(LearnersView); diff --git a/src/discussions/learners/learner/LearnerAvatar.jsx b/src/discussions/learners/learner/LearnerAvatar.jsx index 7f1e0feb..f980c2d6 100644 --- a/src/discussions/learners/learner/LearnerAvatar.jsx +++ b/src/discussions/learners/learner/LearnerAvatar.jsx @@ -1,23 +1,26 @@ import React from 'react'; -import PropTypes from 'prop-types'; import { Avatar } from '@edx/paragon'; -const LearnerAvatar = ({ username }) => ( -
- -
-); +import { learnerShape } from './proptypes'; + +function LearnerAvatar({ learner }) { + return ( +
+ +
+ ); +} LearnerAvatar.propTypes = { - username: PropTypes.string.isRequired, + learner: learnerShape.isRequired, }; -export default React.memo(LearnerAvatar); +export default LearnerAvatar; diff --git a/src/discussions/learners/learner/LearnerCard.jsx b/src/discussions/learners/learner/LearnerCard.jsx index 97acb808..ea59f090 100644 --- a/src/discussions/learners/learner/LearnerCard.jsx +++ b/src/discussions/learners/learner/LearnerCard.jsx @@ -1,7 +1,10 @@ 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'; @@ -9,11 +12,11 @@ import LearnerAvatar from './LearnerAvatar'; import LearnerFooter from './LearnerFooter'; import { learnerShape } from './proptypes'; -const LearnerCard = ({ learner }) => { - const { - username, threads, inactiveFlags, activeFlags, responses, replies, - } = learner; - const { enableInContextSidebar, learnerUsername, courseId } = useContext(DiscussionContext); +function LearnerCard({ + learner, + courseId, +}) { + const { enableInContextSidebar, learnerUsername } = useContext(DiscussionContext); const linkUrl = discussionsPath(Routes.LEARNERS.POSTS, { 0: enableInContextSidebar ? 'in-context' : undefined, learnerUsername: learner.username, @@ -27,40 +30,32 @@ const LearnerCard = ({ learner }) => { >
- +
- {username} + {learner.username}
- {threads !== null && ( - - )} + {learner.threads === null ? null : }
); -}; +} LearnerCard.propTypes = { learner: learnerShape.isRequired, + courseId: PropTypes.string.isRequired, }; -export default React.memo(LearnerCard); +export default injectIntl(LearnerCard); diff --git a/src/discussions/learners/learner/LearnerFilterBar.jsx b/src/discussions/learners/learner/LearnerFilterBar.jsx index 3afe6ccb..62f7cdec 100644 --- a/src/discussions/learners/learner/LearnerFilterBar.jsx +++ b/src/discussions/learners/learner/LearnerFilterBar.jsx @@ -1,11 +1,11 @@ -import React, { useCallback, useState } from 'react'; +import React, { 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 { useIntl } from '@edx/frontend-platform/i18n'; +import { injectIntl, intlShape } 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 = React.memo(({ +const ActionItem = ({ id, label, value, @@ -38,7 +38,7 @@ const ActionItem = React.memo(({ {label} -)); +); ActionItem.propTypes = { id: PropTypes.string.isRequired, @@ -47,15 +47,16 @@ ActionItem.propTypes = { selected: PropTypes.string.isRequired, }; -const LearnerFilterBar = () => { - const intl = useIntl(); +function LearnerFilterBar({ + intl, +}) { const dispatch = useDispatch(); const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges); const userIsGroupTa = useSelector(selectUserIsGroupTa); const currentSorting = useSelector(selectLearnerSorting()); const [isOpen, setOpen] = useState(false); - const handleSortFilterChange = useCallback((event) => { + const handleSortFilterChange = (event) => { const { name, value } = event.currentTarget; if (name === 'sort') { @@ -67,16 +68,12 @@ const LearnerFilterBar = () => { }, ); } - }, []); - - const handleOnToggle = useCallback(() => { - setOpen(!isOpen); - }, [isOpen]); + }; return ( setOpen(!isOpen)} className="filter-bar collapsible-card-lg border-0" > @@ -127,6 +124,10 @@ const LearnerFilterBar = () => { ); +} + +LearnerFilterBar.propTypes = { + intl: intlShape.isRequired, }; -export default LearnerFilterBar; +export default injectIntl(LearnerFilterBar); diff --git a/src/discussions/learners/learner/LearnerFooter.jsx b/src/discussions/learners/learner/LearnerFooter.jsx index 2ee5bd5b..abd6d1a2 100644 --- a/src/discussions/learners/learner/LearnerFooter.jsx +++ b/src/discussions/learners/learner/LearnerFooter.jsx @@ -1,22 +1,24 @@ import React from 'react'; -import PropTypes from 'prop-types'; import { useSelector } from 'react-redux'; -import { useIntl } from '@edx/frontend-platform/i18n'; +import { injectIntl, intlShape } 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'; -const LearnerFooter = ({ - inactiveFlags, activeFlags, threads, responses, replies, username, -}) => { - const intl = useIntl(); +function LearnerFooter({ + learner, + intl, +}) { const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges); const userIsGroupTa = useSelector(selectUserIsGroupTa); + const { inactiveFlags } = learner; + const { activeFlags } = learner; const canSeeLearnerReportedStats = (activeFlags || inactiveFlags) && (userHasModerationPrivileges || userIsGroupTa); return ( @@ -33,7 +35,7 @@ const LearnerFooter = ({ >
- {threads + responses + replies} + {learner.threads + learner.responses + learner.replies}
- {threads} + {learner.threads}
{Boolean(canSeeLearnerReportedStats) && ( +
{Boolean(activeFlags) && ( @@ -81,24 +83,11 @@ const LearnerFooter = ({ )}
); -}; +} LearnerFooter.propTypes = { - inactiveFlags: PropTypes.number, - activeFlags: PropTypes.number, - threads: PropTypes.number, - responses: PropTypes.number, - replies: PropTypes.number, - username: PropTypes.string, + intl: intlShape.isRequired, + learner: learnerShape.isRequired, }; -LearnerFooter.defaultProps = { - inactiveFlags: 0, - activeFlags: 0, - threads: 0, - responses: 0, - replies: 0, - username: '', -}; - -export default React.memo(LearnerFooter); +export default injectIntl(LearnerFooter); diff --git a/src/discussions/navigation/breadcrumb-menu/BreadcrumbDropdown.jsx b/src/discussions/navigation/breadcrumb-menu/BreadcrumbDropdown.jsx index 8c55dd0f..70886178 100644 --- a/src/discussions/navigation/breadcrumb-menu/BreadcrumbDropdown.jsx +++ b/src/discussions/navigation/breadcrumb-menu/BreadcrumbDropdown.jsx @@ -3,23 +3,22 @@ import PropTypes from 'prop-types'; import { Link } from 'react-router-dom'; -import { useIntl } from '@edx/frontend-platform/i18n'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { Dropdown, DropdownButton } from '@edx/paragon'; import messages from './messages'; -const BreadcrumbDropdown = ({ +function 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,10 +60,9 @@ BreadcrumbDropdown.propTypes = { itemActiveFunc: PropTypes.func.isRequired, itemFilterFunc: PropTypes.func, }; - BreadcrumbDropdown.defaultProps = { currentItem: null, itemFilterFunc: null, }; -export default React.memo(BreadcrumbDropdown); +export default injectIntl(BreadcrumbDropdown); diff --git a/src/discussions/navigation/breadcrumb-menu/LegacyBreadcrumbMenu.jsx b/src/discussions/navigation/breadcrumb-menu/LegacyBreadcrumbMenu.jsx index f9ad455e..d5afd9ff 100644 --- a/src/discussions/navigation/breadcrumb-menu/LegacyBreadcrumbMenu.jsx +++ b/src/discussions/navigation/breadcrumb-menu/LegacyBreadcrumbMenu.jsx @@ -1,4 +1,4 @@ -import React, { useCallback } from 'react'; +import React 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'; -const LegacyBreadcrumbMenu = () => { +function LegacyBreadcrumbMenu() { const { params: { courseId, @@ -21,6 +21,7 @@ const 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', '#'); @@ -29,68 +30,31 @@ const 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} items={nonCoursewareTopics} showAllPath={discussionsPath(Routes.TOPICS.ALL, { courseId })} - itemPathFunc={nonCoursewareItemPath} + itemPathFunc={(topic) => discussionsPath(Routes.TOPICS.TOPIC, { + courseId, + topicId: topic.id, + })} /> ) : ( catId} + itemActiveFunc={(catId) => catId === currentCategory} items={categories} showAllPath={discussionsPath(Routes.TOPICS.ALL, { courseId })} - itemPathFunc={coursewareItemPath} + itemPathFunc={(catId) => discussionsPath(Routes.TOPICS.CATEGORY, { + courseId, + category: catId, + })} /> )} {currentCategory && ( @@ -98,19 +62,24 @@ const LegacyBreadcrumbMenu = () => {
/
item?.name} + itemActiveFunc={(topic) => topic?.id === currentTopicId} items={topicsInCategory} showAllPath={discussionsPath(Routes.TOPICS.CATEGORY, { courseId, category: currentCategory, })} - itemPathFunc={categoryItemPath} + itemPathFunc={(topic) => discussionsPath(Routes.TOPICS.TOPIC, { + courseId, + topicId: topic.id, + })} /> )}
); -}; +} + +LegacyBreadcrumbMenu.propTypes = {}; export default LegacyBreadcrumbMenu; diff --git a/src/discussions/navigation/navigation-bar/NavigationBar.jsx b/src/discussions/navigation/navigation-bar/NavigationBar.jsx index dd7ea886..ee99beb8 100644 --- a/src/discussions/navigation/navigation-bar/NavigationBar.jsx +++ b/src/discussions/navigation/navigation-bar/NavigationBar.jsx @@ -1,23 +1,21 @@ -import React, { useContext, useMemo } from 'react'; +import React from 'react'; -import { matchPath } from 'react-router'; +import { matchPath, useParams } from 'react-router'; import { NavLink } from 'react-router-dom'; -import { useIntl } from '@edx/frontend-platform/i18n'; +import { injectIntl, intlShape } 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'; -const NavigationBar = () => { - const intl = useIntl(); - const { courseId } = useContext(DiscussionContext); +function NavigationBar({ intl }) { + const { courseId } = useParams(); const showLearnersTab = useShowLearnersTab(); - const navLinks = useMemo(() => ([ + const navLinks = [ { route: Routes.POSTS.MY_POSTS, labelMessage: messages.myPosts, @@ -31,23 +29,19 @@ const NavigationBar = () => { isActive: (match, location) => Boolean(matchPath(location.pathname, { path: Routes.TOPICS.PATH })), labelMessage: messages.allTopics, }, - ]), []); - - useMemo(() => { - if (showLearnersTab) { - navLinks.push({ - route: Routes.LEARNERS.PATH, - labelMessage: messages.learners, - }); - } - }, [showLearnersTab]); + ]; + if (showLearnersTab) { + navLinks.push({ + route: Routes.LEARNERS.PATH, + labelMessage: messages.learners, + }); + } return ( -
); +} + +CommentSortDropdown.propTypes = { + intl: intlShape.isRequired, + }; -export default CommentSortDropdown; +export default injectIntl(CommentSortDropdown); diff --git a/src/discussions/post-comments/comments/CommentsView.jsx b/src/discussions/post-comments/comments/CommentsView.jsx index b7319f19..6647ebce 100644 --- a/src/discussions/post-comments/comments/CommentsView.jsx +++ b/src/discussions/post-comments/comments/CommentsView.jsx @@ -1,39 +1,36 @@ -import React, { useCallback, useContext, useState } from 'react'; +import React, { useMemo, useState } from 'react'; import PropTypes from 'prop-types'; -import { useIntl } from '@edx/frontend-platform/i18n'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { Button, Spinner } from '@edx/paragon'; import { EndorsementStatus } from '../../../data/constants'; import { useUserCanAddThreadInBlackoutDate } from '../../data/hooks'; -import { isLastElementOfList } from '../../utils'; +import { filterPosts, isLastElementOfList } from '../../utils'; import { usePostComments } from '../data/hooks'; import messages from '../messages'; -import { PostCommentsContext } from '../postCommentsContext'; import { Comment, ResponseEditor } from './comment'; -const CommentsView = ({ endorsed }) => { - const intl = useIntl(); - const [addingResponse, setAddingResponse] = useState(false); - const { isClosed } = useContext(PostCommentsContext); - const userCanAddThreadInBlackoutDate = useUserCanAddThreadInBlackoutDate(); +function CommentsView({ + postType, + postId, + intl, + endorsed, + isClosed, +}) { const { - endorsedCommentsIds, - unEndorsedCommentsIds, + comments, hasMorePages, isLoading, handleLoadMoreResponses, - } = usePostComments(endorsed); + } = usePostComments(postId, endorsed); - const handleAddResponse = useCallback(() => { - setAddingResponse(true); - }, []); + const endorsedComments = useMemo(() => [...filterPosts(comments, 'endorsed')], [comments]); + const unEndorsedComments = useMemo(() => [...filterPosts(comments, 'unendorsed')], [comments]); + const userCanAddThreadInBlackoutDate = useUserCanAddThreadInBlackoutDate(); + const [addingResponse, setAddingResponse] = useState(false); - const handleCloseResponseEditor = useCallback(() => { - setAddingResponse(false); - }, []); - - const handleDefinition = useCallback((message, commentsLength) => ( + const handleDefinition = (message, commentsLength) => (
{ > {intl.formatMessage(message, { num: commentsLength })}
- ), []); + ); - const handleComments = useCallback((postCommentsIds, showLoadMoreResponses = false) => ( + const handleComments = (postComments, showLoadMoreResponses = false) => (
- {postCommentsIds.map((commentId) => ( + {postComments.map((comment) => ( ))} {hasMorePages && !isLoading && !showLoadMoreResponses && ( @@ -69,26 +68,26 @@ const CommentsView = ({ endorsed }) => {
)}
- ), [hasMorePages, isLoading, handleLoadMoreResponses]); + ); return ( <> {((hasMorePages && isLoading) || !isLoading) && ( <> - {endorsedCommentsIds.length > 0 && ( + {endorsedComments.length > 0 && ( <> - {handleDefinition(messages.endorsedResponseCount, endorsedCommentsIds.length)} + {handleDefinition(messages.endorsedResponseCount, endorsedComments.length)} {endorsed === EndorsementStatus.DISCUSSION - ? handleComments(endorsedCommentsIds, true) - : handleComments(endorsedCommentsIds, false)} + ? handleComments(endorsedComments, true) + : handleComments(endorsedComments, false)} )} {endorsed !== EndorsementStatus.ENDORSED && ( <> - {handleDefinition(messages.responseCount, unEndorsedCommentsIds.length)} - {unEndorsedCommentsIds.length === 0 &&
} - {handleComments(unEndorsedCommentsIds, false)} - {(userCanAddThreadInBlackoutDate && !!unEndorsedCommentsIds.length && !isClosed) && ( + {handleDefinition(messages.responseCount, unEndorsedComments.length)} + {unEndorsedComments.length === 0 &&
} + {handleComments(unEndorsedComments, false)} + {(userCanAddThreadInBlackoutDate && !!unEndorsedComments.length && !isClosed) && (
{!addingResponse && ( )} setAddingResponse(false)} addWrappingDiv addingResponse={addingResponse} - handleCloseEditor={handleCloseResponseEditor} />
)} @@ -115,12 +115,16 @@ const CommentsView = ({ endorsed }) => { )} ); -}; +} 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 React.memo(CommentsView); +export default injectIntl(CommentsView); diff --git a/src/discussions/post-comments/comments/comment/Comment.jsx b/src/discussions/post-comments/comments/comment/Comment.jsx index 93733793..841f8d08 100644 --- a/src/discussions/post-comments/comments/comment/Comment.jsx +++ b/src/discussions/post-comments/comments/comment/Comment.jsx @@ -1,12 +1,13 @@ 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 { useIntl } from '@edx/frontend-platform/i18n'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { Button, useToggle } from '@edx/paragon'; import HTMLLoader from '../../../../components/HTMLLoader'; @@ -14,7 +15,6 @@ 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,123 +22,88 @@ 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'; -const Comment = ({ - commentId, - marginBottom, +function Comment({ + postType, + comment, 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); + isClosedPost, + intl, + marginBottom, +}) { const dispatch = useDispatch(); - const { courseId } = useContext(DiscussionContext); - const { isClosed } = useContext(PostCommentsContext); + const hasChildren = comment.childCount > 0; + const isNested = Boolean(comment.parentId); + const inlineReplies = useSelector(selectCommentResponses(comment.id)); const [isEditing, setEditing] = useState(false); - const [isReplying, setReplying] = useState(false); const [isDeleting, showDeleteConfirmation, hideDeleteConfirmation] = useToggle(false); const [isReporting, showReportConfirmation, hideReportConfirmation] = useToggle(false); - 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 [isReplying, setReplying] = useState(false); + const hasMorePages = useSelector(selectCommentHasMorePages(comment.id)); + const currentPage = useSelector(selectCommentCurrentPage(comment.id)); const userCanAddThreadInBlackoutDate = useUserCanAddThreadInBlackoutDate(); + const { courseId } = useContext(DiscussionContext); + const sortedOrder = useSelector(selectCommentSortOrder); useEffect(() => { // If the comment has a parent comment, it won't have any children, so don't fetch them. if (hasChildren && showFullThread) { - dispatch(fetchCommentResponses(id, { + dispatch(fetchCommentResponses(comment.id, { page: 1, reverseOrder: sortedOrder, })); } - }, [id, sortedOrder]); + }, [comment.id, sortedOrder]); - 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 actions = useActions({ + ...comment, + postType, + }); + const endorseIcons = actions.find(({ action }) => action === EndorsementStatus.ENDORSED); const handleAbusedFlag = useCallback(() => { - if (abuseFlagged) { - dispatch(editComment(id, { flagged: !abuseFlagged })); + if (comment.abuseFlagged) { + dispatch(editComment(comment.id, { flagged: !comment.abuseFlagged })); } else { showReportConfirmation(); } - }, [abuseFlagged, id, showReportConfirmation]); + }, [comment.abuseFlagged, comment.id, dispatch, showReportConfirmation]); - const handleDeleteConfirmation = useCallback(() => { - dispatch(removeComment(id)); + const handleDeleteConfirmation = () => { + dispatch(removeComment(comment.id)); hideDeleteConfirmation(); - }, [id, hideDeleteConfirmation]); + }; - const handleReportConfirmation = useCallback(() => { - dispatch(editComment(id, { flagged: !abuseFlagged })); + const handleReportConfirmation = () => { + dispatch(editComment(comment.id, { flagged: !comment.abuseFlagged })); hideReportConfirmation(); - }, [abuseFlagged, id, hideReportConfirmation]); + }; const actionHandlers = useMemo(() => ({ - [ContentActions.EDIT_CONTENT]: handleEditContent, - [ContentActions.ENDORSE]: handleCommentEndorse, + [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.DELETE]: showDeleteConfirmation, - [ContentActions.REPORT]: handleAbusedFlag, - }), [handleEditContent, handleCommentEndorse, showDeleteConfirmation, handleAbusedFlag]); + [ContentActions.REPORT]: () => handleAbusedFlag(), + }), [showDeleteConfirmation, dispatch, comment.id, comment.endorsed, comment.threadId, courseId, handleAbusedFlag]); - const handleLoadMoreComments = useCallback(() => ( - dispatch(fetchCommentResponses(id, { + const handleLoadMoreComments = () => ( + dispatch(fetchCommentResponses(comment.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 (
@@ -146,7 +111,7 @@ const Comment = ({
- {!abuseFlagged && ( + {!comment.abuseFlagged && ( )} - +
setReplying(true)} + onLike={() => dispatch(editComment(comment.id, { voted: !comment.voted }))} addResponseCommentButtonMessage={intl.formatMessage(messages.addComment)} - onLike={handleCommentLike} - voted={voted} - following={following} + isClosedPost={isClosedPost} endorseIcons={endorseIcons} /> - - - {isEditing ? ( - - ) : ( - - )} - {voted && ( + + + {isEditing + ? ( + setEditing(false)} formClasses="pt-3" /> + ) + : ( + + )} + {comment.voted && (
dispatch(editComment(comment.id, { voted: !comment.voted }))} + voted={comment.voted} />
)} - {inlineRepliesIds.length > 0 && ( + {inlineReplies.length > 0 && (
- {inlineRepliesIds.map(replyId => ( + {/* Pass along intl since component used here is the one before it's injected with `injectIntl` */} + {inlineReplies.map(inlineReply => ( ))}
@@ -259,39 +195,46 @@ const Comment = ({ isReplying ? (
setReplying(false)} />
) : ( - !isClosed && userCanAddThreadInBlackoutDate && (inlineReplies.length >= 5) && ( - - ) + <> + {!isClosedPost && userCanAddThreadInBlackoutDate && (inlineReplies.length >= 5) + && ( + + )} + ) )}
); -}; +} Comment.propTypes = { - commentId: PropTypes.string.isRequired, - marginBottom: PropTypes.bool, + postType: PropTypes.oneOf(['discussion', 'question']).isRequired, + comment: commentShape.isRequired, showFullThread: PropTypes.bool, + isClosedPost: PropTypes.bool, + intl: intlShape.isRequired, + marginBottom: PropTypes.bool, }; Comment.defaultProps = { - marginBottom: false, showFullThread: true, + isClosedPost: false, + marginBottom: true, }; -export default React.memo(Comment); +export default injectIntl(Comment); diff --git a/src/discussions/post-comments/comments/comment/CommentEditor.jsx b/src/discussions/post-comments/comments/comment/CommentEditor.jsx index ed921ee7..422dab6b 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, { useCallback, useContext, useRef } from 'react'; +import React, { useContext, useRef } from 'react'; import PropTypes from 'prop-types'; import { Formik } from 'formik'; import { useSelector } from 'react-redux'; import * as Yup from 'yup'; -import { useIntl } from '@edx/frontend-platform/i18n'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { AppContext } from '@edx/frontend-platform/react'; import { Button, Form, StatefulButton } from '@edx/paragon'; @@ -25,15 +25,12 @@ 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); @@ -45,7 +42,7 @@ function CommentEditor({ const canDisplayEditReason = (reasonCodesEnabled && edit && (userHasModerationPrivileges || userIsGroupTa || userIsStaff) - && author !== authenticatedUser.username + && comment?.author !== authenticatedUser.username ); const editReasonCodeValidation = canDisplayEditReason && { @@ -59,34 +56,34 @@ function CommentEditor({ }); const initialValues = { - comment: rawBody, - editReasonCode: lastEdit?.reasonCode || (userIsStaff ? 'violates-guidelines' : ''), + comment: comment.rawBody, + editReasonCode: comment?.lastEdit?.reasonCode || (userIsStaff ? 'violates-guidelines' : ''), }; - const handleCloseEditor = useCallback((resetForm) => { + const handleCloseEditor = (resetForm) => { resetForm({ values: initialValues }); onCloseEditor(); - }, [onCloseEditor, initialValues]); + }; - const saveUpdatedComment = useCallback(async (values, { resetForm }) => { - if (id) { + const saveUpdatedComment = async (values, { resetForm }) => { + if (comment.id) { const payload = { ...values, editReasonCode: values.editReasonCode || undefined, }; - await dispatch(editComment(id, payload)); + await dispatch(editComment(comment.id, payload)); } else { - await dispatch(addComment(values.comment, threadId, parentId, enableInContextSidebar)); + await dispatch(addComment(values.comment, comment.threadId, comment.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-${id || parentId || threadId}`; + const editorId = `comment-editor-${comment.id || comment.parentId || comment.threadId}`; return ( { - const colorClass = AvatarOutlineAndLabelColors[authorLabel]; - const hasAnyAlert = useAlertBannerVisible({ - author, - abuseFlagged, - lastEdit, - closed, - }); +function CommentHeader({ + comment, +}) { + const colorClass = AvatarOutlineAndLabelColors[comment.authorLabel]; + const hasAnyAlert = useAlertBannerVisible(comment); return (
); -}; +} 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, - }), + comment: commentShape.isRequired, }; -CommentHeader.defaultProps = { - authorLabel: null, - closed: undefined, - lastEdit: null, -}; - -export default React.memo(CommentHeader); +export default injectIntl(CommentHeader); diff --git a/src/discussions/post-comments/comments/comment/Reply.jsx b/src/discussions/post-comments/comments/comment/Reply.jsx index aa59683f..d0fcc366 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, useSelector } from 'react-redux'; +import { useDispatch } from 'react-redux'; import * as timeago from 'timeago.js'; -import { useIntl } from '@edx/frontend-platform/i18n'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { Avatar, useToggle } from '@edx/paragon'; import HTMLLoader from '../../../../components/HTMLLoader'; @@ -13,71 +13,57 @@ 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'; -const Reply = ({ responseId }) => { +function Reply({ + reply, + postType, + intl, +}) { 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 (abuseFlagged) { - dispatch(editComment(id, { flagged: !abuseFlagged })); + if (reply.abuseFlagged) { + dispatch(editComment(reply.id, { flagged: !reply.abuseFlagged })); } else { showReportConfirmation(); } - }, [abuseFlagged, id, showReportConfirmation]); + }, [dispatch, reply.abuseFlagged, reply.id, showReportConfirmation]); - const handleCloseEditor = useCallback(() => { - setEditing(false); - }, []); + const handleDeleteConfirmation = () => { + dispatch(removeComment(reply.id)); + hideDeleteConfirmation(); + }; + + const handleReportConfirmation = () => { + dispatch(editComment(reply.id, { flagged: !reply.abuseFlagged })); + hideReportConfirmation(); + }; const actionHandlers = useMemo(() => ({ - [ContentActions.EDIT_CONTENT]: handleEditContent, - [ContentActions.ENDORSE]: handleReplyEndorse, + [ContentActions.EDIT_CONTENT]: () => setEditing(true), + [ContentActions.ENDORSE]: () => dispatch(editComment( + reply.id, + { endorsed: !reply.endorsed }, + ContentActions.ENDORSE, + )), [ContentActions.DELETE]: showDeleteConfirmation, - [ContentActions.REPORT]: handleAbusedFlag, - }), [handleEditContent, handleReplyEndorse, showDeleteConfirmation, handleAbusedFlag]); + [ContentActions.REPORT]: () => handleAbusedFlag(), + }), [dispatch, handleAbusedFlag, reply.endorsed, reply.id, showDeleteConfirmation]); + + const colorClass = AvatarOutlineAndLabelColors[reply.authorLabel]; + const hasAnyAlert = useAlertBannerVisible(reply); return ( -
+
{ closeButtonVaraint="tertiary" confirmButtonText={intl.formatMessage(messages.deleteConfirmationDelete)} /> - {!abuseFlagged && ( + {!reply.abuseFlagged && ( {
- +
)} +
{ >
- {isEditing ? ( - - ) : ( - - )} + {isEditing + ? setEditing(false)} /> + : ( + + )}
); -}; - +} Reply.propTypes = { - responseId: PropTypes.string.isRequired, + postType: PropTypes.oneOf(['discussion', 'question']).isRequired, + reply: commentShape.isRequired, + intl: intlShape.isRequired, }; - -export default React.memo(Reply); +export default injectIntl(Reply); diff --git a/src/discussions/post-comments/comments/comment/ResponseEditor.jsx b/src/discussions/post-comments/comments/comment/ResponseEditor.jsx index 33714fed..aae380a9 100644 --- a/src/discussions/post-comments/comments/comment/ResponseEditor.jsx +++ b/src/discussions/post-comments/comments/comment/ResponseEditor.jsx @@ -1,34 +1,36 @@ -import React, { useContext, useEffect } from 'react'; +import React, { useEffect } from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; -import { DiscussionContext } from '../../../common/context'; +import { injectIntl } from '@edx/frontend-platform/i18n'; + import CommentEditor from './CommentEditor'; -const ResponseEditor = ({ +function ResponseEditor({ + postId, 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, @@ -38,4 +40,4 @@ ResponseEditor.defaultProps = { addWrappingDiv: false, }; -export default React.memo(ResponseEditor); +export default injectIntl(ResponseEditor); diff --git a/src/discussions/post-comments/data/api.js b/src/discussions/post-comments/data/api.js index 09b8acca..999abf5f 100644 --- a/src/discussions/post-comments/data/api.js +++ b/src/discussions/post-comments/data/api.js @@ -27,7 +27,6 @@ export async function getThreadComments( pageSize, reverseOrder, enableInContextSidebar = false, - signal, } = {}, ) { const params = snakeCaseObject({ @@ -41,7 +40,7 @@ export async function getThreadComments( }); const { data } = await getAuthenticatedHttpClient() - .get(getCommentsApiUrl(), { params: { ...params, signal } }); + .get(getCommentsApiUrl(), { params }); return data; } diff --git a/src/discussions/post-comments/data/hooks.js b/src/discussions/post-comments/data/hooks.js index 75fe0371..5cb072b0 100644 --- a/src/discussions/post-comments/data/hooks.js +++ b/src/discussions/post-comments/data/hooks.js @@ -1,6 +1,4 @@ -import { - useCallback, useContext, useEffect, useMemo, -} from 'react'; +import { useContext, useEffect } from 'react'; import { useDispatch, useSelector } from 'react-redux'; @@ -11,21 +9,20 @@ 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'; -const trackLoadMoreEvent = (postId, params) => ( +function trackLoadMoreEvent(postId, params) { sendTrackEvent( 'edx.forum.responses.loadMore', { postId, params, }, - ) -); + ); +} export function usePost(postId) { const dispatch = useDispatch(); @@ -37,26 +34,18 @@ export function usePost(postId) { } }, [postId]); - return thread || {}; + return thread; } -export function usePostComments(endorsed = null) { - const { enableInContextSidebar, postId } = useContext(DiscussionContext); +export function usePostComments(postId, endorsed = null) { 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 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 handleLoadMoreResponses = async () => { const params = { endorsed, page: currentPage + 1, @@ -64,27 +53,19 @@ export function usePostComments(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, })); - - return () => { - abortController.abort(); - }; - }, [postId, endorsed, reverseOrder, enableInContextSidebar]); + }, [postId, reverseOrder]); return { - endorsedCommentsIds, - unEndorsedCommentsIds, + comments, hasMorePages, isLoading, handleLoadMoreResponses, @@ -96,9 +77,5 @@ export function useCommentsCount(postId) { const endorsedQuestions = useSelector(selectThreadComments(postId, EndorsementStatus.ENDORSED)); const unendorsedQuestions = useSelector(selectThreadComments(postId, EndorsementStatus.UNENDORSED)); - const commentsLength = useMemo(() => ( - [...discussions, ...endorsedQuestions, ...unendorsedQuestions].length - ), [discussions, endorsedQuestions, unendorsedQuestions]); - - return commentsLength; + return [...discussions, ...endorsedQuestions, ...unendorsedQuestions].length; } diff --git a/src/discussions/post-comments/data/selectors.js b/src/discussions/post-comments/data/selectors.js index 960cb8ac..c8769b71 100644 --- a/src/discussions/post-comments/data/selectors.js +++ b/src/discussions/post-comments/data/selectors.js @@ -4,11 +4,6 @@ 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] || [], @@ -17,10 +12,6 @@ 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 3cb22227..92f9cefb 100644 --- a/src/discussions/post-comments/data/thunks.js +++ b/src/discussions/post-comments/data/thunks.js @@ -81,14 +81,13 @@ export function fetchThreadComments( reverseOrder, endorsed = EndorsementStatus.DISCUSSION, enableInContextSidebar, - signal, } = {}, ) { return async (dispatch) => { try { dispatch(fetchCommentsRequest()); const data = await getThreadComments(threadId, { - page, reverseOrder, endorsed, enableInContextSidebar, signal, + page, reverseOrder, endorsed, enableInContextSidebar, }); dispatch(fetchCommentsSuccess({ ...normaliseComments(camelCaseObject(data)), diff --git a/src/discussions/post-comments/postCommentsContext.js b/src/discussions/post-comments/postCommentsContext.js deleted file mode 100644 index 89be6d70..00000000 --- a/src/discussions/post-comments/postCommentsContext.js +++ /dev/null @@ -1,8 +0,0 @@ -/* 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 73654d0d..fbc99a50 100644 --- a/src/discussions/posts/NoResults.jsx +++ b/src/discussions/posts/NoResults.jsx @@ -1,16 +1,13 @@ -import React from 'react'; - import classNames from 'classnames'; import { useSelector } from 'react-redux'; -import { useIntl } from '@edx/frontend-platform/i18n'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { selectAreThreadsFiltered } from '../data/selectors'; import { selectTopicFilter } from '../in-context-topics/data/selectors'; import messages from '../messages'; -const NoResults = () => { - const intl = useIntl(); +function NoResults({ intl }) { const postsFiltered = useSelector(selectAreThreadsFiltered); const inContextTopicsFilter = useSelector(selectTopicFilter); const topicsFilter = useSelector(({ topics }) => topics.filter); @@ -20,7 +17,6 @@ const NoResults = () => { || (learnersFilter !== null) || (inContextTopicsFilter !== ''); let helpMessage = messages.removeFilters; - if (!isFiltered) { return null; } if (filters.search || learnersFilter) { @@ -28,7 +24,6 @@ const NoResults = () => { } if (topicsFilter || inContextTopicsFilter) { helpMessage = messages.removeKeywordsOnly; } - const titleCssClasses = classNames( { 'font-weight-normal text-primary-500': topicsFilter || learnersFilter }, ); @@ -42,6 +37,10 @@ const NoResults = () => { {intl.formatMessage(helpMessage)} ); +} + +NoResults.propTypes = { + intl: intlShape.isRequired, }; -export default NoResults; +export default injectIntl(NoResults); diff --git a/src/discussions/posts/PostsList.jsx b/src/discussions/posts/PostsList.jsx index 28c30c49..e0ecbff0 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 { useIntl } from '@edx/frontend-platform/i18n'; +import { injectIntl, intlShape } 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 { usePostList } from './data/hooks'; +import { filterPosts } from '../utils'; import { selectThreadFilters, selectThreadNextPage, selectThreadSorting, threadsLoadingStatus, } from './data/selectors'; @@ -22,24 +22,25 @@ import { fetchThreads } from './data/thunks'; import NoResults from './NoResults'; import { PostLink } from './post'; -const PostsList = ({ - postsIds, topicsIds, isTopicTab, parentIsLoading, -}) => { - const intl = useIntl(); +function PostsList({ + posts, topics, intl, isTopicTab, parentIsLoading, +}) { const dispatch = useDispatch(); - const { authenticatedUser } = useContext(AppContext); - const { courseId, page } = useContext(DiscussionContext); + const { + courseId, + page, + } = useContext(DiscussionContext); const loadingStatus = useSelector(threadsLoadingStatus()); + const { authenticatedUser } = useContext(AppContext); 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 = useCallback((topicIds, pageNum = undefined, isFilterChanged = false) => { + const loadThreads = (topicIds, pageNum = undefined, isFilterChanged = false) => { const params = { orderBy, filters, @@ -49,68 +50,75 @@ const 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 (topicsIds !== undefined && configStatus === RequestStatus.SUCCESSFUL) { - loadThreads(topicsIds); + if (topics !== undefined && configStatus === RequestStatus.SUCCESSFUL) { + loadThreads(topics); } - }, [courseId, filters, orderBy, page, JSON.stringify(topicsIds), configStatus]); + }, [courseId, filters, orderBy, page, JSON.stringify(topics), configStatus]); useEffect(() => { - if (isTopicTab) { - loadThreads(topicsIds, 1, true); - } + if (isTopicTab) { loadThreads(topics, 1, true); } }, [filters]); - const postInstances = useMemo(() => ( - sortedPostsIds?.map((postId, idx) => ( + 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) => ( )) - ), [sortedPostsIds]); + ), []); return ( <> - {!parentIsLoading && postInstances} - {sortedPostsIds?.length === 0 && loadingStatus === RequestStatus.SUCCESSFUL && } + {!parentIsLoading && postInstances(pinnedPosts)} + {!parentIsLoading && postInstances(unpinnedPosts)} + {posts?.length === 0 && loadingStatus === RequestStatus.SUCCESSFUL && } {loadingStatus === RequestStatus.IN_PROGRESS || parentIsLoading ? (
) : ( nextPage && loadingStatus === RequestStatus.SUCCESSFUL && ( - ) )} ); -}; +} PostsList.propTypes = { - postsIds: PropTypes.arrayOf(PropTypes.string), - topicsIds: PropTypes.arrayOf(PropTypes.string), + posts: PropTypes.arrayOf(PropTypes.shape({ + pinned: PropTypes.bool.isRequired, + id: PropTypes.string.isRequired, + })), + topics: PropTypes.arrayOf(PropTypes.string), isTopicTab: PropTypes.bool, parentIsLoading: PropTypes.bool, + intl: intlShape.isRequired, }; PostsList.defaultProps = { - postsIds: [], - topicsIds: undefined, + posts: [], + topics: undefined, isTopicTab: false, parentIsLoading: undefined, }; -export default React.memo(PostsList); +export default injectIntl(PostsList); diff --git a/src/discussions/posts/PostsView.jsx b/src/discussions/posts/PostsView.jsx index 883d5052..fe050bcc 100644 --- a/src/discussions/posts/PostsView.jsx +++ b/src/discussions/posts/PostsView.jsx @@ -1,6 +1,4 @@ -import React, { - useCallback, useContext, useEffect, useMemo, -} from 'react'; +import React, { useContext, useEffect } from 'react'; import PropTypes from 'prop-types'; import isEmpty from 'lodash/isEmpty'; @@ -15,42 +13,39 @@ 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 { selectAllThreadsIds, selectTopicThreadsIds } from './data/selectors'; +import { selectAllThreads, selectTopicThreads } from './data/selectors'; import { setSearchQuery } from './data/slices'; import PostFilterBar from './post-filter-bar/PostFilterBar'; import PostsList from './PostsList'; -const AllPostsList = () => { - const postsIds = useSelector(selectAllThreadsIds); +function AllPostsList() { + const posts = useSelector(selectAllThreads); + return ; +} - return ; -}; - -const TopicPostsList = React.memo(({ topicId }) => { - const postsIds = useSelector(selectTopicThreadsIds([topicId])); - - return ; -}); +function TopicPostsList({ topicId }) { + const posts = useSelector(selectTopicThreads([topicId])); + return ; +} TopicPostsList.propTypes = { topicId: PropTypes.string.isRequired, }; -const CategoryPostsList = React.memo(({ category }) => { +function CategoryPostsList({ 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 postsIds = useSelector(enableInContextSidebar ? selectAllThreadsIds : selectTopicThreadsIds(topicIds)); - - return ; -}); + const posts = useSelector(enableInContextSidebar ? selectAllThreads : selectTopicThreads(topicIds)); + return ; +} CategoryPostsList.propTypes = { category: PropTypes.string.isRequired, }; -const PostsView = () => { +function PostsView() { const { topicId, category, @@ -73,19 +68,15 @@ const PostsView = () => { } }, [topics]); - const handleOnClear = useCallback(() => { - dispatch(setSearchQuery('')); - }, []); + let postsListComponent; - const postsListComponent = useMemo(() => { - if (topicId) { - return ; - } - if (category) { - return ; - } - return ; - }, [topicId, category]); + if (topicId) { + postsListComponent = ; + } else if (category) { + postsListComponent = ; + } else { + postsListComponent = ; + } return (
@@ -94,7 +85,7 @@ const PostsView = () => { count={resultsFound} text={searchString} loadingStatus={loadingStatus} - onClear={handleOnClear} + onClear={() => dispatch(setSearchQuery(''))} textSearchRewrite={textSearchRewrite} /> )} @@ -105,6 +96,9 @@ const PostsView = () => {
); +} + +PostsView.propTypes = { }; export default PostsView; diff --git a/src/discussions/posts/data/hooks.js b/src/discussions/posts/data/hooks.js deleted file mode 100644 index 8837f9a0..00000000 --- a/src/discussions/posts/data/hooks.js +++ /dev/null @@ -1,26 +0,0 @@ -/* 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 9693dd12..9f0fd55a 100644 --- a/src/discussions/posts/data/selectors.js +++ b/src/discussions/posts/data/selectors.js @@ -16,15 +16,6 @@ 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], @@ -46,11 +37,6 @@ 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 33ce00d4..754dfa3d 100644 --- a/src/discussions/posts/data/slices.js +++ b/src/discussions/posts/data/slices.js @@ -15,10 +15,7 @@ const mergeThreadsInTopics = (dataFromState, dataFromPayload) => { const values = Object.values(obj); keys.forEach((key, index) => { if (!acc[key]) { acc[key] = []; } - if (Array.isArray(acc[key])) { - const uniqueValues = [...new Set(acc[key].concat(values[index]))]; - acc[key] = uniqueValues; - } else { acc[key].push(values[index]); } + if (Array.isArray(acc[key])) { acc[key] = acc[key].concat(values[index]); } else { acc[key].push(values[index]); } return acc; }); return acc; @@ -71,29 +68,29 @@ const threadsSlice = createSlice({ state.status = RequestStatus.IN_PROGRESS; }, fetchThreadsSuccess: (state, { payload }) => { - const { - author, page, ids, threadsById, isFilterChanged, threadsInTopic, avatars, pagination, textSearchRewrite, - } = payload; - - if (state.author !== author) { + if (state.author !== payload.author) { state.pages = []; - state.author = author; + state.author = payload.author; } - if (!state.pages[page - 1]) { - state.pages[page - 1] = ids; + if (state.pages[payload.page - 1]) { + state.pages[payload.page - 1] = [...state.pages[payload.page - 1], ...payload.ids]; } else { - state.pages[page - 1] = [...new Set([...state.pages[page - 1], ...ids])]; + state.pages[payload.page - 1] = payload.ids; } state.status = RequestStatus.SUCCESSFUL; - 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; + 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; }, 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 7aac9f98..d5dbe05a 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, { useCallback, useContext } from 'react'; +import React, { useContext } from 'react'; import classNames from 'classnames'; import { useDispatch, useSelector } from 'react-redux'; -import { useIntl } from '@edx/frontend-platform/i18n'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { Button, Icon, IconButton, } from '@edx/paragon'; @@ -21,21 +21,18 @@ import messages from './messages'; import './actionBar.scss'; -const PostActionsBar = () => { - const intl = useIntl(); +function PostActionsBar({ + intl, +}) { const dispatch = useDispatch(); const loadingStatus = useSelector(selectconfigLoadingStatus); const enableInContext = useSelector(selectEnableInContext); const userCanAddThreadInBlackoutDate = useUserCanAddThreadInBlackoutDate(); const { enableInContextSidebar, page } = useContext(DiscussionContext); - const handleCloseInContext = useCallback(() => { + const handleCloseInContext = () => { postMessageToParent('learning.events.sidebar.close'); - }, []); - - const handleAddPost = useCallback(() => { - dispatch(showPostEditor()); - }, []); + }; return (
@@ -56,7 +53,7 @@ const PostActionsBar = () => { variant={enableInContextSidebar ? 'plain' : 'brand'} className={classNames('my-0 font-style border-0 line-height-24', { 'px-3 py-10px border-0': enableInContextSidebar })} - onClick={handleAddPost} + onClick={() => dispatch(showPostEditor())} size={enableInContextSidebar ? 'md' : 'sm'} > {intl.formatMessage(messages.addAPost)} @@ -80,6 +77,10 @@ const PostActionsBar = () => { )}
); +} + +PostActionsBar.propTypes = { + intl: intlShape.isRequired, }; -export default PostActionsBar; +export default injectIntl(PostActionsBar); diff --git a/src/discussions/posts/post-editor/PostEditor.jsx b/src/discussions/posts/post-editor/PostEditor.jsx index 26f97b21..b4c67e45 100644 --- a/src/discussions/posts/post-editor/PostEditor.jsx +++ b/src/discussions/posts/post-editor/PostEditor.jsx @@ -1,8 +1,9 @@ import React, { - useCallback, useContext, useEffect, useRef, + 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'; @@ -12,7 +13,7 @@ import * as Yup from 'yup'; import { useIntl } from '@edx/frontend-platform/i18n'; import { AppContext } from '@edx/frontend-platform/react'; import { - Button, Form, Spinner, StatefulButton, + Button, Card, Form, Spinner, StatefulButton, } from '@edx/paragon'; import { Help, Post } from '@edx/paragon/icons'; @@ -48,22 +49,58 @@ import { hidePostEditor } from '../data'; import { selectThread } from '../data/selectors'; import { createNewThread, fetchThread, updateExistingThread } from '../data/thunks'; import messages from './messages'; -import PostTypeCard from './PostTypeCard'; -const PostEditor = ({ +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({ editExisting, -}) => { +}) { const intl = useIntl(); - const history = useHistory(); - const location = useLocation(); + const { authenticatedUser } = useContext(AppContext); const dispatch = useDispatch(); const editorRef = useRef(null); - const { courseId, postId } = useParams(); - const { authenticatedUser } = useContext(AppContext); + const [submitting, dispatchSubmit] = useDispatchWithState(); + const history = useHistory(); + const location = useLocation(); + const commentsPagePath = useCommentsPagePath(); + const { + courseId, + postId, + } = useParams(); 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); @@ -77,7 +114,6 @@ const 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) @@ -88,7 +124,7 @@ const PostEditor = ({ editReasonCode: Yup.string().required(intl.formatMessage(messages.editReasonCodeError)), }; - const canSelectCohort = useCallback((tId) => { + const canSelectCohort = (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) { @@ -99,7 +135,7 @@ const PostEditor = ({ } const isCohorting = settings.alwaysDivideInlineDiscussions || settings.dividedInlineDiscussions.includes(tId); return isCohorting; - }, [nonCoursewareIds, settings, userHasModerationPrivileges]); + }; const initialValues = { postType: post?.type || 'discussion', @@ -109,13 +145,11 @@ const 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 = useCallback((resetForm) => { + const hideEditor = (resetForm) => { resetForm({ values: initialValues }); if (editExisting) { const newLocation = discussionsPath(commentsPagePath, { @@ -128,14 +162,10 @@ const 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 = useCallback((cohort) => ( - cohort === 'default' ? null : cohort), - []); - - const submitForm = useCallback(async (values, { resetForm }) => { + const selectedCohort = (cohort) => (cohort === 'default' ? null : cohort); + const submitForm = async (values, { resetForm }) => { if (editExisting) { await dispatchSubmit(updateExistingThread(postId, { topicId: values.topic, @@ -165,10 +195,7 @@ const PostEditor = ({ editorRef.current.plugins.autosave.removeDraft(); } hideEditor(resetForm); - }, [ - allowAnonymous, allowAnonymousToPeers, canSelectCohort, editExisting, - enableInContextSidebar, hideEditor, postId, selectedCohort, topicId, - ]); + }; useEffect(() => { if (userHasModerationPrivileges && isEmpty(cohorts)) { @@ -219,6 +246,8 @@ const PostEditor = ({ ...editReasonCodeValidation, }); + const postEditorId = `post-editor-${editExisting ? postId : 'new'}`; + const handleInContextSelectLabel = (section, subsection) => ( `${section.displayName} / ${subsection.displayName}` || intl.formatMessage(messages.unnamedTopics) ); @@ -229,65 +258,66 @@ const 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 => ( + + ))} + + )} + + ) : ( + coursewareTopics.map(categoryObj => ( + + {categoryObj.topics.map(subtopic => ( + + ))} + + )) + )} + + + {canSelectCohort(values.topic) && ( + + + + {cohorts.map(cohort => ( + ))} - {(userIsStaff || userIsGroupTa || userHasModerationPrivileges) && ( - - {archivedTopics.map(topic => ( - - ))} - - )} - - ) : ( - coursewareTopics.map(categoryObj => ( - - {categoryObj.topics.map(subtopic => ( - - ))} - - )) - )} - - - {canSelectCohort(values.topic) && ( - - + + )} +
+ +
+ - - {cohorts.map(cohort => ( - - ))} - - - )} -
-
- - - - - {canDisplayEditReason && ( - - - - {editReasons.map(({ code, label }) => ( - - ))} - - - - )} -
-
- + + + {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, @@ -475,4 +510,4 @@ PostEditor.defaultProps = { editExisting: false, }; -export default React.memo(PostEditor); +export default PostEditor; diff --git a/src/discussions/posts/post-editor/PostTypeCard.jsx b/src/discussions/posts/post-editor/PostTypeCard.jsx deleted file mode 100644 index b0a28094..00000000 --- a/src/discussions/posts/post-editor/PostTypeCard.jsx +++ /dev/null @@ -1,44 +0,0 @@ -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 3633096e..d0cd89e0 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, { - useCallback, useContext, useEffect, useMemo, useState, + 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 { useIntl } from '@edx/frontend-platform/i18n'; +import { injectIntl, intlShape } 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 = React.memo(({ +export const ActionItem = ({ id, label, value, @@ -52,7 +52,7 @@ export const ActionItem = React.memo(({ {label} -)); +); ActionItem.propTypes = { id: PropTypes.string.isRequired, @@ -61,8 +61,9 @@ ActionItem.propTypes = { selected: PropTypes.string.isRequired, }; -const PostFilterBar = () => { - const intl = useIntl(); +function PostFilterBar({ + intl, +}) { const dispatch = useDispatch(); const { courseId } = useParams(); const { page } = useContext(DiscussionContext); @@ -74,13 +75,11 @@ const PostFilterBar = () => { const cohorts = useSelector(selectCourseCohorts); const [isOpen, setOpen] = useState(false); - const selectedCohort = useMemo(() => ( - cohorts.find(cohort => ( - toString(cohort.id) === currentFilters.cohort - )) - ), [cohorts, currentFilters.cohort]); + const selectedCohort = useMemo(() => cohorts.find(cohort => ( + toString(cohort.id) === currentFilters.cohort)), + [currentFilters.cohort]); - const handleSortFilterChange = useCallback((event) => { + const handleSortFilterChange = (event) => { const currentType = currentFilters.postType; const currentStatus = currentFilters.status; const { @@ -94,7 +93,6 @@ const PostFilterBar = () => { cohortFilter: selectedCohort, triggeredBy: name, }; - if (name === 'type') { dispatch(setPostsTypeFilter(value)); if ( @@ -105,7 +103,6 @@ const PostFilterBar = () => { } filterContentEventProperties.threadTypeFilter = value; } - if (name === 'status') { dispatch(setStatusFilter(value)); if (value === PostsStatusFilter.UNANSWERED && currentType !== ThreadType.QUESTION) { @@ -118,23 +115,16 @@ const 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)) { @@ -142,48 +132,10 @@ const PostFilterBar = () => { } }, [courseId, userHasModerationPrivileges]); - const renderCohortFilter = useMemo(() => ( - userHasModerationPrivileges && ( - <> -
- {status === RequestStatus.IN_PROGRESS ? ( -
- -
- ) : ( -
- - - {cohorts.map(cohort => ( - - ))} - -
- )} - - ) - ), [cohorts, currentFilters.cohort, handleSortFilterChange, status, userHasModerationPrivileges]); - return ( setOpen(!isOpen)} className="filter-bar collapsible-card-lg border-0" > @@ -205,6 +157,7 @@ const PostFilterBar = () => { +
@@ -307,11 +260,49 @@ const PostFilterBar = () => { />
- {renderCohortFilter} + {userHasModerationPrivileges && ( + <> +
+ {status === RequestStatus.IN_PROGRESS ? ( +
+ +
+ ) : ( +
+ + + {cohorts.map(cohort => ( + + ))} + +
+ )} + + )} ); +} + +PostFilterBar.propTypes = { + intl: intlShape.isRequired, }; -export default React.memo(PostFilterBar); +export default injectIntl(PostFilterBar); diff --git a/src/discussions/posts/post/ClosePostReasonModal.jsx b/src/discussions/posts/post/ClosePostReasonModal.jsx index d3678932..9e287b32 100644 --- a/src/discussions/posts/post/ClosePostReasonModal.jsx +++ b/src/discussions/posts/post/ClosePostReasonModal.jsx @@ -1,11 +1,9 @@ -import React, { - useCallback, useEffect, useRef, useState, -} from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import PropTypes from 'prop-types'; import { useSelector } from 'react-redux'; -import { useIntl } from '@edx/frontend-platform/i18n'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { ActionRow, Button, @@ -16,23 +14,24 @@ import { import { selectModerationSettings } from '../../data/selectors'; import messages from './messages'; -const ClosePostReasonModal = ({ +function ClosePostReasonModal({ + intl, isOpen, onCancel, onConfirm, -}) => { - const intl = useIntl(); +}) { const scrollTo = useRef(null); const [reasonCode, setReasonCode] = useState(null); + const { postCloseReasons } = useSelector(selectModerationSettings); - const onChange = useCallback(event => { + const onChange = 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. */ @@ -88,12 +87,13 @@ const ClosePostReasonModal = ({ ); -}; +} ClosePostReasonModal.propTypes = { + intl: intlShape.isRequired, isOpen: PropTypes.bool.isRequired, onCancel: PropTypes.func.isRequired, onConfirm: PropTypes.func.isRequired, }; -export default React.memo(ClosePostReasonModal); +export default injectIntl(ClosePostReasonModal); diff --git a/src/discussions/posts/post/LikeButton.jsx b/src/discussions/posts/post/LikeButton.jsx index 883b0389..2702f324 100644 --- a/src/discussions/posts/post/LikeButton.jsx +++ b/src/discussions/posts/post/LikeButton.jsx @@ -1,7 +1,7 @@ -import React, { useCallback } from 'react'; +import React from 'react'; import PropTypes from 'prop-types'; -import { useIntl } from '@edx/frontend-platform/i18n'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { Icon, IconButton, OverlayTrigger, Tooltip, } from '@edx/paragon'; @@ -9,16 +9,19 @@ import { import { ThumbUpFilled, ThumbUpOutline } from '../../../components/icons'; import messages from './messages'; -const LikeButton = ({ count, onClick, voted }) => { - const intl = useIntl(); - - const handleClick = useCallback((e) => { +function LikeButton({ + count, + intl, + onClick, + voted, +}) { + const handleClick = (e) => { e.preventDefault(); if (onClick) { onClick(); } return false; - }, []); + }; return (
@@ -44,10 +47,11 @@ const LikeButton = ({ count, onClick, voted }) => {
); -}; +} LikeButton.propTypes = { count: PropTypes.number.isRequired, + intl: intlShape.isRequired, onClick: PropTypes.func, voted: PropTypes.bool, }; @@ -57,4 +61,4 @@ LikeButton.defaultProps = { onClick: undefined, }; -export default React.memo(LikeButton); +export default injectIntl(LikeButton); diff --git a/src/discussions/posts/post/Post.jsx b/src/discussions/posts/post/Post.jsx index faff8636..a4556b09 100644 --- a/src/discussions/posts/post/Post.jsx +++ b/src/discussions/posts/post/Post.jsx @@ -2,12 +2,11 @@ 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 { useIntl } from '@edx/frontend-platform/i18n'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { Hyperlink, useToggle } from '@edx/paragon'; import HTMLLoader from '../../../components/HTMLLoader'; @@ -16,119 +15,102 @@ 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'; -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(); +function Post({ + post, + intl, + handleAddResponseButton, +}) { const location = useLocation(); const history = useHistory(); const dispatch = useDispatch(); + const { enableInContextSidebar } = useContext(DiscussionContext); const courseId = useSelector((state) => state.config.id); - const topic = useSelector(selectTopic(topicId)); + const topic = useSelector(selectTopic(post.topicId)); const getTopicSubsection = useSelector(selectorForUnitSubsection); - const topicContext = useSelector(selectTopicContext(topicId)); + const topicContext = useSelector(selectTopicContext(post.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 = following || voteCount || closed || (groupId && userHasModerationPrivileges); + const displayPostFooter = post.following || post.voteCount || post.closed + || (post.groupId && userHasModerationPrivileges); - const handleDeleteConfirmation = useCallback(async () => { - await dispatch(removeThread(postId)); + 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)); history.push({ pathname: '.', search: enableInContextSidebar && '?inContextSidebar', }); hideDeleteConfirmation(); - }, [enableInContextSidebar, postId, hideDeleteConfirmation]); + }; - const handleReportConfirmation = useCallback(() => { - dispatch(updateExistingThread(postId, { flagged: !abuseFlagged })); + const handleReportConfirmation = () => { + dispatch(updateExistingThread(post.id, { flagged: !post.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]: handlePostContentEdit, + [ContentActions.EDIT_CONTENT]: () => history.push({ + ...location, + pathname: `${location.pathname}/edit`, + }), [ContentActions.DELETE]: showDeleteConfirmation, - [ContentActions.CLOSE]: handlePostClose, - [ContentActions.COPY_LINK]: handlePostCopyLink, - [ContentActions.PIN]: handlePostPin, - [ContentActions.REPORT]: handlePostReport, + [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(), }), [ - handlePostClose, handlePostContentEdit, handlePostCopyLink, handlePostPin, handlePostReport, showDeleteConfirmation, + showDeleteConfirmation, + history, + location, + post.closed, + post.id, + post.pinned, + reasonCodesEnabled, + dispatch, + showClosePostModal, + courseId, + handleAbusedFlag, ]); - const handleClosePostConfirmation = useCallback((closeReasonCode) => { - dispatch(updateExistingThread(postId, { closed: true, closeReasonCode })); - hideClosePostModal(); - }, [postId, hideClosePostModal]); - - const handlePostLike = useCallback(() => { - dispatch(updateExistingThread(postId, { voted: !voted })); - }, [postId, voted]); - - const handlePostFollow = useCallback(() => { - dispatch(updateExistingThread(postId, { following: !following })); - }, [postId, following]); - - const getTopicCategoryName = useCallback(topicData => ( + const getTopicCategoryName = topicData => ( topicData.usageKey ? getTopicSubsection(topicData.usageKey)?.displayName : topicData.categoryId - ), [getTopicSubsection]); + ); - const getTopicInfo = useCallback(topicData => ( + const getTopicInfo = topicData => ( getTopicCategoryName(topicData) ? `${getTopicCategoryName(topicData)} / ${topicData.name}` : `${topicData.name}` - ), [getTopicCategoryName]); + ); return (
@@ -141,7 +123,7 @@ const Post = ({ handleAddResponseButton }) => { closeButtonVaraint="tertiary" confirmButtonText={intl.formatMessage(messages.deleteConfirmationDelete)} /> - {!abuseFlagged && ( + {!post.abuseFlagged && ( { /> )} - - dispatch(updateExistingThread(post.id, { voted: !post.voted }))} + onFollow={() => dispatch(updateExistingThread(post.id, { following: !post.following }))} + isClosedPost={post.closed} /> + +
- +
{(topicContext || topic) && (
{ { 'w-100': enableInContextSidebar, 'mb-1': !displayPostFooter })} style={{ lineHeight: '20px' }} > - - {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(); + }} />
); -}; +} Post.propTypes = { + intl: intlShape.isRequired, + post: postShape.isRequired, handleAddResponseButton: PropTypes.func.isRequired, }; -export default React.memo(Post); +export default injectIntl(Post); diff --git a/src/discussions/posts/post/PostFooter.jsx b/src/discussions/posts/post/PostFooter.jsx index e57ac8bc..13b93263 100644 --- a/src/discussions/posts/post/PostFooter.jsx +++ b/src/discussions/posts/post/PostFooter.jsx @@ -1,9 +1,9 @@ -import React, { useCallback } from 'react'; +import React from 'react'; import PropTypes from 'prop-types'; import { useDispatch } from 'react-redux'; -import { useIntl } from '@edx/frontend-platform/i18n'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { Icon, IconButton, OverlayTrigger, Tooltip, } from '@edx/paragon'; @@ -13,46 +13,36 @@ import { StarFilled, StarOutline } from '../../../components/icons'; import { updateExistingThread } from '../data/thunks'; import LikeButton from './LikeButton'; import messages from './messages'; +import { postShape } from './proptypes'; -const PostFooter = ({ - closed, - following, - groupId, - groupName, - id, +function PostFooter({ + intl, + post, userHasModerationPrivileges, - voted, - voteCount, -}) => { +}) { const dispatch = useDispatch(); - const intl = useIntl(); - - const handlePostLike = useCallback(() => { - dispatch(updateExistingThread(id, { voted: !voted })); - }, [id, voted]); - return (
- {voteCount !== 0 && ( + {post.voteCount !== 0 && ( dispatch(updateExistingThread(post.id, { voted: !post.voted }))} + voted={post.voted} /> )} - {following && ( + {post.following && ( - {intl.formatMessage(following ? messages.unFollow : messages.follow)} + + {intl.formatMessage(post.following ? messages.unFollow : messages.follow)} )} > { e.preventDefault(); - dispatch(updateExistingThread(id, { following: !following })); + dispatch(updateExistingThread(post.id, { following: !post.following })); return true; }} iconAs={Icon} @@ -63,10 +53,10 @@ const PostFooter = ({ )}
- {groupId && userHasModerationPrivileges && ( + {post.groupId && userHasModerationPrivileges && ( {groupName} + {post.groupName} )} > @@ -81,43 +71,36 @@ const PostFooter = ({ )} - {closed && ( - - {intl.formatMessage(messages.postClosed)} - - )} - > - - - )} + + {post.closed + && ( + + {intl.formatMessage(messages.postClosed)} + + )} + > + + + )}
); -}; +} PostFooter.propTypes = { - 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, + intl: intlShape.isRequired, + post: postShape.isRequired, userHasModerationPrivileges: PropTypes.bool.isRequired, }; -PostFooter.defaultProps = { - groupId: null, - groupName: null, -}; - -export default React.memo(PostFooter); +export default injectIntl(PostFooter); diff --git a/src/discussions/posts/post/PostHeader.jsx b/src/discussions/posts/post/PostHeader.jsx index 5708dc51..dea83486 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 { useIntl } from '@edx/frontend-platform/i18n'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { Avatar, Badge, Icon } from '@edx/paragon'; import { Issue, Question } from '../../../components/icons'; @@ -11,35 +11,36 @@ import { AvatarOutlineAndLabelColors, ThreadType } from '../../../data/constants import { AuthorLabel } from '../../common'; import { useAlertBannerVisible } from '../../data/hooks'; import messages from './messages'; +import { postShape } from './proptypes'; -export const PostAvatar = React.memo(({ - author, postType, authorLabel, fromPostLink, read, -}) => { +export function PostAvatar({ + post, authorLabel, fromPostLink, read, +}) { const outlineColor = AvatarOutlineAndLabelColors[authorLabel]; const avatarSize = useMemo(() => { let size = '2rem'; - if (postType === ThreadType.DISCUSSION && !fromPostLink) { + if (post.type === ThreadType.DISCUSSION && !fromPostLink) { size = '2rem'; - } else if (postType === ThreadType.QUESTION) { + } else if (post.type === ThreadType.QUESTION) { size = '1.5rem'; } return size; - }, [postType]); + }, [post.type]); const avatarSpacing = useMemo(() => { let spacing = 'mr-3 '; - if (postType === ThreadType.DISCUSSION && fromPostLink) { + if (post.type === ThreadType.DISCUSSION && fromPostLink) { spacing += 'pt-2 ml-0.5'; - } else if (postType === ThreadType.DISCUSSION) { + } else if (post.type === ThreadType.DISCUSSION) { spacing += 'ml-0.5 mt-0.5'; } return spacing; - }, [postType]); + }, [post.type]); return (
- {postType === ThreadType.QUESTION && ( + {post.type === ThreadType.QUESTION && (
); -}); +} PostAvatar.propTypes = { - author: PropTypes.string.isRequired, - postType: PropTypes.string.isRequired, + post: postShape.isRequired, authorLabel: PropTypes.string, fromPostLink: PropTypes.bool, read: PropTypes.bool, @@ -78,86 +78,65 @@ PostAvatar.defaultProps = { read: false, }; -const PostHeader = ({ - abuseFlagged, - author, - authorLabel, - closed, - createdAt, - hasEndorsed, - lastEdit, - title, - postType, +function PostHeader({ + intl, + post, preview, -}) => { - const intl = useIntl(); - const showAnsweredBadge = preview && hasEndorsed && postType === ThreadType.QUESTION; - const authorLabelColor = AvatarOutlineAndLabelColors[authorLabel]; - const hasAnyAlert = useAlertBannerVisible({ - author, abuseFlagged, lastEdit, closed, - }); +}) { + const showAnsweredBadge = preview && post.hasEndorsed && post.type === ThreadType.QUESTION; + const authorLabelColor = AvatarOutlineAndLabelColors[post.authorLabel]; + const hasAnyAlert = useAlertBannerVisible(post); return (
- +
- {preview ? ( -
-
- {title} -
- {showAnsweredBadge + {preview + ? ( +
+
+ {post.title} +
+ {showAnsweredBadge && {intl.formatMessage(messages.answered)}} -
- ) : ( -
- {title} -
- )} +
+ ) + : ( +
+ {post.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 React.memo(PostHeader); +export default injectIntl(PostHeader); diff --git a/src/discussions/posts/post/PostLink.jsx b/src/discussions/posts/post/PostLink.jsx index 068145b3..53248aa6 100644 --- a/src/discussions/posts/post/PostLink.jsx +++ b/src/discussions/posts/post/PostLink.jsx @@ -1,9 +1,8 @@ /* eslint-disable react/no-unknown-property */ -import React, { useContext, useMemo } from 'react'; +import React, { useContext } 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'; @@ -15,45 +14,37 @@ 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'; -const PostLink = ({ - idx, - postId, +function PostLink({ + post, + isSelected, showDivider, -}) => { + idx, +}) { 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, - topicId, - postId, + courseId: post.courseId, + topicId: post.topicId, + postId: post.id, category, learnerUsername, }); - 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]); + 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); return ( <> @@ -64,24 +55,19 @@ const PostLink = ({ }) } to={linkUrl} - aria-current={checkIsSelected ? 'page' : undefined} + onClick={() => isSelected(post.id)} + aria-current={isSelected(post.id) ? 'page' : undefined} role="option" - tabIndex={(checkIsSelected || idx === 0) ? 0 : -1} + tabIndex={(isSelected(post.id) || idx === 0) ? 0 : -1} >
- +
@@ -92,14 +78,14 @@ const PostLink = ({ { 'font-weight-bolder': !read }) } > - {title} + {post.title} - {isPostPreviewAvailable(previewBody) - ? previewBody + {isPostPreviewAvailable(post.previewBody) + ? post.previewBody : intl.formatMessage(messages.postWithoutPreview)} @@ -108,6 +94,7 @@ const PostLink = ({ {' '}answered )} + {canSeeReportedBadge && ( {' '}reported )} - {pinned && ( + + {post.pinned && (
- +
- {!showDivider && pinned &&
} + {!showDivider && post.pinned &&
} ); -}; +} PostLink.propTypes = { - idx: PropTypes.number, - postId: PropTypes.string.isRequired, + post: postShape.isRequired, + isSelected: PropTypes.func.isRequired, showDivider: PropTypes.bool, + idx: PropTypes.number, }; PostLink.defaultProps = { - idx: -1, showDivider: true, + idx: -1, }; -export default React.memo(PostLink); +export default PostLink; diff --git a/src/discussions/posts/post/PostSummaryFooter.jsx b/src/discussions/posts/post/PostSummaryFooter.jsx index 1c288add..f7459572 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 { useIntl } from '@edx/frontend-platform/i18n'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { Badge, Icon, OverlayTrigger, Tooltip, } from '@edx/paragon'; @@ -16,86 +16,78 @@ 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'; -const PostSummaryFooter = ({ - postId, - voted, - voteCount, - following, - commentCount, - unreadCommentCount, - groupId, - groupName, - createdAt, +function PostSummaryFooter({ + post, + intl, preview, showNewCountLabel, -}) => { - timeago.register('time-locale', timeLocale); - const intl = useIntl(); +}) { const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges); - + timeago.register('time-locale', timeLocale); return (
- {intl.formatMessage(voted ? messages.likedPost : messages.postLikes)} + + {intl.formatMessage(post.voted ? messages.likedPost : messages.postLikes)} )} > - - {' '}{intl.formatMessage(voted ? messages.likedPost : messages.postLikes)} + + {' '}{intl.formatMessage(post.voted ? messages.likedPost : messages.postLikes)}
- {(voteCount && voteCount > 0) ? voteCount : null} + {(post.voteCount && post.voteCount > 0) ? post.voteCount : null}
- {intl.formatMessage(following ? messages.followed : messages.notFollowed)} + + {intl.formatMessage(post.following ? messages.followed : messages.notFollowed)} )} > - + - {' '}{intl.formatMessage(following ? messages.srOnlyFollowDescription : messages.srOnlyUnFollowDescription)} + {' '}{intl.formatMessage(post.following ? messages.srOnlyFollowDescription : messages.srOnlyUnFollowDescription)} - {preview && commentCount > 1 && ( + {preview && post.commentCount > 1 && (
+ {intl.formatMessage(messages.activity)} )} > {' '} {intl.formatMessage(messages.activity)} - {commentCount} + {post.commentCount}
)} - {showNewCountLabel && preview && unreadCommentCount > 0 && commentCount > 1 && ( + {showNewCountLabel && preview && post?.unreadCommentCount > 0 && post.commentCount > 1 && ( - {intl.formatMessage(messages.newLabel, { count: unreadCommentCount })} + {intl.formatMessage(messages.newLabel, { count: post.unreadCommentCount })} )}
- {groupId && userHasModerationPrivileges && ( + {post.groupId && userHasModerationPrivileges && ( {groupName} + {post.groupName} )} > @@ -106,24 +98,17 @@ const PostSummaryFooter = ({ )} - - {timeago.format(createdAt, 'time-locale')} + + {timeago.format(post.createdAt, 'time-locale')}
); -}; +} PostSummaryFooter.propTypes = { - 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, + intl: intlShape.isRequired, + post: postShape.isRequired, preview: PropTypes.bool, showNewCountLabel: PropTypes.bool, }; @@ -131,8 +116,6 @@ PostSummaryFooter.propTypes = { PostSummaryFooter.defaultProps = { preview: false, showNewCountLabel: false, - groupId: null, - groupName: null, }; -export default React.memo(PostSummaryFooter); +export default injectIntl(PostSummaryFooter); diff --git a/src/discussions/topics/TopicsView.jsx b/src/discussions/topics/TopicsView.jsx index 61cabebb..fd0fbe62 100644 --- a/src/discussions/topics/TopicsView.jsx +++ b/src/discussions/topics/TopicsView.jsx @@ -1,6 +1,4 @@ -import React, { - useCallback, useContext, useEffect, useMemo, -} from 'react'; +import React, { useContext, useEffect } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { useParams } from 'react-router'; @@ -18,57 +16,49 @@ import LegacyTopicGroup from './topic-group/LegacyTopicGroup'; import Topic from './topic-group/topic/Topic'; import countFilteredTopics from './utils'; -const CourseWideTopics = () => { +function CourseWideTopics() { const { category } = useParams(); const filter = useSelector(selectTopicFilter); const nonCoursewareTopics = useSelector(selectNonCoursewareTopics); - - const filteredNonCoursewareTopics = useMemo(() => ( - nonCoursewareTopics.filter(item => ( - filter ? item.name.toLowerCase().includes(filter) : true - ))), [nonCoursewareTopics, filter]); + const filteredNonCoursewareTopics = nonCoursewareTopics.filter(item => (filter + ? item.name.toLowerCase().includes(filter) + : true + )); return (nonCoursewareTopics && category === undefined) && filteredNonCoursewareTopics.map((topic, index) => ( )); -}; +} -const LegacyCoursewareTopics = () => { +function LegacyCoursewareTopics() { const { category } = useParams(); - const categories = useSelector(selectCategories); - - const filteredCategories = useMemo(() => ( - categories.filter(cat => (category ? cat === category : true)) - ), [categories, category]); - - return filteredCategories?.map( - categoryId => ( + const categories = useSelector(selectCategories) + .filter(cat => (category ? cat === category : true)); + return categories?.map( + topicGroup => ( ), ); -}; +} -const TopicsView = () => { - const dispatch = useDispatch(); +function TopicsView() { 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 handleOnClear = useCallback(() => { - dispatch(setFilter('')); - }, []); + const dispatch = useDispatch(); useEffect(() => { // Don't load till the provider information is available @@ -89,7 +79,7 @@ const TopicsView = () => { text={topicFilter} count={filteredTopicsCount} loadingStatus={loadingStatus} - onClear={handleOnClear} + onClear={() => dispatch(setFilter(''))} /> )}
handleKeyDown(e)}> @@ -104,6 +94,8 @@ const TopicsView = () => { }
); -}; +} + +TopicsView.propTypes = {}; export default TopicsView; diff --git a/src/discussions/topics/data/selectors.js b/src/discussions/topics/data/selectors.js index 3093ccde..58ea1ce0 100644 --- a/src/discussions/topics/data/selectors.js +++ b/src/discussions/topics/data/selectors.js @@ -13,10 +13,6 @@ 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 e94da24e..bab0774d 100644 --- a/src/discussions/topics/topic-group/LegacyTopicGroup.jsx +++ b/src/discussions/topics/topic-group/LegacyTopicGroup.jsx @@ -3,23 +3,27 @@ import PropTypes from 'prop-types'; import { useSelector } from 'react-redux'; -import { selectTopicsInCategoryIds } from '../data/selectors'; +import { selectTopicsInCategory } from '../data/selectors'; import TopicGroupBase from './TopicGroupBase'; -const LegacyTopicGroup = ({ categoryId }) => { - const topicsIds = useSelector(selectTopicsInCategoryIds(categoryId)); - +function LegacyTopicGroup({ + id, + category, +}) { + const topics = useSelector(selectTopicsInCategory(category)); return ( - + ); -}; +} LegacyTopicGroup.propTypes = { - categoryId: PropTypes.string, + id: PropTypes.string, + category: PropTypes.string, }; LegacyTopicGroup.defaultProps = { - categoryId: null, + id: null, + category: null, }; -export default React.memo(LegacyTopicGroup); +export default LegacyTopicGroup; diff --git a/src/discussions/topics/topic-group/TopicGroupBase.jsx b/src/discussions/topics/topic-group/TopicGroupBase.jsx index b36df877..337dcad6 100644 --- a/src/discussions/topics/topic-group/TopicGroupBase.jsx +++ b/src/discussions/topics/topic-group/TopicGroupBase.jsx @@ -1,63 +1,43 @@ -import React, { useContext, useMemo } from 'react'; +import React, { useContext } from 'react'; import PropTypes from 'prop-types'; import { useSelector } from 'react-redux'; import { Link } from 'react-router-dom'; -import { useIntl } from '@edx/frontend-platform/i18n'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { Routes } from '../../../data/constants'; import { DiscussionContext } from '../../common/context'; import { discussionsPath } from '../../utils'; -import { selectTopicFilter, selectTopicsById } from '../data/selectors'; +import { selectTopicFilter } from '../data/selectors'; import messages from '../messages'; -import Topic from './topic/Topic'; +import Topic, { topicShape } from './topic/Topic'; -const TopicGroupBase = ({ +function TopicGroupBase({ groupId, groupTitle, linkToGroup, - topicsIds, -}) => { - const intl = useIntl(); + topics, + intl, +}) { 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 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 filteredTopicElements = topics.filter( + topic => (filter + ? (topic.name.toLowerCase().includes(filter) || matchesFilter) + : true + ), + ); 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) + )}
- {renderFilteredTopics} + {filteredTopicElements.map((topic, index) => ( + + ))}
); -}; +} TopicGroupBase.propTypes = { groupId: PropTypes.string.isRequired, groupTitle: PropTypes.string.isRequired, - topicsIds: PropTypes.arrayOf(PropTypes.string).isRequired, + topics: PropTypes.arrayOf(topicShape).isRequired, linkToGroup: PropTypes.bool, + intl: intlShape.isRequired, }; TopicGroupBase.defaultProps = { linkToGroup: true, }; -export default React.memo(TopicGroupBase); +export default injectIntl(TopicGroupBase); diff --git a/src/discussions/topics/topic-group/topic/Topic.jsx b/src/discussions/topics/topic-group/topic/Topic.jsx index af5ee705..47a1d246 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, { useCallback } from 'react'; +import React 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 { useIntl } from '@edx/frontend-platform/i18n'; +import { injectIntl, intlShape } 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'; -const Topic = ({ topicId, showDivider, index }) => { - const intl = useIntl(); +function Topic({ + topic, + showDivider, + index, + intl, +}) { const { courseId } = useParams(); - const topic = useSelector(selectTopic(topicId)); - const { - id, inactiveFlags, activeFlags, name, threadCounts, - } = topic; + const topicUrl = discussionsPath(Routes.TOPICS.TOPIC, { + courseId, + topicId: topic.id, + }); const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges); const userIsGroupTa = useSelector(selectUserIsGroupTa); + const { inactiveFlags, activeFlags } = topic; const canSeeReportedStats = (activeFlags || inactiveFlags) && (userHasModerationPrivileges || userIsGroupTa); - const topicUrl = discussionsPath(Routes.TOPICS.TOPIC, { courseId, topicId }); - - const isSelected = useCallback((selectedId) => ( - window.location.pathname.includes(selectedId) - ), []); + const isSelected = (id) => window.location.pathname.includes(id); return ( { 'border-bottom border-light-400': showDivider, }) } - data-topic-id={id} + data-topic-id={topic.id} to={topicUrl} - onClick={() => isSelected(id)} - aria-current={isSelected(id) ? 'page' : undefined} + onClick={() => isSelected(topic.id)} + aria-current={isSelected(topic.id) ? 'page' : undefined} role="option" - tabIndex={(isSelected(id) || index === 0) ? 0 : -1} + tabIndex={(isSelected(topic.id) || index === 0) ? 0 : -1} >
- {name || intl.formatMessage(messages.unnamedTopicSubCategories)} + {topic.name || intl.formatMessage(messages.unnamedTopicSubCategories)}
@@ -61,7 +61,7 @@ const Topic = ({ topicId, showDivider, index }) => {
{intl.formatMessage(messages.discussions, { - count: threadCounts?.discussion || 0, + count: topic.threadCounts?.discussion || 0, })}
@@ -69,7 +69,7 @@ const Topic = ({ topicId, showDivider, index }) => { >
- {threadCounts?.discussion || 0} + {topic.threadCounts?.discussion || 0}
{
{intl.formatMessage(messages.questions, { - count: threadCounts?.question || 0, + count: topic.threadCounts?.question || 0, })}
@@ -86,7 +86,7 @@ const Topic = ({ topicId, showDivider, index }) => { >
- {threadCounts?.question || 0} + {topic.threadCounts?.question || 0}
{Boolean(canSeeReportedStats) && ( @@ -121,7 +121,7 @@ const Topic = ({ topicId, showDivider, index }) => { {!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 = { - topicId: PropTypes.string.isRequired, + intl: intlShape.isRequired, + topic: topicShape.isRequired, showDivider: PropTypes.bool, index: PropTypes.number, }; @@ -142,4 +142,4 @@ Topic.defaultProps = { index: -1, }; -export default React.memo(Topic); +export default injectIntl(Topic); diff --git a/src/discussions/tours/DiscussionsProductTour.jsx b/src/discussions/tours/DiscussionsProductTour.jsx index 804e39ac..bd236285 100644 --- a/src/discussions/tours/DiscussionsProductTour.jsx +++ b/src/discussions/tours/DiscussionsProductTour.jsx @@ -1,17 +1,17 @@ -import React, { useEffect } from 'react'; +import { 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'; -const DiscussionsProductTour = () => { +function DiscussionsProductTour({ intl }) { const dispatch = useDispatch(); - const config = useTourConfiguration(); - + const config = useTourConfiguration(intl); useEffect(() => { dispatch(fetchDiscussionTours()); }, []); @@ -25,6 +25,10 @@ const DiscussionsProductTour = () => { )} ); +} + +DiscussionsProductTour.propTypes = { + intl: intlShape.isRequired, }; -export default DiscussionsProductTour; +export default injectIntl(DiscussionsProductTour); diff --git a/src/discussions/utils.js b/src/discussions/utils.js index aa40f2b8..2c724fbd 100644 --- a/src/discussions/utils.js +++ b/src/discussions/utils.js @@ -1,8 +1,6 @@ -import { useCallback, useContext, useMemo } from 'react'; - +/* eslint-disable import/prefer-default-export */ 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'; @@ -12,8 +10,6 @@ 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'; /** @@ -179,26 +175,20 @@ export const ACTIONS_LIST = [ }, ]; -export function useActions(contentType, id) { - const postType = useContext(PostCommentsContext); - const content = { ...useSelector(ContentSelectors[contentType](id)), postType }; - - const checkConditions = useCallback((item, conditions) => ( +export function useActions(content) { + const checkConditions = (item, conditions) => ( conditions ? Object.keys(conditions) .map(key => item[key] === conditions[key]) .every(condition => condition === true) : true - ), []); - - const actions = useMemo(() => ACTIONS_LIST.filter( + ); + return 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 4ec1bfd5..2c05ee21 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 !important; - padding-bottom: 10px !important; + padding-top: 10px; + padding-bottom: 10px; } .py-8px { - padding-top: 8px !important; - padding-bottom: 8px !important; + padding-top: 8px; + padding-bottom: 8px; } .pb-10px { @@ -530,10 +530,3 @@ header { position: relative; background-color: #fff; } - -.spinner-container { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); -}