Perf: improved discussions MFE's components re-rendering and loading time (#513)

* chore: configure WDYR for react profiling

* perf: reduced post content re-rendering

* perf: post content view and it child optimization

* perf: add memoization in post editor

* perf: add memoization in postCommnetsView

* perf: improved endorsed comment view rendering

* perf: improved re-rendering in reply component

* fix: uncomment questionType commentsView

* fix: removed console errors in postContent area

* perf: reduced postType and postId dependancy

* perf: improved re-rendering in discussionHome

* perf: improved re-rendering of postsList and its child components

* perf: improved re-rendering of legacyTopic and learner sidebar

* fix: postFilterBar filter was not updating

* fix: resolve duplicate comment posts issue

* fix: memory leaking issue in comments view

* fix: duplicate topic posts in inContext sidebar

* perf: add lazy loading

* chore: remove WDYR configuration

* fix: alert banner padding

* chore: update package-lock file

* fix: bind tour API call with buttons
This commit is contained in:
Awais Ansari
2023-05-08 16:21:29 +05:00
committed by GitHub
parent 7b7c249abd
commit 0844ee6875
86 changed files with 2501 additions and 1985 deletions

22
package-lock.json generated
View File

@@ -26,7 +26,6 @@
"raw-loader": "4.0.2", "raw-loader": "4.0.2",
"react": "16.14.0", "react": "16.14.0",
"react-dom": "16.14.0", "react-dom": "16.14.0",
"react-mathjax-preview": "2.2.6",
"react-redux": "7.2.6", "react-redux": "7.2.6",
"react-router": "5.2.1", "react-router": "5.2.1",
"react-router-dom": "5.3.0", "react-router-dom": "5.3.0",
@@ -22327,19 +22326,6 @@
"react": ">=16.8.0" "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": { "node_modules/react-overlays": {
"version": "5.2.0", "version": "5.2.0",
"resolved": "https://registry.npmjs.org/react-overlays/-/react-overlays-5.2.0.tgz", "resolved": "https://registry.npmjs.org/react-overlays/-/react-overlays-5.2.0.tgz",
@@ -43849,14 +43835,6 @@
"integrity": "sha512-j1U1CWWs68nBPOg7tkQqnlFcAMFF6oEK6MgqAo15f8A5p7mjH6xyKn2gHbkcimpwfO0VQXqxAswnSYVr8lWzjw==", "integrity": "sha512-j1U1CWWs68nBPOg7tkQqnlFcAMFF6oEK6MgqAo15f8A5p7mjH6xyKn2gHbkcimpwfO0VQXqxAswnSYVr8lWzjw==",
"requires": {} "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": { "react-overlays": {
"version": "5.2.0", "version": "5.2.0",
"resolved": "https://registry.npmjs.org/react-overlays/-/react-overlays-5.2.0.tgz", "resolved": "https://registry.npmjs.org/react-overlays/-/react-overlays-5.2.0.tgz",

View File

@@ -9,7 +9,7 @@
href="<%=htmlWebpackPlugin.options.FAVICON_URL%>" href="<%=htmlWebpackPlugin.options.FAVICON_URL%>"
type="image/x-icon" type="image/x-icon"
/> />
<script> <script defer>
window.MathJax = { window.MathJax = {
tex: { tex: {
inlineMath: [ inlineMath: [
@@ -180,7 +180,6 @@
var r = (window.lightningjs = t(e)); var r = (window.lightningjs = t(e));
(r.require = t), (r.modules = n); (r.require = t), (r.modules = n);
})({}); })({});
</script> </script>
<!-- end usabilla live embed code --> <!-- end usabilla live embed code -->
</body> </body>

View File

@@ -5,31 +5,26 @@ import { getIn, useFormikContext } from 'formik';
import { Form, TransitionReplace } from '@edx/paragon'; import { Form, TransitionReplace } from '@edx/paragon';
function FormikErrorFeedback({ name }) { const FormikErrorFeedback = ({ name }) => {
const { const { touched, errors } = useFormikContext();
touched,
errors,
} = useFormikContext();
const fieldTouched = getIn(touched, name); const fieldTouched = getIn(touched, name);
const fieldError = getIn(errors, name); const fieldError = getIn(errors, name);
return ( return (
<TransitionReplace> <TransitionReplace>
{fieldTouched && fieldError {fieldTouched && fieldError ? (
? ( <Form.Control.Feedback type="invalid" hasIcon={false} key={`${name}-error-feedback`}>
<Form.Control.Feedback type="invalid" hasIcon={false} key={`${name}-error-feedback`}> {fieldError}
{fieldError} </Form.Control.Feedback>
</Form.Control.Feedback> ) : (
) <React.Fragment key={`${name}-no-error-feedback`} />
: ( )}
<React.Fragment key={`${name}-no-error-feedback`} />
)}
</TransitionReplace> </TransitionReplace>
); );
} };
FormikErrorFeedback.propTypes = { FormikErrorFeedback.propTypes = {
name: PropTypes.string.isRequired, name: PropTypes.string.isRequired,
}; };
export default FormikErrorFeedback; export default React.memo(FormikErrorFeedback);

View File

@@ -12,9 +12,9 @@ const defaultSanitizeOptions = {
ADD_ATTR: ['columnalign'], ADD_ATTR: ['columnalign'],
}; };
function HTMLLoader({ const HTMLLoader = ({
htmlNode, componentId, cssClassName, testId, delay, htmlNode, componentId, cssClassName, testId, delay,
}) { }) => {
const sanitizedMath = DOMPurify.sanitize(htmlNode, { ...defaultSanitizeOptions }); const sanitizedMath = DOMPurify.sanitize(htmlNode, { ...defaultSanitizeOptions });
const previewRef = useRef(null); const previewRef = useRef(null);
const debouncedPostContent = useDebounce(htmlNode, delay); const debouncedPostContent = useDebounce(htmlNode, delay);
@@ -45,7 +45,7 @@ function HTMLLoader({
return ( return (
<div ref={previewRef} className={cssClassName} id={componentId} data-testid={testId} /> <div ref={previewRef} className={cssClassName} id={componentId} data-testid={testId} />
); );
} };
HTMLLoader.propTypes = { HTMLLoader.propTypes = {
htmlNode: PropTypes.node, htmlNode: PropTypes.node,
@@ -63,4 +63,4 @@ HTMLLoader.defaultProps = {
delay: 0, delay: 0,
}; };
export default HTMLLoader; export default React.memo(HTMLLoader);

View File

@@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
import classNames from 'classnames'; import classNames from 'classnames';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { fetchTab } from './data/thunks'; import { fetchTab } from './data/thunks';
import Tabs from './tabs/Tabs'; import Tabs from './tabs/Tabs';
@@ -12,12 +12,13 @@ import messages from './messages';
import './navBar.scss'; import './navBar.scss';
function CourseTabsNavigation({ const CourseTabsNavigation = ({
activeTab, className, intl, courseId, rootSlug, activeTab, className, courseId, rootSlug,
}) { }) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const intl = useIntl();
const tabs = useSelector(state => state.courseTabs.tabs); const tabs = useSelector(state => state.courseTabs.tabs);
useEffect(() => { useEffect(() => {
dispatch(fetchTab(courseId, rootSlug)); dispatch(fetchTab(courseId, rootSlug));
}, [courseId]); }, [courseId]);
@@ -25,8 +26,7 @@ function CourseTabsNavigation({
return ( return (
<div id="courseTabsNavigation" className={classNames('course-tabs-navigation', className)}> <div id="courseTabsNavigation" className={classNames('course-tabs-navigation', className)}>
<div className="container-xl"> <div className="container-xl">
{!!tabs.length {!!tabs.length && (
&& (
<Tabs <Tabs
className="nav-underline-tabs" className="nav-underline-tabs"
aria-label={intl.formatMessage(messages.courseMaterial)} aria-label={intl.formatMessage(messages.courseMaterial)}
@@ -41,18 +41,17 @@ function CourseTabsNavigation({
</a> </a>
))} ))}
</Tabs> </Tabs>
)} )}
</div> </div>
</div> </div>
); );
} };
CourseTabsNavigation.propTypes = { CourseTabsNavigation.propTypes = {
activeTab: PropTypes.string, activeTab: PropTypes.string,
className: PropTypes.string, className: PropTypes.string,
rootSlug: PropTypes.string, rootSlug: PropTypes.string,
courseId: PropTypes.string.isRequired, courseId: PropTypes.string.isRequired,
intl: intlShape.isRequired,
}; };
CourseTabsNavigation.defaultProps = { CourseTabsNavigation.defaultProps = {
@@ -61,4 +60,4 @@ CourseTabsNavigation.defaultProps = {
rootSlug: 'outline', rootSlug: 'outline',
}; };
export default injectIntl(CourseTabsNavigation); export default React.memo(CourseTabsNavigation);

View File

@@ -1,16 +1,17 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { Button, Icon, IconButton } from '@edx/paragon'; import { Button, Icon, IconButton } from '@edx/paragon';
import { Close } from '@edx/paragon/icons'; import { Close } from '@edx/paragon/icons';
import messages from '../discussions/posts/post-editor/messages'; import messages from '../discussions/posts/post-editor/messages';
import HTMLLoader from './HTMLLoader'; import HTMLLoader from './HTMLLoader';
function PostPreviewPanel({ const PostPreviewPanel = ({
htmlNode, intl, isPost, editExisting, htmlNode, isPost, editExisting,
}) { }) => {
const intl = useIntl();
const [showPreviewPane, setShowPreviewPane] = useState(false); const [showPreviewPane, setShowPreviewPane] = useState(false);
return ( return (
@@ -30,13 +31,15 @@ function PostPreviewPanel({
iconClassNames="icon-size" iconClassNames="icon-size"
data-testid="hide-preview-button" data-testid="hide-preview-button"
/> />
<HTMLLoader {htmlNode && (
htmlNode={htmlNode} <HTMLLoader
cssClassName="text-primary" htmlNode={htmlNode}
componentId="post-preview" cssClassName="text-primary"
testId="post-preview" componentId="post-preview"
delay={500} testId="post-preview"
/> delay={500}
/>
)}
</div> </div>
)} )}
<div className="d-flex justify-content-end"> <div className="d-flex justify-content-end">
@@ -55,18 +58,18 @@ function PostPreviewPanel({
</div> </div>
</> </>
); );
} };
PostPreviewPanel.propTypes = { PostPreviewPanel.propTypes = {
intl: intlShape.isRequired, htmlNode: PropTypes.node,
htmlNode: PropTypes.node.isRequired,
isPost: PropTypes.bool, isPost: PropTypes.bool,
editExisting: PropTypes.bool, editExisting: PropTypes.bool,
}; };
PostPreviewPanel.defaultProps = { PostPreviewPanel.defaultProps = {
htmlNode: '',
isPost: false, isPost: false,
editExisting: false, editExisting: false,
}; };
export default injectIntl(PostPreviewPanel); export default React.memo(PostPreviewPanel);

View File

@@ -1,9 +1,11 @@
import React, { useContext, useEffect } from 'react'; import React, {
useCallback, useContext, useEffect, useState,
} from 'react';
import camelCase from 'lodash/camelCase'; import camelCase from 'lodash/camelCase';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { Icon, SearchField } from '@edx/paragon'; import { Icon, SearchField } from '@edx/paragon';
import { Search as SearchIcon } from '@edx/paragon/icons'; import { Search as SearchIcon } from '@edx/paragon/icons';
@@ -13,7 +15,8 @@ import { setSearchQuery } from '../discussions/posts/data';
import postsMessages from '../discussions/posts/post-actions-bar/messages'; import postsMessages from '../discussions/posts/post-actions-bar/messages';
import { setFilter as setTopicFilter } from '../discussions/topics/data/slices'; import { setFilter as setTopicFilter } from '../discussions/topics/data/slices';
function Search({ intl }) { const Search = () => {
const intl = useIntl();
const dispatch = useDispatch(); const dispatch = useDispatch();
const { page } = useContext(DiscussionContext); const { page } = useContext(DiscussionContext);
const postSearch = useSelector(({ threads }) => threads.filters.search); const postSearch = useSelector(({ threads }) => threads.filters.search);
@@ -21,8 +24,9 @@ function Search({ intl }) {
const learnerSearch = useSelector(({ learners }) => learners.usernameSearch); const learnerSearch = useSelector(({ learners }) => learners.usernameSearch);
const isPostSearch = ['posts', 'my-posts'].includes(page); const isPostSearch = ['posts', 'my-posts'].includes(page);
const isTopicSearch = 'topics'.includes(page); const isTopicSearch = 'topics'.includes(page);
let searchValue = ''; const [searchValue, setSearchValue] = useState('');
let currentValue = ''; let currentValue = '';
if (isPostSearch) { if (isPostSearch) {
currentValue = postSearch; currentValue = postSearch;
} else if (isTopicSearch) { } else if (isTopicSearch) {
@@ -31,20 +35,21 @@ function Search({ intl }) {
currentValue = learnerSearch; currentValue = learnerSearch;
} }
const onClear = () => { const onClear = useCallback(() => {
dispatch(setSearchQuery('')); dispatch(setSearchQuery(''));
dispatch(setTopicFilter('')); dispatch(setTopicFilter(''));
dispatch(setUsernameSearch('')); dispatch(setUsernameSearch(''));
}; }, []);
const onChange = (query) => { const onChange = useCallback((query) => {
searchValue = query; setSearchValue(query);
}; }, []);
const onSubmit = (query) => { const onSubmit = useCallback((query) => {
if (query === '') { if (query === '') {
return; return;
} }
if (isPostSearch) { if (isPostSearch) {
dispatch(setSearchQuery(query)); dispatch(setSearchQuery(query));
} else if (page === 'topics') { } else if (page === 'topics') {
@@ -52,36 +57,36 @@ function Search({ intl }) {
} else if (page === 'learners') { } else if (page === 'learners') {
dispatch(setUsernameSearch(query)); dispatch(setUsernameSearch(query));
} }
}; }, [page, searchValue]);
const handleIconClick = useCallback((e) => {
e.preventDefault();
onSubmit(searchValue);
}, [searchValue]);
useEffect(() => onClear(), [page]); useEffect(() => onClear(), [page]);
return (
<>
<SearchField.Advanced
onClear={onClear}
onChange={onChange}
onSubmit={onSubmit}
value={currentValue}
>
<SearchField.Label />
<SearchField.Input
style={{ paddingRight: '1rem' }}
placeholder={intl.formatMessage(postsMessages.search, { page: camelCase(page) })}
/>
<span className="mt-auto mb-auto mr-2.5 pointer-cursor-hover">
<Icon
src={SearchIcon}
onClick={() => onSubmit(searchValue)}
data-testid="search-icon"
/>
</span>
</SearchField.Advanced>
</>
);
}
Search.propTypes = { return (
intl: intlShape.isRequired, <SearchField.Advanced
onClear={onClear}
onChange={onChange}
onSubmit={onSubmit}
value={currentValue}
>
<SearchField.Label />
<SearchField.Input
style={{ paddingRight: '1rem' }}
placeholder={intl.formatMessage(postsMessages.search, { page: camelCase(page) })}
/>
<span className="py-auto px-2.5 pointer-cursor-hover">
<Icon
src={SearchIcon}
onClick={handleIconClick}
data-testid="search-icon"
/>
</span>
</SearchField.Advanced>
);
}; };
export default injectIntl(Search); export default React.memo(Search);

View File

@@ -1,32 +1,36 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { Button, Icon } from '@edx/paragon'; import { Button, Icon } from '@edx/paragon';
import { Search } from '@edx/paragon/icons'; import { Search } from '@edx/paragon/icons';
import { RequestStatus } from '../data/constants'; import { RequestStatus } from '../data/constants';
import messages from '../discussions/posts/post-actions-bar/messages'; import messages from '../discussions/posts/post-actions-bar/messages';
function SearchInfo({ const SearchInfo = ({
intl,
count, count,
text, text,
loadingStatus, loadingStatus,
onClear, onClear,
textSearchRewrite, textSearchRewrite,
}) { }) => {
const intl = useIntl();
return ( return (
<div className="d-flex flex-row border-bottom border-light-400"> <div className="d-flex flex-row border-bottom border-light-400">
<Icon src={Search} className="justify-content-start ml-3.5 mr-2 mb-2 mt-2.5" /> <Icon src={Search} className="justify-content-start ml-3.5 mr-2 mb-2 mt-2.5" />
<Button variant="" size="inline" className="text-justify p-2"> <Button variant="" size="inline" className="text-justify p-2">
{loadingStatus === RequestStatus.SUCCESSFUL && ( {loadingStatus === RequestStatus.SUCCESSFUL && (
textSearchRewrite ? intl.formatMessage(messages.searchRewriteInfo, { textSearchRewrite ? (
searchString: text, intl.formatMessage(messages.searchRewriteInfo, {
count, searchString: text,
textSearchRewrite, count,
}) textSearchRewrite,
: intl.formatMessage(messages.searchInfo, { count, text }) })
) : (
intl.formatMessage(messages.searchInfo, { count, text })
)
)} )}
{loadingStatus !== RequestStatus.SUCCESSFUL && intl.formatMessage(messages.searchInfoSearching)} {loadingStatus !== RequestStatus.SUCCESSFUL && intl.formatMessage(messages.searchInfoSearching)}
</Button> </Button>
@@ -35,10 +39,9 @@ function SearchInfo({
</Button> </Button>
</div> </div>
); );
} };
SearchInfo.propTypes = { SearchInfo.propTypes = {
intl: intlShape.isRequired,
count: PropTypes.number.isRequired, count: PropTypes.number.isRequired,
text: PropTypes.string.isRequired, text: PropTypes.string.isRequired,
loadingStatus: PropTypes.string.isRequired, loadingStatus: PropTypes.string.isRequired,
@@ -51,4 +54,4 @@ SearchInfo.defaultProps = {
textSearchRewrite: null, textSearchRewrite: null,
}; };
export default injectIntl(SearchInfo); export default React.memo(SearchInfo);

View File

@@ -0,0 +1,11 @@
import React from 'react';
import { Spinner as ParagonSpinner } from '@edx/paragon';
const Spinner = () => (
<div className="spinner-container">
<ParagonSpinner animation="border" variant="primary" size="lg" />
</div>
);
export default React.memo(Spinner);

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react'; import React, { useCallback, useState } from 'react';
import { Editor } from '@tinymce/tinymce-react'; import { Editor } from '@tinymce/tinymce-react';
import { useParams } from 'react-router'; import { useParams } from 'react-router';
@@ -42,30 +42,31 @@ import contentCss from '!!raw-loader!tinymce/skins/content/default/content.min.c
import contentUiCss from '!!raw-loader!tinymce/skins/ui/oxide/content.min.css'; import contentUiCss from '!!raw-loader!tinymce/skins/ui/oxide/content.min.css';
/* istanbul ignore next */ /* istanbul ignore next */
const setup = (editor) => { function TinyMCEEditor(props) {
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 // note that skin and content_css is disabled to avoid the normal
// loading process and is instead loaded as a string via content_style // loading process and is instead loaded as a string via content_style
const { courseId, postId } = useParams(); const { courseId, postId } = useParams();
const [showImageWarning, setShowImageWarning] = useState(false); const [showImageWarning, setShowImageWarning] = useState(false);
const intl = useIntl(); const intl = useIntl();
const uploadHandler = async (blobInfo, success, failure) => {
/* istanbul ignore next */
const setup = useCallback((editor) => {
editor.ui.registry.addButton('openedx_code', {
icon: 'sourcecode',
onAction: () => {
editor.execCommand('CodeSample');
},
});
editor.ui.registry.addButton('openedx_html', {
text: 'HTML',
onAction: () => {
editor.execCommand('mceCodeEditor');
},
});
}, []);
const uploadHandler = useCallback(async (blobInfo, success, failure) => {
try { try {
const blob = blobInfo.blob(); const blob = blobInfo.blob();
const imageSize = blobInfo.blob().size / 1024; const imageSize = blobInfo.blob().size / 1024;
@@ -76,7 +77,7 @@ export default function TinyMCEEditor(props) {
const filename = blobInfo.filename(); const filename = blobInfo.filename();
const { location } = await uploadFile(blob, filename, courseId, postId || 'root'); const { location } = await uploadFile(blob, filename, courseId, postId || 'root');
const img = new Image(); const img = new Image();
img.onload = function () { img.onload = () => {
if (img.height > 999 || img.width > 999) { setShowImageWarning(true); } if (img.height > 999 || img.width > 999) { setShowImageWarning(true); }
}; };
img.src = location; img.src = location;
@@ -84,7 +85,11 @@ export default function TinyMCEEditor(props) {
} catch (e) { } catch (e) {
failure(e.toString(), { remove: true }); failure(e.toString(), { remove: true });
} }
}; }, [courseId, postId]);
const handleClose = useCallback(() => {
setShowImageWarning(false);
}, []);
let contentStyle; let contentStyle;
// In the test environment this causes an error so set styles to empty since they aren't needed for testing. // In the test environment this causes an error so set styles to empty since they aren't needed for testing.
@@ -131,21 +136,22 @@ export default function TinyMCEEditor(props) {
<AlertModal <AlertModal
title={intl.formatMessage(messages.imageWarningModalTitle)} title={intl.formatMessage(messages.imageWarningModalTitle)}
isOpen={showImageWarning} isOpen={showImageWarning}
onClose={() => setShowImageWarning(false)} onClose={handleClose}
isBlocking isBlocking
footerNode={( footerNode={(
<ActionRow> <ActionRow>
<Button variant="danger" onClick={() => setShowImageWarning(false)}> <Button variant="danger" onClick={handleClose}>
{intl.formatMessage(messages.imageWarningDismissButton)} {intl.formatMessage(messages.imageWarningDismissButton)}
</Button> </Button>
</ActionRow> </ActionRow>
)} )}
> >
<p> <p>
{intl.formatMessage(messages.imageWarningMessage)} {intl.formatMessage(messages.imageWarningMessage)}
</p> </p>
</AlertModal> </AlertModal>
</> </>
); );
} }
export default React.memo(TinyMCEEditor);

View File

@@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { Icon, OverlayTrigger, Tooltip } from '@edx/paragon'; import { Icon, OverlayTrigger, Tooltip } from '@edx/paragon';
import { HelpOutline, PostOutline, Report } from '@edx/paragon/icons'; import { HelpOutline, PostOutline, Report } from '@edx/paragon/icons';
@@ -14,15 +14,16 @@ import {
} from '../discussions/data/selectors'; } from '../discussions/data/selectors';
import messages from '../discussions/in-context-topics/messages'; import messages from '../discussions/in-context-topics/messages';
function TopicStats({ const TopicStats = ({
threadCounts, threadCounts,
activeFlags, activeFlags,
inactiveFlags, inactiveFlags,
intl, }) => {
}) { const intl = useIntl();
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges); const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
const userIsGroupTa = useSelector(selectUserIsGroupTa); const userIsGroupTa = useSelector(selectUserIsGroupTa);
const canSeeReportedStats = (activeFlags || inactiveFlags) && (userHasModerationPrivileges || userIsGroupTa); const canSeeReportedStats = (activeFlags || inactiveFlags) && (userHasModerationPrivileges || userIsGroupTa);
return ( return (
<div className="d-flex align-items-center mt-2.5" style={{ marginBottom: '2px' }}> <div className="d-flex align-items-center mt-2.5" style={{ marginBottom: '2px' }}>
<OverlayTrigger <OverlayTrigger
@@ -87,7 +88,7 @@ function TopicStats({
)} )}
</div> </div>
); );
} };
TopicStats.propTypes = { TopicStats.propTypes = {
threadCounts: PropTypes.shape({ threadCounts: PropTypes.shape({
@@ -96,7 +97,6 @@ TopicStats.propTypes = {
}), }),
activeFlags: PropTypes.number, activeFlags: PropTypes.number,
inactiveFlags: PropTypes.number, inactiveFlags: PropTypes.number,
intl: intlShape.isRequired,
}; };
TopicStats.defaultProps = { TopicStats.defaultProps = {
@@ -108,4 +108,4 @@ TopicStats.defaultProps = {
inactiveFlags: null, inactiveFlags: null,
}; };
export default injectIntl(TopicStats); export default React.memo(TopicStats);

View File

@@ -1,4 +1,5 @@
export { default as PostActionsBar } from '../discussions/posts/post-actions-bar/PostActionsBar'; export { default as PostActionsBar } from '../discussions/posts/post-actions-bar/PostActionsBar';
export { default as Search } from './Search'; export { default as Search } from './Search';
export { default as Spinner } from './Spinner';
export { default as TinyMCEEditor } from './TinyMCEEditor'; export { default as TinyMCEEditor } from './TinyMCEEditor';
export { default as TopicStats } from './TopicStats'; export { default as TopicStats } from './TopicStats';

View File

@@ -18,11 +18,13 @@ import { useDispatch } from 'react-redux';
export function useDispatchWithState() { export function useDispatchWithState() {
const dispatch = useDispatch(); const dispatch = useDispatch();
const [isDispatching, setDispatching] = useState(false); const [isDispatching, setDispatching] = useState(false);
const dispatchWithState = async (thunk) => { const dispatchWithState = async (thunk) => {
setDispatching(true); setDispatching(true);
await dispatch(thunk); await dispatch(thunk);
setDispatching(false); setDispatching(false);
}; };
return [ return [
isDispatching, isDispatching,
dispatchWithState, dispatchWithState,

View File

@@ -1,9 +1,11 @@
import React, { useCallback, useRef, useState } from 'react'; import React, {
useCallback, useMemo, useRef, useState,
} from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { logError } from '@edx/frontend-platform/logging'; import { logError } from '@edx/frontend-platform/logging';
import { import {
Button, Dropdown, Icon, IconButton, ModalPopup, useToggle, Button, Dropdown, Icon, IconButton, ModalPopup, useToggle,
@@ -13,22 +15,22 @@ import { MoreHoriz } from '@edx/paragon/icons';
import { ContentActions } from '../../data/constants'; import { ContentActions } from '../../data/constants';
import { selectBlackoutDate } from '../data/selectors'; import { selectBlackoutDate } from '../data/selectors';
import messages from '../messages'; import messages from '../messages';
import { commentShape } from '../post-comments/comments/comment/proptypes';
import { postShape } from '../posts/post/proptypes';
import { inBlackoutDateRange, useActions } from '../utils'; import { inBlackoutDateRange, useActions } from '../utils';
function ActionsDropdown({ function ActionsDropdown({
intl,
commentOrPost,
disabled,
actionHandlers, actionHandlers,
iconSize, contentType,
disabled,
dropDownIconSize, dropDownIconSize,
iconSize,
id,
}) { }) {
const buttonRef = useRef(); const buttonRef = useRef();
const intl = useIntl();
const [isOpen, open, close] = useToggle(false); const [isOpen, open, close] = useToggle(false);
const [target, setTarget] = useState(null); const [target, setTarget] = useState(null);
const actions = useActions(commentOrPost); const blackoutDateRange = useSelector(selectBlackoutDate);
const actions = useActions(contentType, id);
const handleActions = useCallback((action) => { const handleActions = useCallback((action) => {
const actionFunction = actionHandlers[action]; const actionFunction = actionHandlers[action];
@@ -39,11 +41,12 @@ function ActionsDropdown({
} }
}, [actionHandlers]); }, [actionHandlers]);
const blackoutDateRange = useSelector(selectBlackoutDate);
// Find and remove edit action if in blackout date range. // Find and remove edit action if in blackout date range.
if (inBlackoutDateRange(blackoutDateRange)) { useMemo(() => {
actions.splice(actions.findIndex(action => action.id === 'edit'), 1); if (inBlackoutDateRange(blackoutDateRange)) {
} actions.splice(actions.findIndex(action => action.id === 'edit'), 1);
}
}, [actions, blackoutDateRange]);
const onClickButton = useCallback(() => { const onClickButton = useCallback(() => {
setTarget(buttonRef.current); setTarget(buttonRef.current);
@@ -80,9 +83,7 @@ function ActionsDropdown({
> >
{actions.map(action => ( {actions.map(action => (
<React.Fragment key={action.id}> <React.Fragment key={action.id}>
{(action.action === ContentActions.DELETE) {(action.action === ContentActions.DELETE) && <Dropdown.Divider />}
&& <Dropdown.Divider />}
<Dropdown.Item <Dropdown.Item
as={Button} as={Button}
variant="tertiary" variant="tertiary"
@@ -111,12 +112,12 @@ function ActionsDropdown({
} }
ActionsDropdown.propTypes = { ActionsDropdown.propTypes = {
intl: intlShape.isRequired, id: PropTypes.string.isRequired,
commentOrPost: PropTypes.oneOfType([commentShape, postShape]).isRequired,
disabled: PropTypes.bool, disabled: PropTypes.bool,
actionHandlers: PropTypes.objectOf(PropTypes.func).isRequired, actionHandlers: PropTypes.objectOf(PropTypes.func).isRequired,
iconSize: PropTypes.string, iconSize: PropTypes.string,
dropDownIconSize: PropTypes.bool, dropDownIconSize: PropTypes.bool,
contentType: PropTypes.oneOf(['POST', 'COMMENT']).isRequired,
}; };
ActionsDropdown.defaultProps = { ActionsDropdown.defaultProps = {
@@ -125,4 +126,4 @@ ActionsDropdown.defaultProps = {
dropDownIconSize: false, dropDownIconSize: false,
}; };
export default injectIntl(ActionsDropdown); export default ActionsDropdown;

View File

@@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth'; import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { Alert } from '@edx/paragon'; import { Alert } from '@edx/paragon';
import { Report } from '@edx/paragon/icons'; import { Report } from '@edx/paragon/icons';
@@ -12,26 +12,31 @@ import { AvatarOutlineAndLabelColors } from '../../data/constants';
import { import {
selectModerationSettings, selectUserHasModerationPrivileges, selectUserIsGroupTa, selectUserIsStaff, selectModerationSettings, selectUserHasModerationPrivileges, selectUserIsGroupTa, selectUserIsStaff,
} from '../data/selectors'; } from '../data/selectors';
import { commentShape } from '../post-comments/comments/comment/proptypes';
import messages from '../post-comments/messages'; import messages from '../post-comments/messages';
import { postShape } from '../posts/post/proptypes';
import AlertBar from './AlertBar'; import AlertBar from './AlertBar';
function AlertBanner({ const AlertBanner = ({
intl, author,
content, abuseFlagged,
}) { lastEdit,
closed,
closedBy,
closeReason,
editByLabel,
closedByLabel,
}) => {
const intl = useIntl();
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges); const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
const userIsGroupTa = useSelector(selectUserIsGroupTa); const userIsGroupTa = useSelector(selectUserIsGroupTa);
const userIsGlobalStaff = useSelector(selectUserIsStaff); const userIsGlobalStaff = useSelector(selectUserIsStaff);
const { reasonCodesEnabled } = useSelector(selectModerationSettings); const { reasonCodesEnabled } = useSelector(selectModerationSettings);
const userIsContentAuthor = getAuthenticatedUser().username === content.author; const userIsContentAuthor = getAuthenticatedUser().username === author;
const canSeeReportedBanner = content?.abuseFlagged; const canSeeReportedBanner = abuseFlagged;
const canSeeLastEditOrClosedAlert = (userHasModerationPrivileges || userIsGroupTa const canSeeLastEditOrClosedAlert = (userHasModerationPrivileges || userIsGroupTa
|| userIsGlobalStaff || userIsContentAuthor || userIsGlobalStaff || userIsContentAuthor
); );
const editByLabelColor = AvatarOutlineAndLabelColors[content.editByLabel]; const editByLabelColor = AvatarOutlineAndLabelColors[editByLabel];
const closedByLabelColor = AvatarOutlineAndLabelColors[content.closedByLabel]; const closedByLabelColor = AvatarOutlineAndLabelColors[closedByLabel];
return ( return (
<> <>
@@ -42,33 +47,52 @@ function AlertBanner({
)} )}
{reasonCodesEnabled && canSeeLastEditOrClosedAlert && ( {reasonCodesEnabled && canSeeLastEditOrClosedAlert && (
<> <>
{content.lastEdit?.reason && ( {lastEdit?.reason && (
<AlertBar <AlertBar
message={messages.editedBy} message={intl.formatMessage(messages.editedBy)}
author={content.lastEdit.editorUsername} author={lastEdit.editorUsername}
authorLabel={content.editByLabel} authorLabel={editByLabel}
labelColor={editByLabelColor && `text-${editByLabelColor}`} labelColor={editByLabelColor && `text-${editByLabelColor}`}
reason={content.lastEdit.reason} reason={lastEdit.reason}
/> />
)} )}
{content.closed && ( {closed && (
<AlertBar <AlertBar
message={messages.closedBy} message={intl.formatMessage(messages.closedBy)}
author={content.closedBy} author={closedBy}
authorLabel={content.closedByLabel} authorLabel={closedByLabel}
labelColor={closedByLabelColor && `text-${closedByLabelColor}`} labelColor={closedByLabelColor && `text-${closedByLabelColor}`}
reason={content.closeReason} reason={closeReason}
/> />
)} )}
</> </>
)} )}
</> </>
); );
}
AlertBanner.propTypes = {
intl: intlShape.isRequired,
content: PropTypes.oneOfType([commentShape.isRequired, postShape.isRequired]).isRequired,
}; };
export default injectIntl(AlertBanner); AlertBanner.propTypes = {
author: PropTypes.string.isRequired,
abuseFlagged: PropTypes.bool,
closed: PropTypes.bool,
closedBy: PropTypes.string,
closedByLabel: PropTypes.string,
closeReason: PropTypes.string,
editByLabel: PropTypes.string,
lastEdit: PropTypes.shape({
editorUsername: PropTypes.string,
reason: PropTypes.string,
}),
};
AlertBanner.defaultProps = {
abuseFlagged: false,
closed: undefined,
closedBy: undefined,
closedByLabel: undefined,
closeReason: undefined,
editByLabel: undefined,
lastEdit: {},
};
export default React.memo(AlertBanner);

View File

@@ -1,24 +1,25 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { Alert } from '@edx/paragon'; import { Alert } from '@edx/paragon';
import messages from '../post-comments/messages'; import messages from '../post-comments/messages';
import AuthorLabel from './AuthorLabel'; import AuthorLabel from './AuthorLabel';
function AlertBar({ const AlertBar = ({
intl,
message, message,
author, author,
authorLabel, authorLabel,
labelColor, labelColor,
reason, reason,
}) { }) => {
const intl = useIntl();
return ( return (
<Alert variant="info" className="px-3 shadow-none mb-1 py-10px bg-light-200"> <Alert variant="info" className="px-3 shadow-none mb-1 py-10px bg-light-200">
<div className="d-flex align-items-center flex-wrap text-gray-700 font-style"> <div className="d-flex align-items-center flex-wrap text-gray-700 font-style">
{intl.formatMessage(message)} {message}
<span className="ml-1"> <span className="ml-1">
<AuthorLabel <AuthorLabel
author={author} author={author}
@@ -38,10 +39,9 @@ function AlertBar({
</div> </div>
</Alert> </Alert>
); );
} };
AlertBar.propTypes = { AlertBar.propTypes = {
intl: intlShape.isRequired,
message: PropTypes.string, message: PropTypes.string,
author: PropTypes.string, author: PropTypes.string,
authorLabel: PropTypes.string, authorLabel: PropTypes.string,
@@ -57,4 +57,4 @@ AlertBar.defaultProps = {
reason: '', reason: '',
}; };
export default injectIntl(AlertBar); export default React.memo(AlertBar);

View File

@@ -1,23 +1,22 @@
import React, { useContext } from 'react'; import React, { useContext, useMemo } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import classNames from 'classnames'; import classNames from 'classnames';
import { Link, useLocation } from 'react-router-dom'; import { generatePath } from 'react-router';
import { Link } from 'react-router-dom';
import * as timeago from 'timeago.js'; import * as timeago from 'timeago.js';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { Icon, OverlayTrigger, Tooltip } from '@edx/paragon'; import { Icon, OverlayTrigger, Tooltip } from '@edx/paragon';
import { Institution, School } from '@edx/paragon/icons'; import { Institution, School } from '@edx/paragon/icons';
import { Routes } from '../../data/constants'; import { Routes } from '../../data/constants';
import { useShowLearnersTab } from '../data/hooks'; import { useShowLearnersTab } from '../data/hooks';
import messages from '../messages'; import messages from '../messages';
import { discussionsPath } from '../utils';
import { DiscussionContext } from './context'; import { DiscussionContext } from './context';
import timeLocale from './time-locale'; import timeLocale from './time-locale';
function AuthorLabel({ const AuthorLabel = ({
intl,
author, author,
authorLabel, authorLabel,
linkToProfile, linkToProfile,
@@ -26,17 +25,18 @@ function AuthorLabel({
postCreatedAt, postCreatedAt,
authorToolTip, authorToolTip,
postOrComment, postOrComment,
}) { }) => {
const location = useLocation(); timeago.register('time-locale', timeLocale);
const intl = useIntl();
const { courseId } = useContext(DiscussionContext); const { courseId } = useContext(DiscussionContext);
let icon = null; let icon = null;
let authorLabelMessage = null; let authorLabelMessage = null;
timeago.register('time-locale', timeLocale);
if (authorLabel === 'Staff') { if (authorLabel === 'Staff') {
icon = Institution; icon = Institution;
authorLabelMessage = intl.formatMessage(messages.authorLabelStaff); authorLabelMessage = intl.formatMessage(messages.authorLabelStaff);
} }
if (authorLabel === 'Community TA') { if (authorLabel === 'Community TA') {
icon = School; icon = School;
authorLabelMessage = intl.formatMessage(messages.authorLabelTA); authorLabelMessage = intl.formatMessage(messages.authorLabelTA);
@@ -49,7 +49,7 @@ function AuthorLabel({
const showUserNameAsLink = useShowLearnersTab() const showUserNameAsLink = useShowLearnersTab()
&& linkToProfile && author && author !== intl.formatMessage(messages.anonymous); && linkToProfile && author && author !== intl.formatMessage(messages.anonymous);
const authorName = ( const authorName = useMemo(() => (
<span <span
className={classNames('mr-1.5 font-size-14 font-style font-weight-500', { className={classNames('mr-1.5 font-size-14 font-style font-weight-500', {
'text-gray-700': isRetiredUser, 'text-gray-700': isRetiredUser,
@@ -60,8 +60,9 @@ function AuthorLabel({
> >
{isRetiredUser ? '[Deactivated]' : author} {isRetiredUser ? '[Deactivated]' : author}
</span> </span>
); ), [author, authorLabelMessage, isRetiredUser]);
const labelContents = (
const labelContents = useMemo(() => (
<> <>
<OverlayTrigger <OverlayTrigger
overlay={( overlay={(
@@ -109,7 +110,7 @@ function AuthorLabel({
</span> </span>
)} )}
</> </>
); ), [author, authorLabelMessage, authorToolTip, icon, isRetiredUser, postCreatedAt, showTextPrimary, alert]);
return showUserNameAsLink return showUserNameAsLink
? ( ? (
@@ -117,7 +118,7 @@ function AuthorLabel({
<Link <Link
data-testid="learner-posts-link" data-testid="learner-posts-link"
id="learner-posts-link" id="learner-posts-link"
to={discussionsPath(Routes.LEARNERS.POSTS, { learnerUsername: author, courseId })(location)} to={generatePath(Routes.LEARNERS.POSTS, { learnerUsername: author, courseId })}
className="text-decoration-none" className="text-decoration-none"
style={{ width: 'fit-content' }} style={{ width: 'fit-content' }}
> >
@@ -127,10 +128,9 @@ function AuthorLabel({
</div> </div>
) )
: <div className={className}>{authorName}{labelContents}</div>; : <div className={className}>{authorName}{labelContents}</div>;
} };
AuthorLabel.propTypes = { AuthorLabel.propTypes = {
intl: intlShape.isRequired,
author: PropTypes.string.isRequired, author: PropTypes.string.isRequired,
authorLabel: PropTypes.string, authorLabel: PropTypes.string,
linkToProfile: PropTypes.bool, linkToProfile: PropTypes.bool,
@@ -151,4 +151,4 @@ AuthorLabel.defaultProps = {
postOrComment: false, postOrComment: false,
}; };
export default injectIntl(AuthorLabel); export default React.memo(AuthorLabel);

View File

@@ -1,13 +1,12 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { ActionRow, Button, ModalDialog } from '@edx/paragon'; import { ActionRow, Button, ModalDialog } from '@edx/paragon';
import messages from '../messages'; import messages from '../messages';
function Confirmation({ function Confirmation({
intl,
isOpen, isOpen,
title, title,
description, description,
@@ -17,6 +16,8 @@ function Confirmation({
confirmButtonVariant, confirmButtonVariant,
confirmButtonText, confirmButtonText,
}) { }) {
const intl = useIntl();
return ( return (
<ModalDialog title={title} isOpen={isOpen} hasCloseButton={false} onClose={onClose} zIndex={5000}> <ModalDialog title={title} isOpen={isOpen} hasCloseButton={false} onClose={onClose} zIndex={5000}>
<ModalDialog.Header> <ModalDialog.Header>
@@ -42,7 +43,6 @@ function Confirmation({
} }
Confirmation.propTypes = { Confirmation.propTypes = {
intl: intlShape.isRequired,
isOpen: PropTypes.bool.isRequired, isOpen: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired, onClose: PropTypes.func.isRequired,
comfirmAction: PropTypes.func.isRequired, comfirmAction: PropTypes.func.isRequired,
@@ -59,4 +59,4 @@ Confirmation.defaultProps = {
confirmButtonText: '', confirmButtonText: '',
}; };
export default injectIntl(Confirmation); export default React.memo(Confirmation);

View File

@@ -1,30 +1,34 @@
import React from 'react'; import React, { useContext } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import * as timeago from 'timeago.js'; import * as timeago from 'timeago.js';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { Alert, Icon } from '@edx/paragon'; import { Alert, Icon } from '@edx/paragon';
import { CheckCircle, Verified } from '@edx/paragon/icons'; import { CheckCircle, Verified } from '@edx/paragon/icons';
import { ThreadType } from '../../data/constants'; import { ThreadType } from '../../data/constants';
import { commentShape } from '../post-comments/comments/comment/proptypes';
import messages from '../post-comments/messages'; import messages from '../post-comments/messages';
import { PostCommentsContext } from '../post-comments/postCommentsContext';
import AuthorLabel from './AuthorLabel'; import AuthorLabel from './AuthorLabel';
import timeLocale from './time-locale'; import timeLocale from './time-locale';
function EndorsedAlertBanner({ function EndorsedAlertBanner({
intl, endorsed,
content, endorsedAt,
postType, endorsedBy,
endorsedByLabel,
}) { }) {
timeago.register('time-locale', timeLocale); timeago.register('time-locale', timeLocale);
const intl = useIntl();
const { postType } = useContext(PostCommentsContext);
const isQuestion = postType === ThreadType.QUESTION; const isQuestion = postType === ThreadType.QUESTION;
const classes = isQuestion ? 'bg-success-500 text-white' : 'bg-dark-500 text-white'; const classes = isQuestion ? 'bg-success-500 text-white' : 'bg-dark-500 text-white';
const iconClass = isQuestion ? CheckCircle : Verified; const iconClass = isQuestion ? CheckCircle : Verified;
return ( return (
content.endorsed && ( endorsed && (
<Alert <Alert
variant="plain" variant="plain"
className={`px-2.5 mb-0 py-8px align-items-center shadow-none ${classes}`} className={`px-2.5 mb-0 py-8px align-items-center shadow-none ${classes}`}
@@ -45,11 +49,11 @@ function EndorsedAlertBanner({
</div> </div>
<span className="d-flex align-items-center align-items-center flex-wrap" style={{ marginRight: '-1px' }}> <span className="d-flex align-items-center align-items-center flex-wrap" style={{ marginRight: '-1px' }}>
<AuthorLabel <AuthorLabel
author={content.endorsedBy} author={endorsedBy}
authorLabel={content.endorsedByLabel} authorLabel={endorsedByLabel}
linkToProfile linkToProfile
alert={content.endorsed} alert={endorsed}
postCreatedAt={content.endorsedAt} postCreatedAt={endorsedAt}
authorToolTip authorToolTip
postOrComment postOrComment
/> />
@@ -61,13 +65,16 @@ function EndorsedAlertBanner({
} }
EndorsedAlertBanner.propTypes = { EndorsedAlertBanner.propTypes = {
intl: intlShape.isRequired, endorsed: PropTypes.bool.isRequired,
content: PropTypes.oneOfType([commentShape.isRequired]).isRequired, endorsedAt: PropTypes.string,
postType: PropTypes.string, endorsedBy: PropTypes.string,
endorsedByLabel: PropTypes.string,
}; };
EndorsedAlertBanner.defaultProps = { EndorsedAlertBanner.defaultProps = {
postType: null, endorsedAt: null,
endorsedBy: null,
endorsedByLabel: null,
}; };
export default injectIntl(EndorsedAlertBanner); export default React.memo(EndorsedAlertBanner);

View File

@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
import classNames from 'classnames'; import classNames from 'classnames';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { import {
Button, Icon, IconButton, OverlayTrigger, Tooltip, Button, Icon, IconButton, OverlayTrigger, Tooltip,
} from '@edx/paragon'; } from '@edx/paragon';
@@ -12,29 +12,32 @@ import {
StarFilled, StarOutline, ThumbUpFilled, ThumbUpOutline, StarFilled, StarOutline, ThumbUpFilled, ThumbUpOutline,
} from '../../components/icons'; } from '../../components/icons';
import { useUserCanAddThreadInBlackoutDate } from '../data/hooks'; import { useUserCanAddThreadInBlackoutDate } from '../data/hooks';
import { commentShape } from '../post-comments/comments/comment/proptypes'; import { PostCommentsContext } from '../post-comments/postCommentsContext';
import { postShape } from '../posts/post/proptypes';
import ActionsDropdown from './ActionsDropdown'; import ActionsDropdown from './ActionsDropdown';
import { DiscussionContext } from './context'; import { DiscussionContext } from './context';
function HoverCard({ const HoverCard = ({
intl, id,
commentOrPost, contentType,
actionHandlers, actionHandlers,
handleResponseCommentButton, handleResponseCommentButton,
addResponseCommentButtonMessage, addResponseCommentButtonMessage,
onLike, onLike,
onFollow, onFollow,
isClosedPost, voted,
following,
endorseIcons, endorseIcons,
}) { }) => {
const intl = useIntl();
const { enableInContextSidebar } = useContext(DiscussionContext); const { enableInContextSidebar } = useContext(DiscussionContext);
const { isClosed } = useContext(PostCommentsContext);
const userCanAddThreadInBlackoutDate = useUserCanAddThreadInBlackoutDate(); const userCanAddThreadInBlackoutDate = useUserCanAddThreadInBlackoutDate();
return ( return (
<div <div
className="flex-fill justify-content-end align-items-center hover-card mr-n4 position-absolute" className="flex-fill justify-content-end align-items-center hover-card mr-n4 position-absolute"
data-testid={`hover-card-${commentOrPost.id}`} data-testid={`hover-card-${id}`}
id={`hover-card-${commentOrPost.id}`} id={`hover-card-${id}`}
> >
{userCanAddThreadInBlackoutDate && ( {userCanAddThreadInBlackoutDate && (
<div className="d-flex"> <div className="d-flex">
@@ -43,7 +46,7 @@ function HoverCard({
className={classNames('px-2.5 py-2 border-0 font-style text-gray-700 font-size-12', className={classNames('px-2.5 py-2 border-0 font-style text-gray-700 font-size-12',
{ 'w-100': enableInContextSidebar })} { 'w-100': enableInContextSidebar })}
onClick={() => handleResponseCommentButton()} onClick={() => handleResponseCommentButton()}
disabled={isClosedPost} disabled={isClosed}
style={{ lineHeight: '20px' }} style={{ lineHeight: '20px' }}
> >
{addResponseCommentButtonMessage} {addResponseCommentButtonMessage}
@@ -76,7 +79,7 @@ function HoverCard({
)} )}
<div className="hover-button"> <div className="hover-button">
<IconButton <IconButton
src={commentOrPost.voted ? ThumbUpFilled : ThumbUpOutline} src={voted ? ThumbUpFilled : ThumbUpOutline}
iconAs={Icon} iconAs={Icon}
size="sm" size="sm"
alt="Like" alt="Like"
@@ -87,10 +90,10 @@ function HoverCard({
}} }}
/> />
</div> </div>
{commentOrPost.following !== undefined && ( {following !== undefined && (
<div className="hover-button"> <div className="hover-button">
<IconButton <IconButton
src={commentOrPost.following ? StarFilled : StarOutline} src={following ? StarFilled : StarOutline}
iconAs={Icon} iconAs={Icon}
size="sm" size="sm"
alt="Follow" alt="Follow"
@@ -103,27 +106,34 @@ function HoverCard({
</div> </div>
)} )}
<div className="hover-button ml-auto"> <div className="hover-button ml-auto">
<ActionsDropdown commentOrPost={commentOrPost} actionHandlers={actionHandlers} dropDownIconSize /> <ActionsDropdown
id={id}
contentType={contentType}
actionHandlers={actionHandlers}
dropDownIconSize
/>
</div> </div>
</div> </div>
); );
} };
HoverCard.propTypes = { HoverCard.propTypes = {
intl: intlShape.isRequired, id: PropTypes.string.isRequired,
commentOrPost: PropTypes.oneOfType([commentShape, postShape]).isRequired, contentType: PropTypes.string.isRequired,
actionHandlers: PropTypes.objectOf(PropTypes.func).isRequired, actionHandlers: PropTypes.objectOf(PropTypes.func).isRequired,
handleResponseCommentButton: PropTypes.func.isRequired, handleResponseCommentButton: PropTypes.func.isRequired,
onLike: PropTypes.func.isRequired,
onFollow: PropTypes.func,
addResponseCommentButtonMessage: PropTypes.string.isRequired, addResponseCommentButtonMessage: PropTypes.string.isRequired,
isClosedPost: PropTypes.bool.isRequired, onLike: PropTypes.func.isRequired,
voted: PropTypes.bool.isRequired,
endorseIcons: PropTypes.objectOf(PropTypes.any), endorseIcons: PropTypes.objectOf(PropTypes.any),
onFollow: PropTypes.func,
following: PropTypes.bool,
}; };
HoverCard.defaultProps = { HoverCard.defaultProps = {
onFollow: () => null, onFollow: () => null,
endorseIcons: null, endorseIcons: null,
following: undefined,
}; };
export default injectIntl(HoverCard); export default React.memo(HoverCard);

View File

@@ -0,0 +1,12 @@
import { selectCommentOrResponseById } from '../post-comments/data/selectors';
import { selectThread } from '../posts/data/selectors';
export const ContentSelectors = {
POST: selectThread,
COMMENT: selectCommentOrResponseById,
};
export const ContentTypes = {
POST: 'POST',
COMMENT: 'COMMENT',
};

View File

@@ -1,15 +1,13 @@
/* eslint-disable import/prefer-default-export */
import { import {
useContext, useCallback,
useEffect, useContext, useEffect, useMemo, useRef, useState,
useRef,
useState,
} from 'react'; } from 'react';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { useHistory, useLocation, useRouteMatch } from 'react-router'; import { useHistory, useLocation, useRouteMatch } from 'react-router';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth'; import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { useIntl } from '@edx/frontend-platform/i18n';
import { AppContext } from '@edx/frontend-platform/react'; import { AppContext } from '@edx/frontend-platform/react';
import { breakpoints, useWindowSize } from '@edx/paragon'; import { breakpoints, useWindowSize } from '@edx/paragon';
@@ -42,16 +40,14 @@ import { fetchCourseConfig } from './thunks';
export function useTotalTopicThreadCount() { export function useTotalTopicThreadCount() {
const topics = useSelector(selectTopics); const topics = useSelector(selectTopics);
const count = useMemo(() => (
if (!topics) { Object.keys(topics)?.reduce((total, topicId) => {
return 0;
}
return Object.keys(topics)
.reduce((total, topicId) => {
const topic = topics[topicId]; const topic = topics[topicId];
return total + topic.threadCounts.discussion + topic.threadCounts.question; return total + topic.threadCounts.discussion + topic.threadCounts.question;
}, 0); }, 0)),
[]);
return count;
} }
export const useSidebarVisible = () => { export const useSidebarVisible = () => {
@@ -87,13 +83,14 @@ export function useCourseDiscussionData(courseId) {
export function useRedirectToThread(courseId, enableInContextSidebar) { export function useRedirectToThread(courseId, enableInContextSidebar) {
const dispatch = useDispatch(); const dispatch = useDispatch();
const redirectToThread = useSelector(
(state) => state.threads.redirectToThread,
);
const history = useHistory(); const history = useHistory();
const location = useLocation(); const location = useLocation();
return useEffect(() => { const redirectToThread = useSelector(
(state) => state.threads.redirectToThread,
);
useEffect(() => {
// After posting a new thread we'd like to redirect users to it, the topic and post id are temporarily // After posting a new thread we'd like to redirect users to it, the topic and post id are temporarily
// stored in redirectToThread // stored in redirectToThread
if (redirectToThread) { if (redirectToThread) {
@@ -153,17 +150,20 @@ export function useContainerSize(refContainer) {
return height; return height;
} }
export const useAlertBannerVisible = (content) => { export const useAlertBannerVisible = (
{
author, abuseFlagged, lastEdit, closed,
} = {},
) => {
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges); const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
const userIsGroupTa = useSelector(selectUserIsGroupTa); const userIsGroupTa = useSelector(selectUserIsGroupTa);
const { reasonCodesEnabled } = useSelector(selectModerationSettings); const { reasonCodesEnabled } = useSelector(selectModerationSettings);
const userIsContentAuthor = getAuthenticatedUser().username === content.author; const userIsContentAuthor = getAuthenticatedUser().username === author;
const canSeeLastEditOrClosedAlert = (userHasModerationPrivileges || userIsContentAuthor || userIsGroupTa); const canSeeLastEditOrClosedAlert = (userHasModerationPrivileges || userIsContentAuthor || userIsGroupTa);
const canSeeReportedBanner = content.abuseFlagged; const canSeeReportedBanner = abuseFlagged;
return ( return (
(reasonCodesEnabled && canSeeLastEditOrClosedAlert && (content.lastEdit?.reason || content.closed)) (reasonCodesEnabled && canSeeLastEditOrClosedAlert && (lastEdit?.reason || closed)) || (canSeeReportedBanner)
|| (content.abuseFlagged && canSeeReportedBanner)
); );
}; };
@@ -193,38 +193,50 @@ export const useCurrentDiscussionTopic = () => {
export const useUserCanAddThreadInBlackoutDate = () => { export const useUserCanAddThreadInBlackoutDate = () => {
const blackoutDateRange = useSelector(selectBlackoutDate); const blackoutDateRange = useSelector(selectBlackoutDate);
const isUserAdmin = useSelector(selectUserIsStaff); const isUserAdmin = useSelector(selectUserIsStaff);
const userHasModerationPrivilages = useSelector(selectUserHasModerationPrivileges); const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
const isUserGroupTA = useSelector(selectUserIsGroupTa); const isUserGroupTA = useSelector(selectUserIsGroupTa);
const isCourseAdmin = useSelector(selectIsCourseAdmin); const isCourseAdmin = useSelector(selectIsCourseAdmin);
const isCourseStaff = useSelector(selectIsCourseStaff); const isCourseStaff = useSelector(selectIsCourseStaff);
const isInBlackoutDateRange = inBlackoutDateRange(blackoutDateRange); const isPrivileged = isUserAdmin || userHasModerationPrivileges || isUserGroupTA || isCourseAdmin || isCourseStaff;
const isInBlackoutDateRange = useMemo(() => inBlackoutDateRange(blackoutDateRange), [blackoutDateRange]);
return (!(isInBlackoutDateRange) return (!(isInBlackoutDateRange) || (isPrivileged));
|| (isUserAdmin || userHasModerationPrivilages || isUserGroupTA || isCourseAdmin || isCourseStaff));
}; };
function camelToConstant(string) { function camelToConstant(string) {
return string.replace(/[A-Z]/g, (match) => `_${match}`).toUpperCase(); return string.replace(/[A-Z]/g, (match) => `_${match}`).toUpperCase();
} }
export const useTourConfiguration = (intl) => { export const useTourConfiguration = () => {
const intl = useIntl();
const dispatch = useDispatch(); const dispatch = useDispatch();
const { enableInContextSidebar } = useContext(DiscussionContext); const { enableInContextSidebar } = useContext(DiscussionContext);
const tours = useSelector(selectTours); const tours = useSelector(selectTours);
return tours.map((tour) => ( const handleOnDismiss = useCallback((id) => (
{ dispatch(updateTourShowStatus(id))
tourId: tour.tourName, ), []);
advanceButtonText: intl.formatMessage(messages.advanceButtonText),
dismissButtonText: intl.formatMessage(messages.dismissButtonText), const handleOnEnd = useCallback((id) => (
endButtonText: intl.formatMessage(messages.endButtonText), dispatch(updateTourShowStatus(id))
enabled: tour && Boolean(tour.enabled && tour.showTour && !enableInContextSidebar), ), []);
onDismiss: () => dispatch(updateTourShowStatus(tour.id)),
onEnd: () => dispatch(updateTourShowStatus(tour.id)), const toursConfig = useMemo(() => (
checkpoints: tourCheckpoints(intl)[camelToConstant(tour.tourName)], tours?.map((tour) => (
} {
)); tourId: tour.tourName,
advanceButtonText: intl.formatMessage(messages.advanceButtonText),
dismissButtonText: intl.formatMessage(messages.dismissButtonText),
endButtonText: intl.formatMessage(messages.endButtonText),
enabled: tour && Boolean(tour.enabled && tour.showTour && !enableInContextSidebar),
onDismiss: () => handleOnDismiss(tour.id),
onEnd: () => handleOnEnd(tour.id),
checkpoints: tourCheckpoints(intl)[camelToConstant(tour.tourName)],
}
))
), [tours, enableInContextSidebar]);
return toursConfig;
}; };
export const useDebounce = (value, delay) => { export const useDebounce = (value, delay) => {

View File

@@ -1,36 +1,39 @@
import React, { useState } from 'react'; import React, { useCallback, useMemo, useState } from 'react';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { PageBanner } from '@edx/paragon'; import { PageBanner } from '@edx/paragon';
import { selectBlackoutDate } from '../data/selectors'; import { selectBlackoutDate } from '../data/selectors';
import messages from '../messages'; import messages from '../messages';
import { inBlackoutDateRange } from '../utils'; import { inBlackoutDateRange } from '../utils';
function BlackoutInformationBanner({ const BlackoutInformationBanner = () => {
intl, const intl = useIntl();
}) { const blackoutDate = useSelector(selectBlackoutDate);
const isDiscussionsBlackout = inBlackoutDateRange(useSelector(selectBlackoutDate));
const [showBanner, setShowBanner] = useState(true); const [showBanner, setShowBanner] = useState(true);
const isDiscussionsBlackout = useMemo(() => (
inBlackoutDateRange(blackoutDate)
), [blackoutDate]);
const handleDismiss = useCallback(() => {
setShowBanner(false);
}, []);
return ( return (
<PageBanner <PageBanner
variant="accentB" variant="accentB"
show={isDiscussionsBlackout && showBanner} show={isDiscussionsBlackout && showBanner}
dismissible dismissible
onDismiss={() => setShowBanner(false)} onDismiss={handleDismiss}
> >
<div className="font-weight-500"> <div className="font-weight-500">
{intl.formatMessage(messages.blackoutDiscussionInformation)} {intl.formatMessage(messages.blackoutDiscussionInformation)}
</div> </div>
</PageBanner> </PageBanner>
); );
}
BlackoutInformationBanner.propTypes = {
intl: intlShape.isRequired,
}; };
export default injectIntl(BlackoutInformationBanner); export default BlackoutInformationBanner;

View File

@@ -1,37 +1,39 @@
import React from 'react'; import React, { lazy, Suspense } from 'react';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { Route, Switch } from 'react-router'; import { Route, Switch } from 'react-router';
import { injectIntl } from '@edx/frontend-platform/i18n'; import Spinner from '../../components/Spinner';
import { Routes } from '../../data/constants'; import { Routes } from '../../data/constants';
import { PostCommentsView } from '../post-comments';
import { PostEditor } from '../posts';
function DiscussionContent() { const PostEditor = lazy(() => import('../posts/post-editor/PostEditor'));
const PostCommentsView = lazy(() => import('../post-comments/PostCommentsView'));
const DiscussionContent = () => {
const postEditorVisible = useSelector((state) => state.threads.postEditorVisible); const postEditorVisible = useSelector((state) => state.threads.postEditorVisible);
return ( return (
<div className="d-flex bg-light-400 flex-column w-75 w-xs-100 w-xl-75 align-items-center"> <div className="d-flex bg-light-400 flex-column w-75 w-xs-100 w-xl-75 align-items-center">
<div className="d-flex flex-column w-100"> <div className="d-flex flex-column w-100">
{postEditorVisible ? ( <Suspense fallback={(<Spinner />)}>
<Route path={Routes.POSTS.NEW_POST}> {postEditorVisible ? (
<PostEditor /> <Route path={Routes.POSTS.NEW_POST}>
</Route> <PostEditor />
) : (
<Switch>
<Route path={Routes.POSTS.EDIT_POST}>
<PostEditor editExisting />
</Route> </Route>
<Route path={Routes.COMMENTS.PATH}> ) : (
<PostCommentsView /> <Switch>
</Route> <Route path={Routes.POSTS.EDIT_POST}>
</Switch> <PostEditor editExisting />
)} </Route>
<Route path={Routes.COMMENTS.PATH}>
<PostCommentsView />
</Route>
</Switch>
)}
</Suspense>
</div> </div>
</div> </div>
); );
} };
export default injectIntl(DiscussionContent); export default DiscussionContent;

View File

@@ -1,4 +1,6 @@
import React, { useContext, useEffect, useRef } from 'react'; import React, {
lazy, Suspense, useContext, useEffect, useRef,
} from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import classNames from 'classnames'; import classNames from 'classnames';
@@ -9,18 +11,22 @@ import {
import { useWindowSize } from '@edx/paragon'; import { useWindowSize } from '@edx/paragon';
import Spinner from '../../components/Spinner';
import { RequestStatus, Routes } from '../../data/constants'; import { RequestStatus, Routes } from '../../data/constants';
import { DiscussionContext } from '../common/context'; import { DiscussionContext } from '../common/context';
import { import {
useContainerSize, useIsOnDesktop, useIsOnXLDesktop, useShowLearnersTab, useContainerSize, useIsOnDesktop, useIsOnXLDesktop, useShowLearnersTab,
} from '../data/hooks'; } from '../data/hooks';
import { selectconfigLoadingStatus, selectEnableInContext } from '../data/selectors'; import { selectconfigLoadingStatus, selectEnableInContext } from '../data/selectors';
import { TopicPostsView, TopicsView as InContextTopicsView } from '../in-context-topics';
import { LearnerPostsView, LearnersView } from '../learners';
import { PostsView } from '../posts';
import { TopicsView as LegacyTopicsView } from '../topics';
export default function DiscussionSidebar({ displaySidebar, postActionBarRef }) { const TopicPostsView = lazy(() => import('../in-context-topics/TopicPostsView'));
const InContextTopicsView = lazy(() => import('../in-context-topics/TopicsView'));
const LearnerPostsView = lazy(() => import('../learners/LearnerPostsView'));
const LearnersView = lazy(() => import('../learners/LearnersView'));
const PostsView = lazy(() => import('../posts/PostsView'));
const LegacyTopicsView = lazy(() => import('../topics/TopicsView'));
const DiscussionSidebar = ({ displaySidebar, postActionBarRef }) => {
const location = useLocation(); const location = useLocation();
const isOnDesktop = useIsOnDesktop(); const isOnDesktop = useIsOnDesktop();
const isOnXLDesktop = useIsOnXLDesktop(); const isOnXLDesktop = useIsOnXLDesktop();
@@ -55,15 +61,16 @@ export default function DiscussionSidebar({ displaySidebar, postActionBarRef })
})} })}
data-testid="sidebar" data-testid="sidebar"
> >
<Switch> <Suspense fallback={(<Spinner />)}>
{enableInContext && !enableInContextSidebar && ( <Switch>
{enableInContext && !enableInContextSidebar && (
<Route <Route
path={Routes.TOPICS.ALL} path={Routes.TOPICS.ALL}
component={InContextTopicsView} component={InContextTopicsView}
exact exact
/> />
)} )}
{enableInContext && !enableInContextSidebar && ( {enableInContext && !enableInContextSidebar && (
<Route <Route
path={[ path={[
Routes.TOPICS.TOPIC, Routes.TOPICS.TOPIC,
@@ -74,19 +81,19 @@ export default function DiscussionSidebar({ displaySidebar, postActionBarRef })
component={TopicPostsView} component={TopicPostsView}
exact exact
/> />
)} )}
<Route <Route
path={[Routes.POSTS.ALL_POSTS, Routes.POSTS.MY_POSTS, Routes.POSTS.PATH, Routes.TOPICS.CATEGORY]} path={[Routes.POSTS.ALL_POSTS, Routes.POSTS.MY_POSTS, Routes.POSTS.PATH, Routes.TOPICS.CATEGORY]}
component={PostsView} component={PostsView}
/> />
<Route path={Routes.TOPICS.PATH} component={LegacyTopicsView} /> <Route path={Routes.TOPICS.PATH} component={LegacyTopicsView} />
{redirectToLearnersTab && ( {redirectToLearnersTab && (
<Route path={Routes.LEARNERS.POSTS} component={LearnerPostsView} /> <Route path={Routes.LEARNERS.POSTS} component={LearnerPostsView} />
)} )}
{redirectToLearnersTab && ( {redirectToLearnersTab && (
<Route path={Routes.LEARNERS.PATH} component={LearnersView} /> <Route path={Routes.LEARNERS.PATH} component={LearnersView} />
)} )}
{configStatus === RequestStatus.SUCCESSFUL && ( {configStatus === RequestStatus.SUCCESSFUL && (
<Redirect <Redirect
from={Routes.DISCUSSIONS.PATH} from={Routes.DISCUSSIONS.PATH}
to={{ to={{
@@ -94,15 +101,11 @@ export default function DiscussionSidebar({ displaySidebar, postActionBarRef })
pathname: Routes.POSTS.ALL_POSTS, pathname: Routes.POSTS.ALL_POSTS,
}} }}
/> />
)} )}
</Switch> </Switch>
</Suspense>
</div> </div>
); );
}
DiscussionSidebar.defaultProps = {
displaySidebar: false,
postActionBarRef: null,
}; };
DiscussionSidebar.propTypes = { DiscussionSidebar.propTypes = {
@@ -112,3 +115,10 @@ DiscussionSidebar.propTypes = {
PropTypes.shape({ current: PropTypes.instanceOf(Element) }), PropTypes.shape({ current: PropTypes.instanceOf(Element) }),
]), ]),
}; };
DiscussionSidebar.defaultProps = {
displaySidebar: false,
postActionBarRef: null,
};
export default React.memo(DiscussionSidebar);

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useRef } from 'react'; import React, { lazy, Suspense, useRef } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
@@ -6,12 +6,10 @@ import {
Route, Switch, useLocation, useRouteMatch, Route, Switch, useLocation, useRouteMatch,
} from 'react-router'; } from 'react-router';
import Footer from '@edx/frontend-component-footer';
import { LearningHeader as Header } from '@edx/frontend-component-header'; import { LearningHeader as Header } from '@edx/frontend-component-header';
import { getConfig } from '@edx/frontend-platform'; import { getConfig } from '@edx/frontend-platform';
import { PostActionsBar } from '../../components'; import { Spinner } from '../../components';
import { CourseTabsNavigation } from '../../components/NavigationBar';
import { selectCourseTabs } from '../../components/NavigationBar/data/selectors'; import { selectCourseTabs } from '../../components/NavigationBar/data/selectors';
import { ALL_ROUTES, DiscussionProvider, Routes } from '../../data/constants'; import { ALL_ROUTES, DiscussionProvider, Routes } from '../../data/constants';
import { DiscussionContext } from '../common/context'; import { DiscussionContext } from '../common/context';
@@ -22,17 +20,21 @@ import { selectDiscussionProvider, selectEnableInContext } from '../data/selecto
import { EmptyLearners, EmptyPosts, EmptyTopics } from '../empty-posts'; import { EmptyLearners, EmptyPosts, EmptyTopics } from '../empty-posts';
import { EmptyTopic as InContextEmptyTopics } from '../in-context-topics/components'; import { EmptyTopic as InContextEmptyTopics } from '../in-context-topics/components';
import messages from '../messages'; import messages from '../messages';
import { LegacyBreadcrumbMenu, NavigationBar } from '../navigation';
import { selectPostEditorVisible } from '../posts/data/selectors'; 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 useFeedbackWrapper from './FeedbackWrapper';
import InformationBanner from './InformationBanner';
export default function DiscussionsHome() { const Footer = lazy(() => import('@edx/frontend-component-footer'));
const PostActionsBar = lazy(() => import('../posts/post-actions-bar/PostActionsBar'));
const CourseTabsNavigation = lazy(() => import('../../components/NavigationBar/CourseTabsNavigation'));
const LegacyBreadcrumbMenu = lazy(() => import('../navigation/breadcrumb-menu/LegacyBreadcrumbMenu'));
const NavigationBar = lazy(() => import('../navigation/navigation-bar/NavigationBar'));
const DiscussionsProductTour = lazy(() => import('../tours/DiscussionsProductTour'));
const BlackoutInformationBanner = lazy(() => import('./BlackoutInformationBanner'));
const DiscussionContent = lazy(() => import('./DiscussionContent'));
const DiscussionSidebar = lazy(() => import('./DiscussionSidebar'));
const InformationBanner = lazy(() => import('./InformationBanner'));
const DiscussionsHome = () => {
const location = useLocation(); const location = useLocation();
const postActionBarRef = useRef(null); const postActionBarRef = useRef(null);
const postEditorVisible = useSelector(selectPostEditorVisible); const postEditorVisible = useSelector(selectPostEditorVisible);
@@ -40,7 +42,6 @@ export default function DiscussionsHome() {
const enableInContext = useSelector(selectEnableInContext); const enableInContext = useSelector(selectEnableInContext);
const { courseNumber, courseTitle, org } = useSelector(selectCourseTabs); const { courseNumber, courseTitle, org } = useSelector(selectCourseTabs);
const { params: { page } } = useRouteMatch(`${Routes.COMMENTS.PAGE}?`); const { params: { page } } = useRouteMatch(`${Routes.COMMENTS.PAGE}?`);
const { params: { path } } = useRouteMatch(`${Routes.DISCUSSIONS.PATH}/:path*`);
const { params } = useRouteMatch(ALL_ROUTES); const { params } = useRouteMatch(ALL_ROUTES);
const isRedirectToLearners = useShowLearnersTab(); const isRedirectToLearners = useShowLearnersTab();
const isOnDesktop = useIsOnDesktop(); const isOnDesktop = useIsOnDesktop();
@@ -60,54 +61,60 @@ export default function DiscussionsHome() {
const displayContentArea = (postId || postEditorVisible || (learnerUsername && postId)); const displayContentArea = (postId || postEditorVisible || (learnerUsername && postId));
if (displayContentArea) { displaySidebar = isOnDesktop; } if (displayContentArea) { displaySidebar = isOnDesktop; }
useEffect(() => {
if (path && path !== 'undefined') {
postMessageToParent('discussions.navigate', { path });
}
}, [path]);
return ( return (
<DiscussionContext.Provider value={{ <Suspense fallback={(<Spinner />)}>
page, <DiscussionContext.Provider value={{
courseId, page,
postId, courseId,
topicId, postId,
enableInContextSidebar, topicId,
category, enableInContextSidebar,
learnerUsername, category,
}} learnerUsername,
> }}
{!enableInContextSidebar && <Header courseOrg={org} courseNumber={courseNumber} courseTitle={courseTitle} />} >
<main className="container-fluid d-flex flex-column p-0 w-100" id="main" tabIndex="-1"> {!enableInContextSidebar && (
{!enableInContextSidebar && <CourseTabsNavigation activeTab="discussion" courseId={courseId} />} <Header courseOrg={org} courseNumber={courseNumber} courseTitle={courseTitle} />
<div
className={classNames('header-action-bar', {
'shadow-none border-light-300 border-bottom': enableInContextSidebar,
})}
ref={postActionBarRef}
>
<div
className={classNames('d-flex flex-row justify-content-between navbar fixed-top', {
'pl-4 pr-3 py-0': enableInContextSidebar,
})}
>
{!enableInContextSidebar && <Route path={Routes.DISCUSSIONS.PATH} component={NavigationBar} />}
<PostActionsBar />
</div>
{isFeedbackBannerVisible && <InformationBanner />}
<BlackoutInformationBanner />
</div>
{provider === DiscussionProvider.LEGACY && (
<Route
path={[Routes.POSTS.PATH, Routes.TOPICS.CATEGORY]}
component={LegacyBreadcrumbMenu}
/>
)} )}
<main className="container-fluid d-flex flex-column p-0 w-100" id="main" tabIndex="-1">
<div className="d-flex flex-row"> {!enableInContextSidebar && <CourseTabsNavigation activeTab="discussion" courseId={courseId} />}
<DiscussionSidebar displaySidebar={displaySidebar} postActionBarRef={postActionBarRef} /> <div
{displayContentArea && <DiscussionContent />} className={classNames('header-action-bar', {
{!displayContentArea && ( 'shadow-none border-light-300 border-bottom': enableInContextSidebar,
})}
ref={postActionBarRef}
>
<div
className={classNames('d-flex flex-row justify-content-between navbar fixed-top', {
'pl-4 pr-3 py-0': enableInContextSidebar,
})}
>
{!enableInContextSidebar && (
<NavigationBar />
)}
<PostActionsBar />
</div>
{isFeedbackBannerVisible && <InformationBanner />}
<BlackoutInformationBanner />
</div>
{provider === DiscussionProvider.LEGACY && (
<Suspense fallback={(<Spinner />)}>
<Route
path={[Routes.POSTS.PATH, Routes.TOPICS.CATEGORY]}
component={LegacyBreadcrumbMenu}
/>
</Suspense>
)}
<div className="d-flex flex-row position-relative">
<Suspense fallback={(<Spinner />)}>
<DiscussionSidebar displaySidebar={displaySidebar} postActionBarRef={postActionBarRef} />
</Suspense>
{displayContentArea && (
<Suspense fallback={(<Spinner />)}>
<DiscussionContent />
</Suspense>
)}
{!displayContentArea && (
<Switch> <Switch>
<Route <Route
path={Routes.TOPICS.PATH} path={Routes.TOPICS.PATH}
@@ -123,11 +130,16 @@ export default function DiscussionsHome() {
/> />
{isRedirectToLearners && <Route path={Routes.LEARNERS.PATH} component={EmptyLearners} />} {isRedirectToLearners && <Route path={Routes.LEARNERS.PATH} component={EmptyLearners} />}
</Switch> </Switch>
)}
</div>
{!enableInContextSidebar && (
<DiscussionsProductTour />
)} )}
</div> </main>
{!enableInContextSidebar && <DiscussionsProductTour />} {!enableInContextSidebar && <Footer />}
</main> </DiscussionContext.Provider>
{!enableInContextSidebar && <Footer />} </Suspense>
</DiscussionContext.Provider>
); );
} };
export default React.memo(DiscussionsHome);

View File

@@ -1,16 +1,15 @@
import React, { useState } from 'react'; import React, { useCallback, useState } from 'react';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { Hyperlink, PageBanner } from '@edx/paragon'; import { Hyperlink, PageBanner } from '@edx/paragon';
import { selectUserIsStaff, selectUserRoles } from '../data/selectors'; import { selectUserIsStaff, selectUserRoles } from '../data/selectors';
import messages from '../messages'; import messages from '../messages';
function InformationBanner({ const InformationBanner = () => {
intl, const intl = useIntl();
}) {
const [showBanner, setShowBanner] = useState(true); const [showBanner, setShowBanner] = useState(true);
const userRoles = useSelector(selectUserRoles); const userRoles = useSelector(selectUserRoles);
const isAdmin = useSelector(selectUserIsStaff); const isAdmin = useSelector(selectUserIsStaff);
@@ -20,12 +19,16 @@ function InformationBanner({
const hideLearnMoreButton = ((userRoles.includes('Student') && userRoles.length === 1) || !userRoles.length) && !isAdmin; const hideLearnMoreButton = ((userRoles.includes('Student') && userRoles.length === 1) || !userRoles.length) && !isAdmin;
const showStaffLink = isAdmin || userRoles.includes('Moderator') || userRoles.includes('Administrator'); const showStaffLink = isAdmin || userRoles.includes('Moderator') || userRoles.includes('Administrator');
const handleDismiss = useCallback(() => {
setShowBanner(false);
}, []);
return ( return (
<PageBanner <PageBanner
variant="light" variant="light"
show={showBanner} show={showBanner}
dismissible dismissible
onDismiss={() => setShowBanner(false)} onDismiss={handleDismiss}
> >
<div className="font-weight-500"> <div className="font-weight-500">
{intl.formatMessage(messages.bannerMessage)} {intl.formatMessage(messages.bannerMessage)}
@@ -55,10 +58,6 @@ function InformationBanner({
</div> </div>
</PageBanner> </PageBanner>
); );
}
InformationBanner.propTypes = {
intl: intlShape.isRequired,
}; };
export default injectIntl(InformationBanner); export default InformationBanner;

View File

@@ -1,12 +1,13 @@
import React from 'react'; import React from 'react';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { useIsOnDesktop } from '../data/hooks'; import { useIsOnDesktop } from '../data/hooks';
import messages from '../messages'; import messages from '../messages';
import EmptyPage from './EmptyPage'; import EmptyPage from './EmptyPage';
function EmptyLearners({ intl }) { const EmptyLearners = () => {
const intl = useIntl();
const isOnDesktop = useIsOnDesktop(); const isOnDesktop = useIsOnDesktop();
if (!isOnDesktop) { if (!isOnDesktop) {
@@ -16,10 +17,6 @@ function EmptyLearners({ intl }) {
return ( return (
<EmptyPage title={intl.formatMessage(messages.emptyTitle)} /> <EmptyPage title={intl.formatMessage(messages.emptyTitle)} />
); );
}
EmptyLearners.propTypes = {
intl: intlShape.isRequired,
}; };
export default injectIntl(EmptyLearners); export default EmptyLearners;

View File

@@ -7,13 +7,13 @@ import { Button } from '@edx/paragon';
import { ReactComponent as EmptyIcon } from '../../assets/empty.svg'; import { ReactComponent as EmptyIcon } from '../../assets/empty.svg';
function EmptyPage({ const EmptyPage = ({
title, title,
subTitle = null, subTitle = null,
action = null, action = null,
actionText = null, actionText = null,
fullWidth = false, fullWidth = false,
}) { }) => {
const containerClasses = classNames( const containerClasses = classNames(
'min-content-height justify-content-center align-items-center d-flex w-100 flex-column', 'min-content-height justify-content-center align-items-center d-flex w-100 flex-column',
{ 'bg-light-400': !fullWidth }, { 'bg-light-400': !fullWidth },
@@ -33,7 +33,7 @@ function EmptyPage({
</div> </div>
</div> </div>
); );
} };
EmptyPage.propTypes = { EmptyPage.propTypes = {
title: propTypes.string.isRequired, title: propTypes.string.isRequired,
@@ -50,4 +50,4 @@ EmptyPage.defaultProps = {
actionText: null, actionText: null,
}; };
export default EmptyPage; export default React.memo(EmptyPage);

View File

@@ -1,9 +1,9 @@
import React from 'react'; import React, { useCallback } from 'react';
import propTypes from 'prop-types'; import propTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { useIsOnDesktop } from '../data/hooks'; import { useIsOnDesktop } from '../data/hooks';
import { selectAreThreadsFiltered, selectPostThreadCount } from '../data/selectors'; import { selectAreThreadsFiltered, selectPostThreadCount } from '../data/selectors';
@@ -11,16 +11,16 @@ import messages from '../messages';
import { messages as postMessages, showPostEditor } from '../posts'; import { messages as postMessages, showPostEditor } from '../posts';
import EmptyPage from './EmptyPage'; import EmptyPage from './EmptyPage';
function EmptyPosts({ intl, subTitleMessage }) { const EmptyPosts = ({ subTitleMessage }) => {
const intl = useIntl();
const dispatch = useDispatch(); const dispatch = useDispatch();
const isOnDesktop = useIsOnDesktop();
const isFiltered = useSelector(selectAreThreadsFiltered); const isFiltered = useSelector(selectAreThreadsFiltered);
const totalThreads = useSelector(selectPostThreadCount); const totalThreads = useSelector(selectPostThreadCount);
const isOnDesktop = useIsOnDesktop();
function addPost() { const addPost = useCallback(() => (
return dispatch(showPostEditor()); dispatch(showPostEditor())
} ), []);
let title = messages.noPostSelected; let title = messages.noPostSelected;
let subTitle = null; let subTitle = null;
@@ -49,7 +49,7 @@ function EmptyPosts({ intl, subTitleMessage }) {
fullWidth={fullWidth} fullWidth={fullWidth}
/> />
); );
} };
EmptyPosts.propTypes = { EmptyPosts.propTypes = {
subTitleMessage: propTypes.shape({ subTitleMessage: propTypes.shape({
@@ -57,7 +57,6 @@ EmptyPosts.propTypes = {
defaultMessage: propTypes.string, defaultMessage: propTypes.string,
description: propTypes.string, description: propTypes.string,
}).isRequired, }).isRequired,
intl: intlShape.isRequired,
}; };
export default injectIntl(EmptyPosts); export default React.memo(EmptyPosts);

View File

@@ -1,9 +1,9 @@
import React from 'react'; import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { useRouteMatch } from 'react-router'; import { useRouteMatch } from 'react-router';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { ALL_ROUTES } from '../../data/constants'; import { ALL_ROUTES } from '../../data/constants';
import { useIsOnDesktop, useTotalTopicThreadCount } from '../data/hooks'; import { useIsOnDesktop, useTotalTopicThreadCount } from '../data/hooks';
@@ -12,18 +12,17 @@ import messages from '../messages';
import { messages as postMessages, showPostEditor } from '../posts'; import { messages as postMessages, showPostEditor } from '../posts';
import EmptyPage from './EmptyPage'; import EmptyPage from './EmptyPage';
function EmptyTopics({ intl }) { const EmptyTopics = () => {
const intl = useIntl();
const match = useRouteMatch(ALL_ROUTES); const match = useRouteMatch(ALL_ROUTES);
const dispatch = useDispatch(); const dispatch = useDispatch();
const isOnDesktop = useIsOnDesktop();
const hasGlobalThreads = useTotalTopicThreadCount() > 0; const hasGlobalThreads = useTotalTopicThreadCount() > 0;
const topicThreadCount = useSelector(selectTopicThreadCount(match.params.topicId)); const topicThreadCount = useSelector(selectTopicThreadCount(match.params.topicId));
function addPost() { const addPost = useCallback(() => (
return dispatch(showPostEditor()); dispatch(showPostEditor())
} ), []);
const isOnDesktop = useIsOnDesktop();
let title = messages.emptyTitle; let title = messages.emptyTitle;
let fullWidth = false; let fullWidth = false;
@@ -62,10 +61,6 @@ function EmptyTopics({ intl }) {
fullWidth={fullWidth} fullWidth={fullWidth}
/> />
); );
}
EmptyTopics.propTypes = {
intl: intlShape.isRequired,
}; };
export default injectIntl(EmptyTopics); export default EmptyTopics;

View File

@@ -1,15 +1,17 @@
import React, { useContext, useEffect } from 'react'; import React, {
useCallback, useContext, useEffect, useMemo,
} from 'react';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { Spinner } from '@edx/paragon'; import { Spinner } from '@edx/paragon';
import { RequestStatus, Routes } from '../../data/constants'; import { RequestStatus, Routes } from '../../data/constants';
import { DiscussionContext } from '../common/context'; import { DiscussionContext } from '../common/context';
import { selectDiscussionProvider } from '../data/selectors'; import { selectDiscussionProvider } from '../data/selectors';
import { selectTopicThreads } from '../posts/data/selectors'; import { selectTopicThreadsIds } from '../posts/data/selectors';
import PostsList from '../posts/PostsList'; import PostsList from '../posts/PostsList';
import { discussionsPath, handleKeyDown } from '../utils'; import { discussionsPath, handleKeyDown } from '../utils';
import { import {
@@ -21,19 +23,34 @@ import { BackButton, NoResults } from './components';
import messages from './messages'; import messages from './messages';
import { Topic } from './topic'; import { Topic } from './topic';
function TopicPostsView({ intl }) { const TopicPostsView = () => {
const intl = useIntl();
const location = useLocation(); const location = useLocation();
const dispatch = useDispatch(); const dispatch = useDispatch();
const { courseId, topicId, category } = useContext(DiscussionContext); const { courseId, topicId, category } = useContext(DiscussionContext);
const provider = useSelector(selectDiscussionProvider); const provider = useSelector(selectDiscussionProvider);
const topicsStatus = useSelector(selectLoadingStatus); const topicsStatus = useSelector(selectLoadingStatus);
const topicsInProgress = topicsStatus === RequestStatus.IN_PROGRESS; const postsIds = useSelector(selectTopicThreadsIds([topicId]));
const posts = useSelector(selectTopicThreads([topicId]));
const selectedSubsectionUnits = useSelector(selectSubsectionUnits(category)); const selectedSubsectionUnits = useSelector(selectSubsectionUnits(category));
const selectedSubsection = useSelector(selectSubsection(category)); const selectedSubsection = useSelector(selectSubsection(category));
const selectedUnit = useSelector(selectUnits)?.find(unit => unit.id === topicId); const units = useSelector(selectUnits);
const selectedNonCoursewareTopic = useSelector(selectNonCoursewareTopics)?.find(topic => topic.id === topicId); const nonCoursewareTopics = useSelector(selectNonCoursewareTopics);
const selectedArchivedTopic = useSelector(selectArchivedTopic(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(() => { useEffect(() => {
if (provider && topicsStatus === RequestStatus.IDLE) { if (provider && topicsStatus === RequestStatus.IDLE) {
@@ -41,12 +58,6 @@ function TopicPostsView({ intl }) {
} }
}, [provider]); }, [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 ( return (
<div className="discussion-posts d-flex flex-column h-100"> <div className="discussion-posts d-flex flex-column h-100">
{topicId ? ( {topicId ? (
@@ -67,8 +78,8 @@ function TopicPostsView({ intl }) {
<div className="list-group list-group-flush flex-fill" role="list" onKeyDown={e => handleKeyDown(e)}> <div className="list-group list-group-flush flex-fill" role="list" onKeyDown={e => handleKeyDown(e)}>
{topicId ? ( {topicId ? (
<PostsList <PostsList
posts={posts} postsIds={postsIds}
topics={[topicId]} topicsIds={[topicId]}
parentIsLoading={topicsInProgress} parentIsLoading={topicsInProgress}
/> />
) : ( ) : (
@@ -90,10 +101,6 @@ function TopicPostsView({ intl }) {
</div> </div>
</div> </div>
); );
}
TopicPostsView.propTypes = {
intl: intlShape.isRequired,
}; };
export default injectIntl(TopicPostsView); export default React.memo(TopicPostsView);

View File

@@ -1,4 +1,6 @@
import React, { useContext, useEffect } from 'react'; import React, {
useCallback, useContext, useEffect, useMemo,
} from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import isEmpty from 'lodash/isEmpty'; import isEmpty from 'lodash/isEmpty';
@@ -21,30 +23,38 @@ import { setFilter } from './data/slices';
import { fetchCourseTopicsV3 } from './data/thunks'; import { fetchCourseTopicsV3 } from './data/thunks';
import { ArchivedBaseGroup, SectionBaseGroup, Topic } from './topic'; import { ArchivedBaseGroup, SectionBaseGroup, Topic } from './topic';
function TopicsList() { const TopicsList = () => {
const loadingStatus = useSelector(selectLoadingStatus); const loadingStatus = useSelector(selectLoadingStatus);
const coursewareTopics = useSelector(selectCoursewareTopics); const coursewareTopics = useSelector(selectCoursewareTopics);
const nonCoursewareTopics = useSelector(selectNonCoursewareTopics); const nonCoursewareTopics = useSelector(selectNonCoursewareTopics);
const archivedTopics = useSelector(selectArchivedTopics); const archivedTopics = useSelector(selectArchivedTopics);
const renderNonCoursewareTopics = useMemo(() => (
nonCoursewareTopics?.map((topic, index) => (
<Topic
key={topic.id}
topic={topic}
showDivider={(nonCoursewareTopics.length - 1) !== index}
/>
))
), [nonCoursewareTopics]);
const renderCoursewareTopics = useMemo(() => (
coursewareTopics?.map((topic, index) => (
<SectionBaseGroup
key={topic.id}
section={topic?.children}
sectionId={topic.id}
sectionTitle={topic.displayName}
showDivider={(coursewareTopics.length - 1) !== index}
/>
))
), [coursewareTopics]);
return ( return (
<> <>
{nonCoursewareTopics?.map((topic, index) => ( {renderNonCoursewareTopics}
<Topic {renderCoursewareTopics}
key={topic.id}
topic={topic}
showDivider={(nonCoursewareTopics.length - 1) !== index}
/>
))}
{coursewareTopics?.map((topic, index) => (
<SectionBaseGroup
key={topic.id}
section={topic?.children}
sectionId={topic.id}
sectionTitle={topic.displayName}
showDivider={(coursewareTopics.length - 1) !== index}
/>
))}
{!isEmpty(archivedTopics) && ( {!isEmpty(archivedTopics) && (
<ArchivedBaseGroup <ArchivedBaseGroup
archivedTopics={archivedTopics} archivedTopics={archivedTopics}
@@ -58,9 +68,9 @@ function TopicsList() {
)} )}
</> </>
); );
} };
function TopicsView() { const TopicsView = () => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const { courseId } = useContext(DiscussionContext); const { courseId } = useContext(DiscussionContext);
const provider = useSelector(selectDiscussionProvider); const provider = useSelector(selectDiscussionProvider);
@@ -83,6 +93,10 @@ function TopicsView() {
} }
}, [isPostsFiltered]); }, [isPostsFiltered]);
const handleOnClear = useCallback(() => {
dispatch(setFilter(''));
}, []);
return ( return (
<div className="d-flex flex-column h-100" data-testid="inContext-topics-view"> <div className="d-flex flex-column h-100" data-testid="inContext-topics-view">
{topicFilter && ( {topicFilter && (
@@ -91,7 +105,7 @@ function TopicsView() {
text={topicFilter} text={topicFilter}
count={filteredTopics.length} count={filteredTopics.length}
loadingStatus={loadingStatus} loadingStatus={loadingStatus}
onClear={() => dispatch(setFilter(''))} onClear={handleOnClear}
/> />
{filteredTopics.length === 0 && loadingStatus === RequestStatus.SUCCESSFUL && <NoResults />} {filteredTopics.length === 0 && loadingStatus === RequestStatus.SUCCESSFUL && <NoResults />}
</> </>
@@ -116,6 +130,6 @@ function TopicsView() {
</div> </div>
</div> </div>
); );
} };
export default TopicsView; export default TopicsView;

View File

@@ -1,9 +1,9 @@
import React, { useContext } from 'react'; import React, { useCallback, useContext } from 'react';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { useRouteMatch } from 'react-router'; import { useRouteMatch } from 'react-router';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { ALL_ROUTES } from '../../../data/constants'; import { ALL_ROUTES } from '../../../data/constants';
import { DiscussionContext } from '../../common/context'; import { DiscussionContext } from '../../common/context';
@@ -14,20 +14,20 @@ import messages from '../../messages';
import { messages as postMessages, showPostEditor } from '../../posts'; import { messages as postMessages, showPostEditor } from '../../posts';
import { selectCourseWareThreadsCount, selectTotalTopicsThreadsCount } from '../data/selectors'; import { selectCourseWareThreadsCount, selectTotalTopicsThreadsCount } from '../data/selectors';
function EmptyTopics({ intl }) { const EmptyTopics = () => {
const intl = useIntl();
const match = useRouteMatch(ALL_ROUTES); const match = useRouteMatch(ALL_ROUTES);
const dispatch = useDispatch(); const dispatch = useDispatch();
const isOnDesktop = useIsOnDesktop();
const { enableInContextSidebar } = useContext(DiscussionContext); const { enableInContextSidebar } = useContext(DiscussionContext);
const courseWareThreadsCount = useSelector(selectCourseWareThreadsCount(match.params.category)); const courseWareThreadsCount = useSelector(selectCourseWareThreadsCount(match.params.category));
const topicThreadsCount = useSelector(selectPostThreadCount); const topicThreadsCount = useSelector(selectPostThreadCount);
// hasGlobalThreads is used to determine if there are any post available in courseware and non-courseware topics // hasGlobalThreads is used to determine if there are any post available in courseware and non-courseware topics
const hasGlobalThreads = useSelector(selectTotalTopicsThreadsCount) > 0; const hasGlobalThreads = useSelector(selectTotalTopicsThreadsCount) > 0;
function addPost() { const addPost = useCallback(() => (
return dispatch(showPostEditor()); dispatch(showPostEditor())
} ), []);
const isOnDesktop = useIsOnDesktop();
let title = messages.emptyTitle; let title = messages.emptyTitle;
let fullWidth = false; let fullWidth = false;
@@ -74,10 +74,6 @@ function EmptyTopics({ intl }) {
fullWidth={fullWidth} fullWidth={fullWidth}
/> />
); );
}
EmptyTopics.propTypes = {
intl: intlShape.isRequired,
}; };
export default injectIntl(EmptyTopics); export default EmptyTopics;

View File

@@ -1,11 +1,14 @@
import React from 'react';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { selectTopics } from '../data/selectors'; import { selectTopics } from '../data/selectors';
import messages from '../messages'; import messages from '../messages';
function NoResults({ intl }) { const NoResults = () => {
const intl = useIntl();
const topics = useSelector(selectTopics); const topics = useSelector(selectTopics);
const title = messages.nothingHere; const title = messages.nothingHere;
@@ -20,10 +23,6 @@ function NoResults({ intl }) {
{ helpMessage && <small className="font-weight-normal text-gray-700">{intl.formatMessage(helpMessage)}</small>} { helpMessage && <small className="font-weight-normal text-gray-700">{intl.formatMessage(helpMessage)}</small>}
</div> </div>
); );
}
NoResults.propTypes = {
intl: intlShape.isRequired,
}; };
export default injectIntl(NoResults); export default NoResults;

View File

@@ -1,8 +1,8 @@
import React, { useContext, useEffect } from 'react'; import React, { useCallback, useContext, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { Icon, SearchField } from '@edx/paragon'; import { Icon, SearchField } from '@edx/paragon';
import { Search as SearchIcon } from '@edx/paragon/icons'; import { Search as SearchIcon } from '@edx/paragon/icons';
@@ -10,56 +10,51 @@ import { DiscussionContext } from '../../common/context';
import postsMessages from '../../posts/post-actions-bar/messages'; import postsMessages from '../../posts/post-actions-bar/messages';
import { setFilter as setTopicFilter } from '../data/slices'; import { setFilter as setTopicFilter } from '../data/slices';
function TopicSearchBar({ intl }) { const TopicSearchBar = () => {
const intl = useIntl();
const dispatch = useDispatch(); const dispatch = useDispatch();
const { page } = useContext(DiscussionContext); const { page } = useContext(DiscussionContext);
const topicSearch = useSelector(({ inContextTopics }) => inContextTopics.filter); const topicSearch = useSelector(({ inContextTopics }) => inContextTopics.filter);
let searchValue = ''; let searchValue = '';
const onClear = () => { const onClear = useCallback(() => {
dispatch(setTopicFilter('')); dispatch(setTopicFilter(''));
}; }, []);
const onChange = (query) => { const onChange = useCallback((query) => {
searchValue = query; searchValue = query;
}; }, []);
const onSubmit = (query) => { const onSubmit = useCallback((query) => {
if (query === '') { if (query === '') {
return; return;
} }
dispatch(setTopicFilter(query)); dispatch(setTopicFilter(query));
}; }, []);
useEffect(() => onClear(), [page]); useEffect(() => onClear(), [page]);
return ( return (
<> <SearchField.Advanced
<SearchField.Advanced onClear={onClear}
onClear={onClear} onChange={onChange}
onChange={onChange} onSubmit={onSubmit}
onSubmit={onSubmit} value={topicSearch}
value={topicSearch} >
> <SearchField.Label />
<SearchField.Label /> <SearchField.Input
<SearchField.Input style={{ paddingRight: '1rem' }}
style={{ paddingRight: '1rem' }} placeholder={intl.formatMessage(postsMessages.search, { page: 'topics' })}
placeholder={intl.formatMessage(postsMessages.search, { page: 'topics' })} />
<span className="mt-auto mb-auto mr-2.5 pointer-cursor-hover">
<Icon
src={SearchIcon}
onClick={() => onSubmit(searchValue)}
data-testid="search-icon"
/> />
<span className="mt-auto mb-auto mr-2.5 pointer-cursor-hover"> </span>
<Icon </SearchField.Advanced>
src={SearchIcon}
onClick={() => onSubmit(searchValue)}
data-testid="search-icon"
/>
</span>
</SearchField.Advanced>
</>
); );
}
TopicSearchBar.propTypes = {
intl: intlShape.isRequired,
}; };
export default injectIntl(TopicSearchBar); export default TopicSearchBar;

View File

@@ -1,16 +1,27 @@
import React from 'react'; import React, { useMemo } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import messages from '../messages'; import messages from '../messages';
import Topic, { topicShape } from './Topic'; import Topic, { topicShape } from './Topic';
function ArchivedBaseGroup({ const ArchivedBaseGroup = ({
archivedTopics, archivedTopics,
showDivider, showDivider,
intl, }) => {
}) { const intl = useIntl();
const renderArchivedTopics = useMemo(() => (
archivedTopics?.map((topic, index) => (
<Topic
key={topic.id}
topic={topic}
showDivider={(archivedTopics.length - 1) !== index}
/>
))
), [archivedTopics]);
return ( return (
<> <>
{showDivider && ( {showDivider && (
@@ -24,25 +35,18 @@ function ArchivedBaseGroup({
data-testid="archived-group" data-testid="archived-group"
> >
<div className="pt-3 px-4 font-weight-bold">{intl.formatMessage(messages.archivedTopics)}</div> <div className="pt-3 px-4 font-weight-bold">{intl.formatMessage(messages.archivedTopics)}</div>
{archivedTopics?.map((topic, index) => ( {renderArchivedTopics}
<Topic
key={topic.id}
topic={topic}
showDivider={(archivedTopics.length - 1) !== index}
/>
))}
</div> </div>
</> </>
); );
} };
ArchivedBaseGroup.propTypes = { ArchivedBaseGroup.propTypes = {
archivedTopics: PropTypes.arrayOf(topicShape).isRequired, archivedTopics: PropTypes.arrayOf(topicShape).isRequired,
showDivider: PropTypes.bool, showDivider: PropTypes.bool,
intl: intlShape.isRequired,
}; };
ArchivedBaseGroup.defaultProps = { ArchivedBaseGroup.defaultProps = {
showDivider: false, showDivider: false,
}; };
export default injectIntl(ArchivedBaseGroup); export default React.memo(ArchivedBaseGroup);

View File

@@ -1,11 +1,11 @@
import React from 'react'; import React, { useCallback, useMemo } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import classNames from 'classnames'; import classNames from 'classnames';
import { useParams } from 'react-router'; import { useParams } from 'react-router';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import TopicStats from '../../../components/TopicStats'; import TopicStats from '../../../components/TopicStats';
import { Routes } from '../../../data/constants'; import { Routes } from '../../../data/constants';
@@ -13,19 +13,52 @@ import { discussionsPath } from '../../utils';
import messages from '../messages'; import messages from '../messages';
import { topicShape } from './Topic'; import { topicShape } from './Topic';
function SectionBaseGroup({ const SectionBaseGroup = ({
section, section,
sectionTitle, sectionTitle,
sectionId, sectionId,
showDivider, showDivider,
intl, }) => {
}) { const intl = useIntl();
const { courseId } = useParams(); const { courseId } = useParams();
const isSelected = (id) => window.location.pathname.includes(id);
const sectionUrl = (id) => discussionsPath(Routes.TOPICS.CATEGORY, { const isSelected = useCallback((id) => (
window.location.pathname.includes(id)
), []);
const sectionUrl = useCallback((id) => discussionsPath(Routes.TOPICS.CATEGORY, {
courseId, courseId,
category: id, category: id,
}); }), [courseId]);
const renderSection = useMemo(() => (
section?.map((subsection, index) => (
<Link
className={classNames('subsection p-0 text-decoration-none text-primary-500', {
'border-bottom border-light-400': (section.length - 1 !== index),
})}
key={subsection.id}
role="option"
data-subsection-id={subsection.id}
data-testid="subsection-group"
to={sectionUrl(subsection.id)}
onClick={() => isSelected(subsection.id)}
aria-current={isSelected(section.id) ? 'page' : undefined}
tabIndex={(isSelected(subsection.id) || index === 0) ? 0 : -1}
>
<div className="d-flex flex-row pt-2.5 pb-2 px-4">
<div className="d-flex flex-column flex-fill" style={{ minWidth: 0 }}>
<div className="d-flex flex-column justify-content-start mw-100 flex-fill">
<div className="topic-name text-truncate">
{subsection?.displayName || intl.formatMessage(messages.unnamedSubsection)}
</div>
<TopicStats threadCounts={subsection?.threadCounts} />
</div>
</div>
</div>
</Link>
))
), [section, sectionUrl, isSelected]);
return ( return (
<div <div
@@ -36,32 +69,7 @@ function SectionBaseGroup({
<div className="pt-3 px-4 font-weight-bold"> <div className="pt-3 px-4 font-weight-bold">
{sectionTitle || intl.formatMessage(messages.unnamedSection)} {sectionTitle || intl.formatMessage(messages.unnamedSection)}
</div> </div>
{section.map((subsection, index) => ( {renderSection}
<Link
className={classNames('subsection p-0 text-decoration-none text-primary-500', {
'border-bottom border-light-400': (section.length - 1 !== index),
})}
key={subsection.id}
role="option"
data-subsection-id={subsection.id}
data-testid="subsection-group"
to={sectionUrl(subsection.id)}
onClick={() => isSelected(subsection.id)}
aria-current={isSelected(section.id) ? 'page' : undefined}
tabIndex={(isSelected(subsection.id) || index === 0) ? 0 : -1}
>
<div className="d-flex flex-row pt-2.5 pb-2 px-4">
<div className="d-flex flex-column flex-fill" style={{ minWidth: 0 }}>
<div className="d-flex flex-column justify-content-start mw-100 flex-fill">
<div className="topic-name text-truncate">
{subsection?.displayName || intl.formatMessage(messages.unnamedSubsection)}
</div>
<TopicStats threadCounts={subsection?.threadCounts} />
</div>
</div>
</div>
</Link>
))}
{showDivider && ( {showDivider && (
<> <>
<div className="divider border-top border-light-500" /> <div className="divider border-top border-light-500" />
@@ -70,7 +78,7 @@ function SectionBaseGroup({
)} )}
</div> </div>
); );
} };
SectionBaseGroup.propTypes = { SectionBaseGroup.propTypes = {
section: PropTypes.arrayOf(PropTypes.shape({ section: PropTypes.arrayOf(PropTypes.shape({
@@ -86,7 +94,6 @@ SectionBaseGroup.propTypes = {
sectionTitle: PropTypes.string.isRequired, sectionTitle: PropTypes.string.isRequired,
sectionId: PropTypes.string.isRequired, sectionId: PropTypes.string.isRequired,
showDivider: PropTypes.bool.isRequired, showDivider: PropTypes.bool.isRequired,
intl: intlShape.isRequired,
}; };
export default injectIntl(SectionBaseGroup); export default React.memo(SectionBaseGroup);

View File

@@ -7,7 +7,7 @@ import { useSelector } from 'react-redux';
import { useParams } from 'react-router'; import { useParams } from 'react-router';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { Icon, OverlayTrigger, Tooltip } from '@edx/paragon'; import { Icon, OverlayTrigger, Tooltip } from '@edx/paragon';
import { HelpOutline, PostOutline, Report } from '@edx/paragon/icons'; import { HelpOutline, PostOutline, Report } from '@edx/paragon/icons';
@@ -17,12 +17,12 @@ import { selectUserHasModerationPrivileges, selectUserIsGroupTa } from '../../da
import { discussionsPath } from '../../utils'; import { discussionsPath } from '../../utils';
import messages from '../messages'; import messages from '../messages';
function Topic({ const Topic = ({
topic, topic,
showDivider, showDivider,
index, index,
intl, }) => {
}) { const intl = useIntl();
const { courseId } = useParams(); const { courseId } = useParams();
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges); const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
const userIsGroupTa = useSelector(selectUserIsGroupTa); const userIsGroupTa = useSelector(selectUserIsGroupTa);
@@ -70,7 +70,7 @@ function Topic({
)} )}
</> </>
); );
} };
export const topicShape = PropTypes.shape({ export const topicShape = PropTypes.shape({
id: PropTypes.string, id: PropTypes.string,
@@ -85,7 +85,6 @@ export const topicShape = PropTypes.shape({
}); });
Topic.propTypes = { Topic.propTypes = {
intl: intlShape.isRequired,
topic: topicShape, topic: topicShape,
showDivider: PropTypes.bool, showDivider: PropTypes.bool,
index: PropTypes.number, index: PropTypes.number,
@@ -99,4 +98,4 @@ Topic.defaultProps = {
}, },
}; };
export default injectIntl(Topic); export default React.memo(Topic);

View File

@@ -6,7 +6,7 @@ import capitalize from 'lodash/capitalize';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { useHistory, useLocation } from 'react-router-dom'; import { useHistory, useLocation } from 'react-router-dom';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { import {
Button, Icon, IconButton, Spinner, Button, Icon, IconButton, Spinner,
} from '@edx/paragon'; } from '@edx/paragon';
@@ -18,33 +18,36 @@ import {
} from '../../data/constants'; } from '../../data/constants';
import { DiscussionContext } from '../common/context'; import { DiscussionContext } from '../common/context';
import { selectUserHasModerationPrivileges, selectUserIsStaff } from '../data/selectors'; import { selectUserHasModerationPrivileges, selectUserIsStaff } from '../data/selectors';
import { usePostList } from '../posts/data/hooks';
import { import {
selectAllThreads, selectAllThreadsIds,
selectThreadNextPage, selectThreadNextPage,
threadsLoadingStatus, threadsLoadingStatus,
} from '../posts/data/selectors'; } from '../posts/data/selectors';
import { clearPostsPages } from '../posts/data/slices'; import { clearPostsPages } from '../posts/data/slices';
import NoResults from '../posts/NoResults'; import NoResults from '../posts/NoResults';
import { PostLink } from '../posts/post'; import { PostLink } from '../posts/post';
import { discussionsPath, filterPosts } from '../utils'; import { discussionsPath } from '../utils';
import { fetchUserPosts } from './data/thunks'; import { fetchUserPosts } from './data/thunks';
import LearnerPostFilterBar from './learner-post-filter-bar/LearnerPostFilterBar'; import LearnerPostFilterBar from './learner-post-filter-bar/LearnerPostFilterBar';
import messages from './messages'; import messages from './messages';
function LearnerPostsView({ intl }) { const LearnerPostsView = () => {
const intl = useIntl();
const location = useLocation(); const location = useLocation();
const history = useHistory(); const history = useHistory();
const dispatch = useDispatch(); const dispatch = useDispatch();
const posts = useSelector(selectAllThreads); const postsIds = useSelector(selectAllThreadsIds);
const loadingStatus = useSelector(threadsLoadingStatus()); const loadingStatus = useSelector(threadsLoadingStatus());
const postFilter = useSelector(state => state.learners.postFilter); const postFilter = useSelector(state => state.learners.postFilter);
const { courseId, learnerUsername: username } = useContext(DiscussionContext); const { courseId, learnerUsername: username } = useContext(DiscussionContext);
const nextPage = useSelector(selectThreadNextPage()); const nextPage = useSelector(selectThreadNextPage());
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges); const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
const userIsStaff = useSelector(selectUserIsStaff); const userIsStaff = useSelector(selectUserIsStaff);
const sortedPostsIds = usePostList(postsIds);
const loadMorePosts = (pageNum = undefined) => { const loadMorePosts = useCallback((pageNum = undefined) => {
const params = { const params = {
author: username, author: username,
page: pageNum, page: pageNum,
@@ -54,29 +57,24 @@ function LearnerPostsView({ intl }) {
}; };
dispatch(fetchUserPosts(courseId, params)); dispatch(fetchUserPosts(courseId, params));
}; }, [courseId, postFilter, username, userHasModerationPrivileges, userIsStaff]);
const postInstances = useMemo(() => (
sortedPostsIds?.map((postId, idx) => (
<PostLink
postId={postId}
idx={idx}
key={postId}
showDivider={(sortedPostsIds.length - 1) !== idx}
/>
))
), [sortedPostsIds]);
useEffect(() => { useEffect(() => {
dispatch(clearPostsPages()); dispatch(clearPostsPages());
loadMorePosts(); loadMorePosts();
}, [courseId, postFilter, username]); }, [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) => (
<PostLink
post={post}
key={post.id}
isSelected={checkIsSelected}
idx={idx}
showDivider={(sortedPosts.length - 1) !== idx}
/>
))
), []);
return ( return (
<div className="discussion-posts d-flex flex-column"> <div className="discussion-posts d-flex flex-column">
<div className="d-flex align-items-center justify-content-between px-2.5"> <div className="d-flex align-items-center justify-content-between px-2.5">
@@ -97,9 +95,8 @@ function LearnerPostsView({ intl }) {
<LearnerPostFilterBar /> <LearnerPostFilterBar />
<div className="border-bottom border-light-400" /> <div className="border-bottom border-light-400" />
<div className="list-group list-group-flush"> <div className="list-group list-group-flush">
{postInstances(pinnedPosts)} {postInstances}
{postInstances(unpinnedPosts)} {loadingStatus !== RequestStatus.IN_PROGRESS && sortedPostsIds?.length === 0 && <NoResults />}
{loadingStatus !== RequestStatus.IN_PROGRESS && posts?.length === 0 && <NoResults />}
{loadingStatus === RequestStatus.IN_PROGRESS ? ( {loadingStatus === RequestStatus.IN_PROGRESS ? (
<div className="d-flex justify-content-center p-4"> <div className="d-flex justify-content-center p-4">
<Spinner animation="border" variant="primary" size="lg" /> <Spinner animation="border" variant="primary" size="lg" />
@@ -114,10 +111,6 @@ function LearnerPostsView({ intl }) {
</div> </div>
</div> </div>
); );
}
LearnerPostsView.propTypes = {
intl: intlShape.isRequired,
}; };
export default injectIntl(LearnerPostsView); export default LearnerPostsView;

View File

@@ -1,11 +1,11 @@
import React, { useEffect } from 'react'; import React, { useCallback, useEffect, useMemo } from 'react';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { import {
Redirect, useLocation, useParams, Redirect, useLocation, useParams,
} from 'react-router'; } from 'react-router';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { Button, Spinner } from '@edx/paragon'; import { Button, Spinner } from '@edx/paragon';
import SearchInfo from '../../components/SearchInfo'; import SearchInfo from '../../components/SearchInfo';
@@ -24,7 +24,8 @@ import { fetchLearners } from './data/thunks';
import { LearnerCard, LearnerFilterBar } from './learner'; import { LearnerCard, LearnerFilterBar } from './learner';
import messages from './messages'; import messages from './messages';
function LearnersView({ intl }) { const LearnersView = () => {
const intl = useIntl();
const { courseId } = useParams(); const { courseId } = useParams();
const location = useLocation(); const location = useLocation();
const dispatch = useDispatch(); const dispatch = useDispatch();
@@ -46,7 +47,7 @@ function LearnersView({ intl }) {
} }
}, [courseId, orderBy, learnersTabEnabled, usernameSearch]); }, [courseId, orderBy, learnersTabEnabled, usernameSearch]);
const loadPage = async () => { const loadPage = useCallback(async () => {
if (nextPage) { if (nextPage) {
dispatch(fetchLearners(courseId, { dispatch(fetchLearners(courseId, {
orderBy, orderBy,
@@ -54,7 +55,19 @@ function LearnersView({ intl }) {
usernameSearch, usernameSearch,
})); }));
} }
}; }, [courseId, orderBy, nextPage, usernameSearch]);
const handleOnClear = useCallback(() => {
dispatch(setUsernameSearch(''));
}, []);
const renderLearnersList = useMemo(() => (
(
courseConfigLoadingStatus === RequestStatus.SUCCESSFUL && learnersTabEnabled && learners.map((learner) => (
<LearnerCard learner={learner} key={learner.username} />
))
) || <></>
), [courseConfigLoadingStatus, learnersTabEnabled, learners]);
return ( return (
<div className="d-flex flex-column border-right border-light-400"> <div className="d-flex flex-column border-right border-light-400">
@@ -65,7 +78,7 @@ function LearnersView({ intl }) {
text={usernameSearch} text={usernameSearch}
count={learners.length} count={learners.length}
loadingStatus={loadingStatus} loadingStatus={loadingStatus}
onClear={() => dispatch(setUsernameSearch(''))} onClear={handleOnClear}
/> />
)} )}
<div className="list-group list-group-flush learner" role="list"> <div className="list-group list-group-flush learner" role="list">
@@ -77,12 +90,7 @@ function LearnersView({ intl }) {
}} }}
/> />
)} )}
{courseConfigLoadingStatus === RequestStatus.SUCCESSFUL {renderLearnersList}
&& learnersTabEnabled
&& learners.map((learner, index) => (
// eslint-disable-next-line react/no-array-index-key
<LearnerCard learner={learner} key={index} courseId={courseId} />
))}
{loadingStatus === RequestStatus.IN_PROGRESS ? ( {loadingStatus === RequestStatus.IN_PROGRESS ? (
<div className="d-flex justify-content-center p-4"> <div className="d-flex justify-content-center p-4">
<Spinner animation="border" variant="primary" size="lg" /> <Spinner animation="border" variant="primary" size="lg" />
@@ -98,10 +106,6 @@ function LearnersView({ intl }) {
</div> </div>
</div> </div>
); );
}
LearnersView.propTypes = {
intl: intlShape.isRequired,
}; };
export default injectIntl(LearnersView); export default LearnersView;

View File

@@ -1,26 +1,23 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import { Avatar } from '@edx/paragon'; import { Avatar } from '@edx/paragon';
import { learnerShape } from './proptypes'; const LearnerAvatar = ({ username }) => (
<div className="mr-3 mt-1">
function LearnerAvatar({ learner }) { <Avatar
return ( size="sm"
<div className="mr-3 mt-1"> alt={username}
<Avatar style={{
size="sm" height: '2rem',
alt={learner.username} width: '2rem',
style={{ }}
height: '2rem', />
width: '2rem', </div>
}} );
/>
</div>
);
}
LearnerAvatar.propTypes = { LearnerAvatar.propTypes = {
learner: learnerShape.isRequired, username: PropTypes.string.isRequired,
}; };
export default LearnerAvatar; export default React.memo(LearnerAvatar);

View File

@@ -1,10 +1,7 @@
import React, { useContext } from 'react'; import React, { useContext } from 'react';
import PropTypes from 'prop-types';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { injectIntl } from '@edx/frontend-platform/i18n';
import { Routes } from '../../../data/constants'; import { Routes } from '../../../data/constants';
import { DiscussionContext } from '../../common/context'; import { DiscussionContext } from '../../common/context';
import { discussionsPath } from '../../utils'; import { discussionsPath } from '../../utils';
@@ -12,11 +9,11 @@ import LearnerAvatar from './LearnerAvatar';
import LearnerFooter from './LearnerFooter'; import LearnerFooter from './LearnerFooter';
import { learnerShape } from './proptypes'; import { learnerShape } from './proptypes';
function LearnerCard({ const LearnerCard = ({ learner }) => {
learner, const {
courseId, username, threads, inactiveFlags, activeFlags, responses, replies,
}) { } = learner;
const { enableInContextSidebar, learnerUsername } = useContext(DiscussionContext); const { enableInContextSidebar, learnerUsername, courseId } = useContext(DiscussionContext);
const linkUrl = discussionsPath(Routes.LEARNERS.POSTS, { const linkUrl = discussionsPath(Routes.LEARNERS.POSTS, {
0: enableInContextSidebar ? 'in-context' : undefined, 0: enableInContextSidebar ? 'in-context' : undefined,
learnerUsername: learner.username, learnerUsername: learner.username,
@@ -30,32 +27,40 @@ function LearnerCard({
> >
<div <div
className="d-flex flex-row flex-fill mw-100 py-3 px-4 border-primary-500" className="d-flex flex-row flex-fill mw-100 py-3 px-4 border-primary-500"
style={learner.username === learnerUsername ? { style={username === learnerUsername ? {
borderRightWidth: '4px', borderRightWidth: '4px',
borderRightStyle: 'solid', borderRightStyle: 'solid',
} : null} } : null}
> >
<LearnerAvatar learner={learner} /> <LearnerAvatar username={username} />
<div className="d-flex flex-column flex-fill" style={{ minWidth: 0 }}> <div className="d-flex flex-column flex-fill" style={{ minWidth: 0 }}>
<div className="d-flex flex-column justify-content-start mw-100 flex-fill"> <div className="d-flex flex-column justify-content-start mw-100 flex-fill">
<div className="d-flex align-items-center flex-fill"> <div className="d-flex align-items-center flex-fill">
<div <div
className="text-truncate font-weight-500 font-size-14 text-primary-500 font-style" className="text-truncate font-weight-500 font-size-14 text-primary-500 font-style"
> >
{learner.username} {username}
</div> </div>
</div> </div>
{learner.threads === null ? null : <LearnerFooter learner={learner} /> } {threads !== null && (
<LearnerFooter
inactiveFlags={inactiveFlags}
activeFlags={activeFlags}
threads={threads}
responses={responses}
replies={replies}
username={username}
/>
)}
</div> </div>
</div> </div>
</div> </div>
</Link> </Link>
); );
} };
LearnerCard.propTypes = { LearnerCard.propTypes = {
learner: learnerShape.isRequired, learner: learnerShape.isRequired,
courseId: PropTypes.string.isRequired,
}; };
export default injectIntl(LearnerCard); export default React.memo(LearnerCard);

View File

@@ -1,11 +1,11 @@
import React, { useState } from 'react'; import React, { useCallback, useState } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import classNames from 'classnames'; import classNames from 'classnames';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { sendTrackEvent } from '@edx/frontend-platform/analytics'; import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { Collapsible, Form, Icon } from '@edx/paragon'; import { Collapsible, Form, Icon } from '@edx/paragon';
import { Check, Tune } from '@edx/paragon/icons'; import { Check, Tune } from '@edx/paragon/icons';
@@ -15,7 +15,7 @@ import { setSortedBy } from '../data';
import { selectLearnerSorting } from '../data/selectors'; import { selectLearnerSorting } from '../data/selectors';
import messages from '../messages'; import messages from '../messages';
const ActionItem = ({ const ActionItem = React.memo(({
id, id,
label, label,
value, value,
@@ -38,7 +38,7 @@ const ActionItem = ({
{label} {label}
</span> </span>
</label> </label>
); ));
ActionItem.propTypes = { ActionItem.propTypes = {
id: PropTypes.string.isRequired, id: PropTypes.string.isRequired,
@@ -47,16 +47,15 @@ ActionItem.propTypes = {
selected: PropTypes.string.isRequired, selected: PropTypes.string.isRequired,
}; };
function LearnerFilterBar({ const LearnerFilterBar = () => {
intl, const intl = useIntl();
}) {
const dispatch = useDispatch(); const dispatch = useDispatch();
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges); const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
const userIsGroupTa = useSelector(selectUserIsGroupTa); const userIsGroupTa = useSelector(selectUserIsGroupTa);
const currentSorting = useSelector(selectLearnerSorting()); const currentSorting = useSelector(selectLearnerSorting());
const [isOpen, setOpen] = useState(false); const [isOpen, setOpen] = useState(false);
const handleSortFilterChange = (event) => { const handleSortFilterChange = useCallback((event) => {
const { name, value } = event.currentTarget; const { name, value } = event.currentTarget;
if (name === 'sort') { if (name === 'sort') {
@@ -68,12 +67,16 @@ function LearnerFilterBar({
}, },
); );
} }
}; }, []);
const handleOnToggle = useCallback(() => {
setOpen(!isOpen);
}, [isOpen]);
return ( return (
<Collapsible.Advanced <Collapsible.Advanced
open={isOpen} open={isOpen}
onToggle={() => setOpen(!isOpen)} onToggle={handleOnToggle}
className="filter-bar collapsible-card-lg border-0" className="filter-bar collapsible-card-lg border-0"
> >
<Collapsible.Trigger className="collapsible-trigger border-0"> <Collapsible.Trigger className="collapsible-trigger border-0">
@@ -124,10 +127,6 @@ function LearnerFilterBar({
</Collapsible.Body> </Collapsible.Body>
</Collapsible.Advanced> </Collapsible.Advanced>
); );
}
LearnerFilterBar.propTypes = {
intl: intlShape.isRequired,
}; };
export default injectIntl(LearnerFilterBar); export default LearnerFilterBar;

View File

@@ -1,24 +1,22 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { Icon, OverlayTrigger, Tooltip } from '@edx/paragon'; import { Icon, OverlayTrigger, Tooltip } from '@edx/paragon';
import { Edit, Report, ReportGmailerrorred } from '@edx/paragon/icons'; import { Edit, Report, ReportGmailerrorred } from '@edx/paragon/icons';
import { QuestionAnswerOutline } from '../../../components/icons'; import { QuestionAnswerOutline } from '../../../components/icons';
import { selectUserHasModerationPrivileges, selectUserIsGroupTa } from '../../data/selectors'; import { selectUserHasModerationPrivileges, selectUserIsGroupTa } from '../../data/selectors';
import messages from '../messages'; import messages from '../messages';
import { learnerShape } from './proptypes';
function LearnerFooter({ const LearnerFooter = ({
learner, inactiveFlags, activeFlags, threads, responses, replies, username,
intl, }) => {
}) { const intl = useIntl();
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges); const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
const userIsGroupTa = useSelector(selectUserIsGroupTa); const userIsGroupTa = useSelector(selectUserIsGroupTa);
const { inactiveFlags } = learner;
const { activeFlags } = learner;
const canSeeLearnerReportedStats = (activeFlags || inactiveFlags) && (userHasModerationPrivileges || userIsGroupTa); const canSeeLearnerReportedStats = (activeFlags || inactiveFlags) && (userHasModerationPrivileges || userIsGroupTa);
return ( return (
@@ -35,7 +33,7 @@ function LearnerFooter({
> >
<div className="d-flex align-items-center"> <div className="d-flex align-items-center">
<Icon src={QuestionAnswerOutline} className="icon-size mr-2" /> <Icon src={QuestionAnswerOutline} className="icon-size mr-2" />
{learner.threads + learner.responses + learner.replies} {threads + responses + replies}
</div> </div>
</OverlayTrigger> </OverlayTrigger>
<OverlayTrigger <OverlayTrigger
@@ -50,14 +48,14 @@ function LearnerFooter({
> >
<div className="d-flex align-items-center"> <div className="d-flex align-items-center">
<Icon src={Edit} className="icon-size mr-2 ml-4" /> <Icon src={Edit} className="icon-size mr-2 ml-4" />
{learner.threads} {threads}
</div> </div>
</OverlayTrigger> </OverlayTrigger>
{Boolean(canSeeLearnerReportedStats) && ( {Boolean(canSeeLearnerReportedStats) && (
<OverlayTrigger <OverlayTrigger
placement="right" placement="right"
overlay={( overlay={(
<Tooltip id={`learner-${learner.username}`}> <Tooltip id={`learner-${username}`}>
<div className="d-flex flex-column align-items-start"> <div className="d-flex flex-column align-items-start">
{Boolean(activeFlags) {Boolean(activeFlags)
&& ( && (
@@ -83,11 +81,24 @@ function LearnerFooter({
)} )}
</div> </div>
); );
}
LearnerFooter.propTypes = {
intl: intlShape.isRequired,
learner: learnerShape.isRequired,
}; };
export default injectIntl(LearnerFooter); LearnerFooter.propTypes = {
inactiveFlags: PropTypes.number,
activeFlags: PropTypes.number,
threads: PropTypes.number,
responses: PropTypes.number,
replies: PropTypes.number,
username: PropTypes.string,
};
LearnerFooter.defaultProps = {
inactiveFlags: 0,
activeFlags: 0,
threads: 0,
responses: 0,
replies: 0,
username: '',
};
export default React.memo(LearnerFooter);

View File

@@ -3,22 +3,23 @@ import PropTypes from 'prop-types';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { Dropdown, DropdownButton } from '@edx/paragon'; import { Dropdown, DropdownButton } from '@edx/paragon';
import messages from './messages'; import messages from './messages';
function BreadcrumbDropdown({ const BreadcrumbDropdown = ({
currentItem, currentItem,
intl,
showAllPath, showAllPath,
items, items,
itemPathFunc, itemPathFunc,
itemLabelFunc, itemLabelFunc,
itemActiveFunc, itemActiveFunc,
itemFilterFunc, itemFilterFunc,
}) { }) => {
const intl = useIntl();
const showAllMsg = intl.formatMessage(messages.showAll); const showAllMsg = intl.formatMessage(messages.showAll);
return ( return (
<DropdownButton <DropdownButton
title={itemLabelFunc(currentItem) || showAllMsg} title={itemLabelFunc(currentItem) || showAllMsg}
@@ -46,12 +47,11 @@ function BreadcrumbDropdown({
))} ))}
</DropdownButton> </DropdownButton>
); );
} };
BreadcrumbDropdown.propTypes = { BreadcrumbDropdown.propTypes = {
// eslint-disable-next-line react/forbid-prop-types // eslint-disable-next-line react/forbid-prop-types
currentItem: PropTypes.any, currentItem: PropTypes.any,
intl: intlShape.isRequired,
showAllPath: PropTypes.func.isRequired, showAllPath: PropTypes.func.isRequired,
// eslint-disable-next-line react/forbid-prop-types // eslint-disable-next-line react/forbid-prop-types
items: PropTypes.array.isRequired, items: PropTypes.array.isRequired,
@@ -60,9 +60,10 @@ BreadcrumbDropdown.propTypes = {
itemActiveFunc: PropTypes.func.isRequired, itemActiveFunc: PropTypes.func.isRequired,
itemFilterFunc: PropTypes.func, itemFilterFunc: PropTypes.func,
}; };
BreadcrumbDropdown.defaultProps = { BreadcrumbDropdown.defaultProps = {
currentItem: null, currentItem: null,
itemFilterFunc: null, itemFilterFunc: null,
}; };
export default injectIntl(BreadcrumbDropdown); export default React.memo(BreadcrumbDropdown);

View File

@@ -1,4 +1,4 @@
import React from 'react'; import React, { useCallback } from 'react';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { useRouteMatch } from 'react-router'; import { useRouteMatch } from 'react-router';
@@ -13,7 +13,7 @@ import {
import { discussionsPath } from '../../utils'; import { discussionsPath } from '../../utils';
import BreadcrumbDropdown from './BreadcrumbDropdown'; import BreadcrumbDropdown from './BreadcrumbDropdown';
function LegacyBreadcrumbMenu() { const LegacyBreadcrumbMenu = () => {
const { const {
params: { params: {
courseId, courseId,
@@ -21,7 +21,6 @@ function LegacyBreadcrumbMenu() {
topicId: currentTopicId, topicId: currentTopicId,
}, },
} = useRouteMatch([Routes.TOPICS.CATEGORY, Routes.TOPICS.TOPIC]); } = useRouteMatch([Routes.TOPICS.CATEGORY, Routes.TOPICS.TOPIC]);
const currentTopic = useSelector(selectTopic(currentTopicId)); const currentTopic = useSelector(selectTopic(currentTopicId));
const currentCategory = category || currentTopic?.categoryId; const currentCategory = category || currentTopic?.categoryId;
const decodedCurrentCategory = String(currentCategory).replace('%23', '#'); const decodedCurrentCategory = String(currentCategory).replace('%23', '#');
@@ -30,31 +29,68 @@ function LegacyBreadcrumbMenu() {
const categories = useSelector(selectCategories); const categories = useSelector(selectCategories);
const isNonCoursewareTopic = currentTopic && !currentCategory; 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 ( return (
<div className="breadcrumb-menu d-flex flex-row bg-light-200 box-shadow-down-1 px-2.5 py-1"> <div className="breadcrumb-menu d-flex flex-row bg-light-200 box-shadow-down-1 px-2.5 py-1">
{isNonCoursewareTopic ? ( {isNonCoursewareTopic ? (
<BreadcrumbDropdown <BreadcrumbDropdown
currentItem={currentTopic} currentItem={currentTopic}
itemLabelFunc={(item) => item?.name} itemLabelFunc={nonCoursewareItemLabel}
itemActiveFunc={(topic) => topic?.id === currentTopicId} itemActiveFunc={nonCoursewareActive}
items={nonCoursewareTopics} items={nonCoursewareTopics}
showAllPath={discussionsPath(Routes.TOPICS.ALL, { courseId })} showAllPath={discussionsPath(Routes.TOPICS.ALL, { courseId })}
itemPathFunc={(topic) => discussionsPath(Routes.TOPICS.TOPIC, { itemPathFunc={nonCoursewareItemPath}
courseId,
topicId: topic.id,
})}
/> />
) : ( ) : (
<BreadcrumbDropdown <BreadcrumbDropdown
currentItem={decodedCurrentCategory} currentItem={decodedCurrentCategory}
itemLabelFunc={(catId) => catId} itemLabelFunc={coursewareItemLabel}
itemActiveFunc={(catId) => catId === currentCategory} itemActiveFunc={coursewareActive}
items={categories} items={categories}
showAllPath={discussionsPath(Routes.TOPICS.ALL, { courseId })} showAllPath={discussionsPath(Routes.TOPICS.ALL, { courseId })}
itemPathFunc={(catId) => discussionsPath(Routes.TOPICS.CATEGORY, { itemPathFunc={coursewareItemPath}
courseId,
category: catId,
})}
/> />
)} )}
{currentCategory && ( {currentCategory && (
@@ -62,24 +98,19 @@ function LegacyBreadcrumbMenu() {
<div className="d-flex py-2">/</div> <div className="d-flex py-2">/</div>
<BreadcrumbDropdown <BreadcrumbDropdown
currentItem={currentTopic} currentItem={currentTopic}
itemLabelFunc={(item) => item?.name} itemLabelFunc={categoryItemLabel}
itemActiveFunc={(topic) => topic?.id === currentTopicId} itemActiveFunc={categoryActive}
items={topicsInCategory} items={topicsInCategory}
showAllPath={discussionsPath(Routes.TOPICS.CATEGORY, { showAllPath={discussionsPath(Routes.TOPICS.CATEGORY, {
courseId, courseId,
category: currentCategory, category: currentCategory,
})} })}
itemPathFunc={(topic) => discussionsPath(Routes.TOPICS.TOPIC, { itemPathFunc={categoryItemPath}
courseId,
topicId: topic.id,
})}
/> />
</> </>
)} )}
</div> </div>
); );
} };
LegacyBreadcrumbMenu.propTypes = {};
export default LegacyBreadcrumbMenu; export default LegacyBreadcrumbMenu;

View File

@@ -1,21 +1,23 @@
import React from 'react'; import React, { useContext, useMemo } from 'react';
import { matchPath, useParams } from 'react-router'; import { matchPath } from 'react-router';
import { NavLink } from 'react-router-dom'; import { NavLink } from 'react-router-dom';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { Nav } from '@edx/paragon'; import { Nav } from '@edx/paragon';
import { Routes } from '../../../data/constants'; import { Routes } from '../../../data/constants';
import { DiscussionContext } from '../../common/context';
import { useShowLearnersTab } from '../../data/hooks'; import { useShowLearnersTab } from '../../data/hooks';
import { discussionsPath } from '../../utils'; import { discussionsPath } from '../../utils';
import messages from './messages'; import messages from './messages';
function NavigationBar({ intl }) { const NavigationBar = () => {
const { courseId } = useParams(); const intl = useIntl();
const { courseId } = useContext(DiscussionContext);
const showLearnersTab = useShowLearnersTab(); const showLearnersTab = useShowLearnersTab();
const navLinks = [ const navLinks = useMemo(() => ([
{ {
route: Routes.POSTS.MY_POSTS, route: Routes.POSTS.MY_POSTS,
labelMessage: messages.myPosts, labelMessage: messages.myPosts,
@@ -29,19 +31,23 @@ function NavigationBar({ intl }) {
isActive: (match, location) => Boolean(matchPath(location.pathname, { path: Routes.TOPICS.PATH })), isActive: (match, location) => Boolean(matchPath(location.pathname, { path: Routes.TOPICS.PATH })),
labelMessage: messages.allTopics, labelMessage: messages.allTopics,
}, },
]; ]), []);
if (showLearnersTab) {
navLinks.push({ useMemo(() => {
route: Routes.LEARNERS.PATH, if (showLearnersTab) {
labelMessage: messages.learners, navLinks.push({
}); route: Routes.LEARNERS.PATH,
} labelMessage: messages.learners,
});
}
}, [showLearnersTab]);
return ( return (
<Nav variant="button-group" className="py-2"> <Nav variant="pills" className="py-2 nav-button-group">
{navLinks.map(link => ( {navLinks.map(link => (
<Nav.Item key={link.route}> <Nav.Item key={link.route}>
<Nav.Link <Nav.Link
key={link.route}
as={NavLink} as={NavLink}
to={discussionsPath(link.route, { courseId })} to={discussionsPath(link.route, { courseId })}
isActive={link.isActive} isActive={link.isActive}
@@ -52,10 +58,6 @@ function NavigationBar({ intl }) {
))} ))}
</Nav> </Nav>
); );
}
NavigationBar.propTypes = {
intl: intlShape.isRequired,
}; };
export default injectIntl(NavigationBar); export default React.memo(NavigationBar);

View File

@@ -1,14 +1,14 @@
import React, { useContext, useEffect, useState } from 'react'; import React, {
Suspense, useCallback, useContext, useEffect, useState,
} from 'react';
import { useParams } from 'react-router';
import { useHistory, useLocation } from 'react-router-dom'; import { useHistory, useLocation } from 'react-router-dom';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { import { Button, Icon, IconButton } from '@edx/paragon';
Button, Icon, IconButton, Spinner,
} from '@edx/paragon';
import { ArrowBack } from '@edx/paragon/icons'; import { ArrowBack } from '@edx/paragon/icons';
import Spinner from '../../components/Spinner';
import { EndorsementStatus, PostsPages, ThreadType } from '../../data/constants'; import { EndorsementStatus, PostsPages, ThreadType } from '../../data/constants';
import { useDispatchWithState } from '../../data/hooks'; import { useDispatchWithState } from '../../data/hooks';
import { DiscussionContext } from '../common/context'; import { DiscussionContext } from '../common/context';
@@ -18,30 +18,45 @@ import { Post } from '../posts';
import { fetchThread } from '../posts/data/thunks'; import { fetchThread } from '../posts/data/thunks';
import { discussionsPath } from '../utils'; import { discussionsPath } from '../utils';
import { ResponseEditor } from './comments/comment'; import { ResponseEditor } from './comments/comment';
import CommentsSort from './comments/CommentsSort';
import CommentsView from './comments/CommentsView';
import { useCommentsCount, usePost } from './data/hooks'; import { useCommentsCount, usePost } from './data/hooks';
import messages from './messages'; import messages from './messages';
import { PostCommentsContext } from './postCommentsContext';
function PostCommentsView({ intl }) { const CommentsSort = React.lazy(() => import('./comments/CommentsSort'));
const [isLoading, submitDispatch] = useDispatchWithState(); const CommentsView = React.lazy(() => import('./comments/CommentsView'));
const { postId } = useParams();
const thread = usePost(postId); const PostCommentsView = () => {
const commentsCount = useCommentsCount(postId); const intl = useIntl();
const history = useHistory(); const history = useHistory();
const location = useLocation(); const location = useLocation();
const isOnDesktop = useIsOnDesktop(); const isOnDesktop = useIsOnDesktop();
const [addingResponse, setAddingResponse] = useState(false); const [addingResponse, setAddingResponse] = useState(false);
const [isLoading, submitDispatch] = useDispatchWithState();
const { const {
courseId, learnerUsername, category, topicId, page, enableInContextSidebar, courseId, learnerUsername, category, topicId, page, enableInContextSidebar, postId,
} = useContext(DiscussionContext); } = useContext(DiscussionContext);
const commentsCount = useCommentsCount(postId);
const { closed, id: threadId, type } = usePost(postId);
useEffect(() => { useEffect(() => {
if (!thread) { submitDispatch(fetchThread(postId, courseId, true)); } if (!postId) {
setAddingResponse(false); submitDispatch(fetchThread(postId, courseId, true));
}, [postId]); }
if (!thread) { return () => {
setAddingResponse(false);
};
}, [postId, courseId]);
const handleAddResponseButton = useCallback(() => {
setAddingResponse(true);
}, []);
const handleCloseEditor = useCallback(() => {
setAddingResponse(false);
}, []);
if (!threadId) {
if (!isLoading) { if (!isLoading) {
return ( return (
<EmptyPage title={intl.formatMessage(messages.noThreadFound)} /> <EmptyPage title={intl.formatMessage(messages.noThreadFound)} />
@@ -59,7 +74,12 @@ function PostCommentsView({ intl }) {
} }
return ( return (
<> <PostCommentsContext.Provider value={{
isClosed: closed,
postType: type,
postId,
}}
>
{!isOnDesktop && ( {!isOnDesktop && (
enableInContextSidebar ? ( enableInContextSidebar ? (
<> <>
@@ -95,49 +115,28 @@ function PostCommentsView({ intl }) {
<div <div
className="discussion-comments d-flex flex-column card border-0 post-card-margin post-card-padding on-focus" className="discussion-comments d-flex flex-column card border-0 post-card-margin post-card-padding on-focus"
> >
<Post post={thread} handleAddResponseButton={() => setAddingResponse(true)} /> <Post handleAddResponseButton={handleAddResponseButton} />
{!thread.closed && ( {!closed && (
<ResponseEditor <ResponseEditor
postId={postId} handleCloseEditor={handleCloseEditor}
handleCloseEditor={() => setAddingResponse(false)}
addingResponse={addingResponse} addingResponse={addingResponse}
/> />
)} )}
</div> </div>
{!!commentsCount && <CommentsSort />} <Suspense fallback={(<Spinner />)}>
{thread.type === ThreadType.DISCUSSION && ( {!!commentsCount && <CommentsSort />}
<CommentsView {type === ThreadType.DISCUSSION && (
postId={postId} <CommentsView endorsed={EndorsementStatus.DISCUSSION} />
intl={intl} )}
postType={thread.type} {type === ThreadType.QUESTION && (
endorsed={EndorsementStatus.DISCUSSION} <>
isClosed={thread.closed} <CommentsView endorsed={EndorsementStatus.ENDORSED} />
/> <CommentsView endorsed={EndorsementStatus.UNENDORSED} />
)} </>
{thread.type === ThreadType.QUESTION && ( )}
<> </Suspense>
<CommentsView </PostCommentsContext.Provider>
postId={postId}
intl={intl}
postType={thread.type}
endorsed={EndorsementStatus.ENDORSED}
isClosed={thread.closed}
/>
<CommentsView
postId={postId}
intl={intl}
postType={thread.type}
endorsed={EndorsementStatus.UNENDORSED}
isClosed={thread.closed}
/>
</>
)}
</>
); );
}
PostCommentsView.propTypes = {
intl: intlShape.isRequired,
}; };
export default injectIntl(PostCommentsView); export default PostCommentsView;

View File

@@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { import {
Button, Dropdown, ModalPopup, useToggle, Button, Dropdown, ModalPopup, useToggle,
} from '@edx/paragon'; } from '@edx/paragon';
@@ -13,15 +13,17 @@ import { selectCommentSortOrder } from '../data/selectors';
import { setCommentSortOrder } from '../data/slices'; import { setCommentSortOrder } from '../data/slices';
import messages from '../messages'; import messages from '../messages';
function CommentSortDropdown({ intl }) { const CommentSortDropdown = () => {
const intl = useIntl();
const dispatch = useDispatch(); const dispatch = useDispatch();
const sortedOrder = useSelector(selectCommentSortOrder); const sortedOrder = useSelector(selectCommentSortOrder);
const [isOpen, open, close] = useToggle(false); const [isOpen, open, close] = useToggle(false);
const [target, setTarget] = useState(null); const [target, setTarget] = useState(null);
const handleActions = (reverseOrder) => {
const handleActions = useCallback((reverseOrder) => {
close(); close();
dispatch(setCommentSortOrder(reverseOrder)); dispatch(setCommentSortOrder(reverseOrder));
}; }, []);
const enableCommentsSortTour = useCallback((enabled) => { const enableCommentsSortTour = useCallback((enabled) => {
const data = { const data = {
@@ -94,11 +96,6 @@ function CommentSortDropdown({ intl }) {
</div> </div>
</> </>
); );
}
CommentSortDropdown.propTypes = {
intl: intlShape.isRequired,
}; };
export default injectIntl(CommentSortDropdown); export default CommentSortDropdown;

View File

@@ -1,36 +1,39 @@
import React, { useMemo, useState } from 'react'; import React, { useCallback, useContext, useState } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { Button, Spinner } from '@edx/paragon'; import { Button, Spinner } from '@edx/paragon';
import { EndorsementStatus } from '../../../data/constants'; import { EndorsementStatus } from '../../../data/constants';
import { useUserCanAddThreadInBlackoutDate } from '../../data/hooks'; import { useUserCanAddThreadInBlackoutDate } from '../../data/hooks';
import { filterPosts, isLastElementOfList } from '../../utils'; import { isLastElementOfList } from '../../utils';
import { usePostComments } from '../data/hooks'; import { usePostComments } from '../data/hooks';
import messages from '../messages'; import messages from '../messages';
import { PostCommentsContext } from '../postCommentsContext';
import { Comment, ResponseEditor } from './comment'; import { Comment, ResponseEditor } from './comment';
function CommentsView({ const CommentsView = ({ endorsed }) => {
postType, const intl = useIntl();
postId, const [addingResponse, setAddingResponse] = useState(false);
intl, const { isClosed } = useContext(PostCommentsContext);
endorsed, const userCanAddThreadInBlackoutDate = useUserCanAddThreadInBlackoutDate();
isClosed,
}) {
const { const {
comments, endorsedCommentsIds,
unEndorsedCommentsIds,
hasMorePages, hasMorePages,
isLoading, isLoading,
handleLoadMoreResponses, handleLoadMoreResponses,
} = usePostComments(postId, endorsed); } = usePostComments(endorsed);
const endorsedComments = useMemo(() => [...filterPosts(comments, 'endorsed')], [comments]); const handleAddResponse = useCallback(() => {
const unEndorsedComments = useMemo(() => [...filterPosts(comments, 'unendorsed')], [comments]); setAddingResponse(true);
const userCanAddThreadInBlackoutDate = useUserCanAddThreadInBlackoutDate(); }, []);
const [addingResponse, setAddingResponse] = useState(false);
const handleDefinition = (message, commentsLength) => ( const handleCloseResponseEditor = useCallback(() => {
setAddingResponse(false);
}, []);
const handleDefinition = useCallback((message, commentsLength) => (
<div <div
className="mx-4 my-14px text-gray-700 font-style" className="mx-4 my-14px text-gray-700 font-style"
role="heading" role="heading"
@@ -38,17 +41,15 @@ function CommentsView({
> >
{intl.formatMessage(message, { num: commentsLength })} {intl.formatMessage(message, { num: commentsLength })}
</div> </div>
); ), []);
const handleComments = (postComments, showLoadMoreResponses = false) => ( const handleComments = useCallback((postCommentsIds, showLoadMoreResponses = false) => (
<div className="mx-4" role="list"> <div className="mx-4" role="list">
{postComments.map((comment) => ( {postCommentsIds.map((commentId) => (
<Comment <Comment
comment={comment} commentId={commentId}
key={comment.id} key={commentId}
postType={postType} marginBottom={isLastElementOfList(postCommentsIds, commentId)}
isClosedPost={isClosed}
marginBottom={isLastElementOfList(postComments, comment)}
/> />
))} ))}
{hasMorePages && !isLoading && !showLoadMoreResponses && ( {hasMorePages && !isLoading && !showLoadMoreResponses && (
@@ -68,26 +69,26 @@ function CommentsView({
</div> </div>
)} )}
</div> </div>
); ), [hasMorePages, isLoading, handleLoadMoreResponses]);
return ( return (
<> <>
{((hasMorePages && isLoading) || !isLoading) && ( {((hasMorePages && isLoading) || !isLoading) && (
<> <>
{endorsedComments.length > 0 && ( {endorsedCommentsIds.length > 0 && (
<> <>
{handleDefinition(messages.endorsedResponseCount, endorsedComments.length)} {handleDefinition(messages.endorsedResponseCount, endorsedCommentsIds.length)}
{endorsed === EndorsementStatus.DISCUSSION {endorsed === EndorsementStatus.DISCUSSION
? handleComments(endorsedComments, true) ? handleComments(endorsedCommentsIds, true)
: handleComments(endorsedComments, false)} : handleComments(endorsedCommentsIds, false)}
</> </>
)} )}
{endorsed !== EndorsementStatus.ENDORSED && ( {endorsed !== EndorsementStatus.ENDORSED && (
<> <>
{handleDefinition(messages.responseCount, unEndorsedComments.length)} {handleDefinition(messages.responseCount, unEndorsedCommentsIds.length)}
{unEndorsedComments.length === 0 && <br />} {unEndorsedCommentsIds.length === 0 && <br />}
{handleComments(unEndorsedComments, false)} {handleComments(unEndorsedCommentsIds, false)}
{(userCanAddThreadInBlackoutDate && !!unEndorsedComments.length && !isClosed) && ( {(userCanAddThreadInBlackoutDate && !!unEndorsedCommentsIds.length && !isClosed) && (
<div className="mx-4"> <div className="mx-4">
{!addingResponse && ( {!addingResponse && (
<Button <Button
@@ -95,17 +96,16 @@ function CommentsView({
block="true" block="true"
className="card mb-4 px-0 border-0 py-10px mt-2 font-style font-weight-500 className="card mb-4 px-0 border-0 py-10px mt-2 font-style font-weight-500
line-height-24 font-size-14 text-primary-500" line-height-24 font-size-14 text-primary-500"
onClick={() => setAddingResponse(true)} onClick={handleAddResponse}
data-testid="add-response" data-testid="add-response"
> >
{intl.formatMessage(messages.addResponse)} {intl.formatMessage(messages.addResponse)}
</Button> </Button>
)} )}
<ResponseEditor <ResponseEditor
postId={postId}
handleCloseEditor={() => setAddingResponse(false)}
addWrappingDiv addWrappingDiv
addingResponse={addingResponse} addingResponse={addingResponse}
handleCloseEditor={handleCloseResponseEditor}
/> />
</div> </div>
)} )}
@@ -115,16 +115,12 @@ function CommentsView({
)} )}
</> </>
); );
} };
CommentsView.propTypes = { CommentsView.propTypes = {
postId: PropTypes.string.isRequired,
postType: PropTypes.string.isRequired,
isClosed: PropTypes.bool.isRequired,
intl: intlShape.isRequired,
endorsed: PropTypes.oneOf([ endorsed: PropTypes.oneOf([
EndorsementStatus.ENDORSED, EndorsementStatus.UNENDORSED, EndorsementStatus.DISCUSSION, EndorsementStatus.ENDORSED, EndorsementStatus.UNENDORSED, EndorsementStatus.DISCUSSION,
]).isRequired, ]).isRequired,
}; };
export default injectIntl(CommentsView); export default React.memo(CommentsView);

View File

@@ -1,13 +1,12 @@
import React, { import React, {
useCallback, useCallback, useContext, useEffect, useMemo, useState,
useContext, useEffect, useMemo, useState,
} from 'react'; } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import classNames from 'classnames'; import classNames from 'classnames';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { Button, useToggle } from '@edx/paragon'; import { Button, useToggle } from '@edx/paragon';
import HTMLLoader from '../../../../components/HTMLLoader'; import HTMLLoader from '../../../../components/HTMLLoader';
@@ -15,6 +14,7 @@ import { ContentActions, EndorsementStatus } from '../../../../data/constants';
import { AlertBanner, Confirmation, EndorsedAlertBanner } from '../../../common'; import { AlertBanner, Confirmation, EndorsedAlertBanner } from '../../../common';
import { DiscussionContext } from '../../../common/context'; import { DiscussionContext } from '../../../common/context';
import HoverCard from '../../../common/HoverCard'; import HoverCard from '../../../common/HoverCard';
import { ContentTypes } from '../../../data/constants';
import { useUserCanAddThreadInBlackoutDate } from '../../../data/hooks'; import { useUserCanAddThreadInBlackoutDate } from '../../../data/hooks';
import { fetchThread } from '../../../posts/data/thunks'; import { fetchThread } from '../../../posts/data/thunks';
import LikeButton from '../../../posts/post/LikeButton'; import LikeButton from '../../../posts/post/LikeButton';
@@ -22,88 +22,123 @@ import { useActions } from '../../../utils';
import { import {
selectCommentCurrentPage, selectCommentCurrentPage,
selectCommentHasMorePages, selectCommentHasMorePages,
selectCommentOrResponseById,
selectCommentResponses, selectCommentResponses,
selectCommentResponsesIds,
selectCommentSortOrder, selectCommentSortOrder,
} from '../../data/selectors'; } from '../../data/selectors';
import { editComment, fetchCommentResponses, removeComment } from '../../data/thunks'; import { editComment, fetchCommentResponses, removeComment } from '../../data/thunks';
import messages from '../../messages'; import messages from '../../messages';
import { PostCommentsContext } from '../../postCommentsContext';
import CommentEditor from './CommentEditor'; import CommentEditor from './CommentEditor';
import CommentHeader from './CommentHeader'; import CommentHeader from './CommentHeader';
import { commentShape } from './proptypes';
import Reply from './Reply'; import Reply from './Reply';
function Comment({ const Comment = ({
postType, commentId,
comment,
showFullThread = true,
isClosedPost,
intl,
marginBottom, marginBottom,
}) { showFullThread = true,
}) => {
const comment = useSelector(selectCommentOrResponseById(commentId));
const {
id, parentId, childCount, abuseFlagged, endorsed, threadId, endorsedAt, endorsedBy, endorsedByLabel, renderedBody,
voted, following, voteCount, authorLabel, author, createdAt, lastEdit, rawBody, closed, closedBy, closeReason,
editByLabel, closedByLabel,
} = comment;
const intl = useIntl();
const hasChildren = childCount > 0;
const isNested = Boolean(parentId);
const dispatch = useDispatch(); const dispatch = useDispatch();
const hasChildren = comment.childCount > 0; const { courseId } = useContext(DiscussionContext);
const isNested = Boolean(comment.parentId); const { isClosed } = useContext(PostCommentsContext);
const inlineReplies = useSelector(selectCommentResponses(comment.id));
const [isEditing, setEditing] = useState(false); const [isEditing, setEditing] = useState(false);
const [isReplying, setReplying] = useState(false);
const [isDeleting, showDeleteConfirmation, hideDeleteConfirmation] = useToggle(false); const [isDeleting, showDeleteConfirmation, hideDeleteConfirmation] = useToggle(false);
const [isReporting, showReportConfirmation, hideReportConfirmation] = useToggle(false); const [isReporting, showReportConfirmation, hideReportConfirmation] = useToggle(false);
const [isReplying, setReplying] = useState(false); const inlineReplies = useSelector(selectCommentResponses(id));
const hasMorePages = useSelector(selectCommentHasMorePages(comment.id)); const inlineRepliesIds = useSelector(selectCommentResponsesIds(id));
const currentPage = useSelector(selectCommentCurrentPage(comment.id)); const hasMorePages = useSelector(selectCommentHasMorePages(id));
const userCanAddThreadInBlackoutDate = useUserCanAddThreadInBlackoutDate(); const currentPage = useSelector(selectCommentCurrentPage(id));
const { courseId } = useContext(DiscussionContext);
const sortedOrder = useSelector(selectCommentSortOrder); const sortedOrder = useSelector(selectCommentSortOrder);
const actions = useActions(ContentTypes.COMMENT, id);
const userCanAddThreadInBlackoutDate = useUserCanAddThreadInBlackoutDate();
useEffect(() => { useEffect(() => {
// If the comment has a parent comment, it won't have any children, so don't fetch them. // If the comment has a parent comment, it won't have any children, so don't fetch them.
if (hasChildren && showFullThread) { if (hasChildren && showFullThread) {
dispatch(fetchCommentResponses(comment.id, { dispatch(fetchCommentResponses(id, {
page: 1, page: 1,
reverseOrder: sortedOrder, reverseOrder: sortedOrder,
})); }));
} }
}, [comment.id, sortedOrder]); }, [id, sortedOrder]);
const actions = useActions({ const endorseIcons = useMemo(() => (
...comment, actions.find(({ action }) => action === EndorsementStatus.ENDORSED)
postType, ), [actions]);
});
const endorseIcons = actions.find(({ action }) => action === EndorsementStatus.ENDORSED); const handleEditContent = useCallback(() => {
setEditing(true);
}, []);
const handleCommentEndorse = useCallback(async () => {
await dispatch(editComment(id, { endorsed: !endorsed }, ContentActions.ENDORSE));
await dispatch(fetchThread(threadId, courseId));
}, [id, endorsed, threadId]);
const handleAbusedFlag = useCallback(() => { const handleAbusedFlag = useCallback(() => {
if (comment.abuseFlagged) { if (abuseFlagged) {
dispatch(editComment(comment.id, { flagged: !comment.abuseFlagged })); dispatch(editComment(id, { flagged: !abuseFlagged }));
} else { } else {
showReportConfirmation(); showReportConfirmation();
} }
}, [comment.abuseFlagged, comment.id, dispatch, showReportConfirmation]); }, [abuseFlagged, id, showReportConfirmation]);
const handleDeleteConfirmation = () => { const handleDeleteConfirmation = useCallback(() => {
dispatch(removeComment(comment.id)); dispatch(removeComment(id));
hideDeleteConfirmation(); hideDeleteConfirmation();
}; }, [id, hideDeleteConfirmation]);
const handleReportConfirmation = () => { const handleReportConfirmation = useCallback(() => {
dispatch(editComment(comment.id, { flagged: !comment.abuseFlagged })); dispatch(editComment(id, { flagged: !abuseFlagged }));
hideReportConfirmation(); hideReportConfirmation();
}; }, [abuseFlagged, id, hideReportConfirmation]);
const actionHandlers = useMemo(() => ({ const actionHandlers = useMemo(() => ({
[ContentActions.EDIT_CONTENT]: () => setEditing(true), [ContentActions.EDIT_CONTENT]: handleEditContent,
[ContentActions.ENDORSE]: async () => { [ContentActions.ENDORSE]: handleCommentEndorse,
await dispatch(editComment(comment.id, { endorsed: !comment.endorsed }, ContentActions.ENDORSE));
await dispatch(fetchThread(comment.threadId, courseId));
},
[ContentActions.DELETE]: showDeleteConfirmation, [ContentActions.DELETE]: showDeleteConfirmation,
[ContentActions.REPORT]: () => handleAbusedFlag(), [ContentActions.REPORT]: handleAbusedFlag,
}), [showDeleteConfirmation, dispatch, comment.id, comment.endorsed, comment.threadId, courseId, handleAbusedFlag]); }), [handleEditContent, handleCommentEndorse, showDeleteConfirmation, handleAbusedFlag]);
const handleLoadMoreComments = () => ( const handleLoadMoreComments = useCallback(() => (
dispatch(fetchCommentResponses(comment.id, { dispatch(fetchCommentResponses(id, {
page: currentPage + 1, page: currentPage + 1,
reverseOrder: sortedOrder, 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 ( return (
<div className={classNames({ 'mb-3': (showFullThread && !marginBottom) })}> <div className={classNames({ 'mb-3': (showFullThread && !marginBottom) })}>
@@ -111,7 +146,7 @@ function Comment({
<div <div
tabIndex="0" tabIndex="0"
className="d-flex flex-column card on-focus border-0" className="d-flex flex-column card on-focus border-0"
data-testid={`comment-${comment.id}`} data-testid={`comment-${id}`}
role="listitem" role="listitem"
> >
<Confirmation <Confirmation
@@ -123,7 +158,7 @@ function Comment({
closeButtonVaraint="tertiary" closeButtonVaraint="tertiary"
confirmButtonText={intl.formatMessage(messages.deleteConfirmationDelete)} confirmButtonText={intl.formatMessage(messages.deleteConfirmationDelete)}
/> />
{!comment.abuseFlagged && ( {!abuseFlagged && (
<Confirmation <Confirmation
isOpen={isReporting} isOpen={isReporting}
title={intl.formatMessage(messages.reportResponseTitle)} title={intl.formatMessage(messages.reportResponseTitle)}
@@ -133,49 +168,78 @@ function Comment({
confirmButtonVariant="danger" confirmButtonVariant="danger"
/> />
)} )}
<EndorsedAlertBanner postType={postType} content={comment} /> <EndorsedAlertBanner
endorsed={endorsed}
endorsedAt={endorsedAt}
endorsedBy={endorsedBy}
endorsedByLabel={endorsedByLabel}
/>
<div className="d-flex flex-column post-card-comment px-4 pt-3.5 pb-10px" tabIndex="0"> <div className="d-flex flex-column post-card-comment px-4 pt-3.5 pb-10px" tabIndex="0">
<HoverCard <HoverCard
commentOrPost={comment} id={id}
contentType={ContentTypes.COMMENT}
actionHandlers={actionHandlers} actionHandlers={actionHandlers}
handleResponseCommentButton={() => setReplying(true)} handleResponseCommentButton={handleAddCommentButton}
onLike={() => dispatch(editComment(comment.id, { voted: !comment.voted }))}
addResponseCommentButtonMessage={intl.formatMessage(messages.addComment)} addResponseCommentButtonMessage={intl.formatMessage(messages.addComment)}
isClosedPost={isClosedPost} onLike={handleCommentLike}
voted={voted}
following={following}
endorseIcons={endorseIcons} endorseIcons={endorseIcons}
/> />
<AlertBanner content={comment} /> <AlertBanner
<CommentHeader comment={comment} /> author={author}
{isEditing abuseFlagged={abuseFlagged}
? ( lastEdit={lastEdit}
<CommentEditor comment={comment} onCloseEditor={() => setEditing(false)} formClasses="pt-3" /> closed={closed}
) closedBy={closedBy}
: ( closeReason={closeReason}
<HTMLLoader editByLabel={editByLabel}
cssClassName="comment-body html-loader text-break mt-14px font-style text-primary-500" closedByLabel={closedByLabel}
componentId="comment" />
htmlNode={comment.renderedBody} <CommentHeader
testId={comment.id} author={author}
/> authorLabel={authorLabel}
)} abuseFlagged={abuseFlagged}
{comment.voted && ( closed={closed}
createdAt={createdAt}
lastEdit={lastEdit}
/>
{isEditing ? (
<CommentEditor
comment={{
author,
id,
lastEdit,
threadId,
parentId,
rawBody,
}}
onCloseEditor={handleCloseEditor}
formClasses="pt-3"
/>
) : (
<HTMLLoader
cssClassName="comment-body html-loader text-break mt-14px font-style text-primary-500"
componentId="comment"
htmlNode={renderedBody}
testId={id}
/>
)}
{voted && (
<div className="ml-n1.5 mt-10px"> <div className="ml-n1.5 mt-10px">
<LikeButton <LikeButton
count={comment.voteCount} count={voteCount}
onClick={() => dispatch(editComment(comment.id, { voted: !comment.voted }))} onClick={handleCommentLike}
voted={comment.voted} voted={voted}
/> />
</div> </div>
)} )}
{inlineReplies.length > 0 && ( {inlineRepliesIds.length > 0 && (
<div className="d-flex flex-column mt-0.5" role="list"> <div className="d-flex flex-column mt-0.5" role="list">
{/* Pass along intl since component used here is the one before it's injected with `injectIntl` */} {inlineRepliesIds.map(replyId => (
{inlineReplies.map(inlineReply => (
<Reply <Reply
reply={inlineReply} responseId={replyId}
postType={postType} key={replyId}
key={inlineReply.id}
intl={intl}
/> />
))} ))}
</div> </div>
@@ -195,46 +259,39 @@ function Comment({
isReplying ? ( isReplying ? (
<div className="mt-2.5"> <div className="mt-2.5">
<CommentEditor <CommentEditor
comment={{ threadId: comment.threadId, parentId: comment.id }} comment={{ threadId, parentId: id }}
edit={false} edit={false}
onCloseEditor={() => setReplying(false)} onCloseEditor={handleCloseReplyEditor}
/> />
</div> </div>
) : ( ) : (
<> !isClosed && userCanAddThreadInBlackoutDate && (inlineReplies.length >= 5) && (
{!isClosedPost && userCanAddThreadInBlackoutDate && (inlineReplies.length >= 5) <Button
&& ( className="d-flex flex-grow mt-2 font-size-14 font-style font-weight-500 text-primary-500"
<Button variant="plain"
className="d-flex flex-grow mt-2 font-size-14 font-style font-weight-500 text-primary-500" style={{ height: '36px' }}
variant="plain" onClick={handleAddCommentReply}
style={{ height: '36px' }} >
onClick={() => setReplying(true)} {intl.formatMessage(messages.addComment)}
> </Button>
{intl.formatMessage(messages.addComment)} )
</Button>
)}
</>
) )
)} )}
</div> </div>
</div> </div>
</div> </div>
); );
} };
Comment.propTypes = { Comment.propTypes = {
postType: PropTypes.oneOf(['discussion', 'question']).isRequired, commentId: PropTypes.string.isRequired,
comment: commentShape.isRequired,
showFullThread: PropTypes.bool,
isClosedPost: PropTypes.bool,
intl: intlShape.isRequired,
marginBottom: PropTypes.bool, marginBottom: PropTypes.bool,
showFullThread: PropTypes.bool,
}; };
Comment.defaultProps = { Comment.defaultProps = {
marginBottom: false,
showFullThread: true, showFullThread: true,
isClosedPost: false,
marginBottom: true,
}; };
export default injectIntl(Comment); export default React.memo(Comment);

View File

@@ -1,11 +1,11 @@
import React, { useContext, useRef } from 'react'; import React, { useCallback, useContext, useRef } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { Formik } from 'formik'; import { Formik } from 'formik';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import * as Yup from 'yup'; import * as Yup from 'yup';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { AppContext } from '@edx/frontend-platform/react'; import { AppContext } from '@edx/frontend-platform/react';
import { Button, Form, StatefulButton } from '@edx/paragon'; import { Button, Form, StatefulButton } from '@edx/paragon';
@@ -25,12 +25,15 @@ import { addComment, editComment } from '../../data/thunks';
import messages from '../../messages'; import messages from '../../messages';
function CommentEditor({ function CommentEditor({
intl,
comment, comment,
onCloseEditor,
edit, edit,
formClasses, formClasses,
onCloseEditor,
}) { }) {
const {
id, threadId, parentId, rawBody, author, lastEdit,
} = comment;
const intl = useIntl();
const editorRef = useRef(null); const editorRef = useRef(null);
const { authenticatedUser } = useContext(AppContext); const { authenticatedUser } = useContext(AppContext);
const { enableInContextSidebar } = useContext(DiscussionContext); const { enableInContextSidebar } = useContext(DiscussionContext);
@@ -42,7 +45,7 @@ function CommentEditor({
const canDisplayEditReason = (reasonCodesEnabled && edit const canDisplayEditReason = (reasonCodesEnabled && edit
&& (userHasModerationPrivileges || userIsGroupTa || userIsStaff) && (userHasModerationPrivileges || userIsGroupTa || userIsStaff)
&& comment?.author !== authenticatedUser.username && author !== authenticatedUser.username
); );
const editReasonCodeValidation = canDisplayEditReason && { const editReasonCodeValidation = canDisplayEditReason && {
@@ -56,34 +59,34 @@ function CommentEditor({
}); });
const initialValues = { const initialValues = {
comment: comment.rawBody, comment: rawBody,
editReasonCode: comment?.lastEdit?.reasonCode || (userIsStaff ? 'violates-guidelines' : ''), editReasonCode: lastEdit?.reasonCode || (userIsStaff ? 'violates-guidelines' : ''),
}; };
const handleCloseEditor = (resetForm) => { const handleCloseEditor = useCallback((resetForm) => {
resetForm({ values: initialValues }); resetForm({ values: initialValues });
onCloseEditor(); onCloseEditor();
}; }, [onCloseEditor, initialValues]);
const saveUpdatedComment = async (values, { resetForm }) => { const saveUpdatedComment = useCallback(async (values, { resetForm }) => {
if (comment.id) { if (id) {
const payload = { const payload = {
...values, ...values,
editReasonCode: values.editReasonCode || undefined, editReasonCode: values.editReasonCode || undefined,
}; };
await dispatch(editComment(comment.id, payload)); await dispatch(editComment(id, payload));
} else { } else {
await dispatch(addComment(values.comment, comment.threadId, comment.parentId, enableInContextSidebar)); await dispatch(addComment(values.comment, threadId, parentId, enableInContextSidebar));
} }
/* istanbul ignore if: TinyMCE is mocked so this cannot be easily tested */ /* istanbul ignore if: TinyMCE is mocked so this cannot be easily tested */
if (editorRef.current) { if (editorRef.current) {
editorRef.current.plugins.autosave.removeDraft(); editorRef.current.plugins.autosave.removeDraft();
} }
handleCloseEditor(resetForm); 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 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. // the current comment id, or the current comment parent or the curren thread.
const editorId = `comment-editor-${comment.id || comment.parentId || comment.threadId}`; const editorId = `comment-editor-${id || parentId || threadId}`;
return ( return (
<Formik <Formik
@@ -177,22 +180,28 @@ function CommentEditor({
CommentEditor.propTypes = { CommentEditor.propTypes = {
comment: PropTypes.shape({ comment: PropTypes.shape({
author: PropTypes.string,
id: PropTypes.string, id: PropTypes.string,
threadId: PropTypes.string.isRequired, lastEdit: PropTypes.object,
parentId: PropTypes.string, parentId: PropTypes.string,
rawBody: PropTypes.string, rawBody: PropTypes.string,
author: PropTypes.string, threadId: PropTypes.string.isRequired,
lastEdit: PropTypes.object, }),
}).isRequired,
onCloseEditor: PropTypes.func.isRequired,
intl: intlShape.isRequired,
edit: PropTypes.bool, edit: PropTypes.bool,
formClasses: PropTypes.string, formClasses: PropTypes.string,
onCloseEditor: PropTypes.func.isRequired,
}; };
CommentEditor.defaultProps = { CommentEditor.defaultProps = {
edit: true, edit: true,
comment: {
author: null,
id: null,
lastEdit: null,
parentId: null,
rawBody: '',
},
formClasses: '', formClasses: '',
}; };
export default injectIntl(CommentEditor); export default React.memo(CommentEditor);

View File

@@ -1,20 +1,29 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames'; import classNames from 'classnames';
import { injectIntl } from '@edx/frontend-platform/i18n';
import { Avatar } from '@edx/paragon'; import { Avatar } from '@edx/paragon';
import { AvatarOutlineAndLabelColors } from '../../../../data/constants'; import { AvatarOutlineAndLabelColors } from '../../../../data/constants';
import { AuthorLabel } from '../../../common'; import { AuthorLabel } from '../../../common';
import { useAlertBannerVisible } from '../../../data/hooks'; import { useAlertBannerVisible } from '../../../data/hooks';
import { commentShape } from './proptypes';
function CommentHeader({ const CommentHeader = ({
comment, author,
}) { authorLabel,
const colorClass = AvatarOutlineAndLabelColors[comment.authorLabel]; abuseFlagged,
const hasAnyAlert = useAlertBannerVisible(comment); closed,
createdAt,
lastEdit,
}) => {
const colorClass = AvatarOutlineAndLabelColors[authorLabel];
const hasAnyAlert = useAlertBannerVisible({
author,
abuseFlagged,
lastEdit,
closed,
});
return ( return (
<div className={classNames('d-flex flex-row justify-content-between', { <div className={classNames('d-flex flex-row justify-content-between', {
@@ -24,27 +33,41 @@ function CommentHeader({
<div className="align-items-center d-flex flex-row"> <div className="align-items-center d-flex flex-row">
<Avatar <Avatar
className={`border-0 ml-0.5 mr-2.5 ${colorClass ? `outline-${colorClass}` : 'outline-anonymous'}`} className={`border-0 ml-0.5 mr-2.5 ${colorClass ? `outline-${colorClass}` : 'outline-anonymous'}`}
alt={comment.author} alt={author}
style={{ style={{
width: '32px', width: '32px',
height: '32px', height: '32px',
}} }}
/> />
<AuthorLabel <AuthorLabel
author={comment.author} author={author}
authorLabel={comment.authorLabel} authorLabel={authorLabel}
labelColor={colorClass && `text-${colorClass}`} labelColor={colorClass && `text-${colorClass}`}
linkToProfile linkToProfile
postCreatedAt={comment.createdAt} postCreatedAt={createdAt}
postOrComment postOrComment
/> />
</div> </div>
</div> </div>
); );
}
CommentHeader.propTypes = {
comment: commentShape.isRequired,
}; };
export default injectIntl(CommentHeader); CommentHeader.propTypes = {
author: PropTypes.string.isRequired,
authorLabel: PropTypes.string,
abuseFlagged: PropTypes.bool.isRequired,
closed: PropTypes.bool,
createdAt: PropTypes.string.isRequired,
lastEdit: PropTypes.shape({
editorUsername: PropTypes.string,
reason: PropTypes.string,
}),
};
CommentHeader.defaultProps = {
authorLabel: null,
closed: undefined,
lastEdit: null,
};
export default React.memo(CommentHeader);

View File

@@ -1,10 +1,10 @@
import React, { useCallback, useMemo, useState } from 'react'; import React, { useCallback, useMemo, useState } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { useDispatch } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import * as timeago from 'timeago.js'; import * as timeago from 'timeago.js';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { Avatar, useToggle } from '@edx/paragon'; import { Avatar, useToggle } from '@edx/paragon';
import HTMLLoader from '../../../../components/HTMLLoader'; import HTMLLoader from '../../../../components/HTMLLoader';
@@ -13,57 +13,71 @@ import {
ActionsDropdown, AlertBanner, AuthorLabel, Confirmation, ActionsDropdown, AlertBanner, AuthorLabel, Confirmation,
} from '../../../common'; } from '../../../common';
import timeLocale from '../../../common/time-locale'; import timeLocale from '../../../common/time-locale';
import { ContentTypes } from '../../../data/constants';
import { useAlertBannerVisible } from '../../../data/hooks'; import { useAlertBannerVisible } from '../../../data/hooks';
import { selectCommentOrResponseById } from '../../data/selectors';
import { editComment, removeComment } from '../../data/thunks'; import { editComment, removeComment } from '../../data/thunks';
import messages from '../../messages'; import messages from '../../messages';
import CommentEditor from './CommentEditor'; import CommentEditor from './CommentEditor';
import { commentShape } from './proptypes';
function Reply({ const Reply = ({ responseId }) => {
reply,
postType,
intl,
}) {
timeago.register('time-locale', timeLocale); 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 dispatch = useDispatch();
const [isEditing, setEditing] = useState(false); const [isEditing, setEditing] = useState(false);
const [isDeleting, showDeleteConfirmation, hideDeleteConfirmation] = useToggle(false); const [isDeleting, showDeleteConfirmation, hideDeleteConfirmation] = useToggle(false);
const [isReporting, showReportConfirmation, hideReportConfirmation] = 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(() => { const handleAbusedFlag = useCallback(() => {
if (reply.abuseFlagged) { if (abuseFlagged) {
dispatch(editComment(reply.id, { flagged: !reply.abuseFlagged })); dispatch(editComment(id, { flagged: !abuseFlagged }));
} else { } else {
showReportConfirmation(); showReportConfirmation();
} }
}, [dispatch, reply.abuseFlagged, reply.id, showReportConfirmation]); }, [abuseFlagged, id, showReportConfirmation]);
const handleDeleteConfirmation = () => { const handleCloseEditor = useCallback(() => {
dispatch(removeComment(reply.id)); setEditing(false);
hideDeleteConfirmation(); }, []);
};
const handleReportConfirmation = () => {
dispatch(editComment(reply.id, { flagged: !reply.abuseFlagged }));
hideReportConfirmation();
};
const actionHandlers = useMemo(() => ({ const actionHandlers = useMemo(() => ({
[ContentActions.EDIT_CONTENT]: () => setEditing(true), [ContentActions.EDIT_CONTENT]: handleEditContent,
[ContentActions.ENDORSE]: () => dispatch(editComment( [ContentActions.ENDORSE]: handleReplyEndorse,
reply.id,
{ endorsed: !reply.endorsed },
ContentActions.ENDORSE,
)),
[ContentActions.DELETE]: showDeleteConfirmation, [ContentActions.DELETE]: showDeleteConfirmation,
[ContentActions.REPORT]: () => handleAbusedFlag(), [ContentActions.REPORT]: handleAbusedFlag,
}), [dispatch, handleAbusedFlag, reply.endorsed, reply.id, showDeleteConfirmation]); }), [handleEditContent, handleReplyEndorse, showDeleteConfirmation, handleAbusedFlag]);
const colorClass = AvatarOutlineAndLabelColors[reply.authorLabel];
const hasAnyAlert = useAlertBannerVisible(reply);
return ( return (
<div className="d-flex flex-column mt-2.5 " data-testid={`reply-${reply.id}`} role="listitem"> <div className="d-flex flex-column mt-2.5 " data-testid={`reply-${id}`} role="listitem">
<Confirmation <Confirmation
isOpen={isDeleting} isOpen={isDeleting}
title={intl.formatMessage(messages.deleteCommentTitle)} title={intl.formatMessage(messages.deleteCommentTitle)}
@@ -73,7 +87,7 @@ function Reply({
closeButtonVaraint="tertiary" closeButtonVaraint="tertiary"
confirmButtonText={intl.formatMessage(messages.deleteConfirmationDelete)} confirmButtonText={intl.formatMessage(messages.deleteConfirmationDelete)}
/> />
{!reply.abuseFlagged && ( {!abuseFlagged && (
<Confirmation <Confirmation
isOpen={isReporting} isOpen={isReporting}
title={intl.formatMessage(messages.reportCommentTitle)} title={intl.formatMessage(messages.reportCommentTitle)}
@@ -89,16 +103,24 @@ function Reply({
<Avatar /> <Avatar />
</div> </div>
<div className="w-100"> <div className="w-100">
<AlertBanner content={reply} intl={intl} /> <AlertBanner
author={author}
abuseFlagged={abuseFlagged}
closed={closed}
closedBy={closedBy}
closeReason={closeReason}
lastEdit={lastEdit}
editByLabel={editByLabel}
closedByLabel={closedByLabel}
/>
</div> </div>
</div> </div>
)} )}
<div className="d-flex"> <div className="d-flex">
<div className="d-flex mr-3 mt-2.5"> <div className="d-flex mr-3 mt-2.5">
<Avatar <Avatar
className={`ml-0.5 mt-0.5 border-0 ${colorClass ? `outline-${colorClass}` : 'outline-anonymous'}`} className={`ml-0.5 mt-0.5 border-0 ${colorClass ? `outline-${colorClass}` : 'outline-anonymous'}`}
alt={reply.author} alt={author}
style={{ style={{
width: '32px', width: '32px',
height: '32px', height: '32px',
@@ -111,42 +133,50 @@ function Reply({
> >
<div className="d-flex flex-row justify-content-between" style={{ height: '24px' }}> <div className="d-flex flex-row justify-content-between" style={{ height: '24px' }}>
<AuthorLabel <AuthorLabel
author={reply.author} author={author}
authorLabel={reply.authorLabel} authorLabel={authorLabel}
labelColor={colorClass && `text-${colorClass}`} labelColor={colorClass && `text-${colorClass}`}
linkToProfile linkToProfile
postCreatedAt={reply.createdAt} postCreatedAt={createdAt}
postOrComment postOrComment
/> />
<div className="ml-auto d-flex"> <div className="ml-auto d-flex">
<ActionsDropdown <ActionsDropdown
commentOrPost={{
...reply,
postType,
}}
actionHandlers={actionHandlers} actionHandlers={actionHandlers}
contentType={ContentTypes.COMMENT}
iconSize="inline" iconSize="inline"
id={id}
/> />
</div> </div>
</div> </div>
{isEditing {isEditing ? (
? <CommentEditor comment={reply} onCloseEditor={() => setEditing(false)} /> <CommentEditor
: ( comment={{
<HTMLLoader id,
componentId="reply" threadId,
htmlNode={reply.renderedBody} parentId,
cssClassName="html-loader text-break font-style text-primary-500" rawBody,
testId={reply.id} author,
/> lastEdit,
)} }}
onCloseEditor={handleCloseEditor}
/>
) : (
<HTMLLoader
componentId="reply"
htmlNode={renderedBody}
cssClassName="html-loader text-break font-style text-primary-500"
testId={id}
/>
)}
</div> </div>
</div> </div>
</div> </div>
); );
}
Reply.propTypes = {
postType: PropTypes.oneOf(['discussion', 'question']).isRequired,
reply: commentShape.isRequired,
intl: intlShape.isRequired,
}; };
export default injectIntl(Reply);
Reply.propTypes = {
responseId: PropTypes.string.isRequired,
};
export default React.memo(Reply);

View File

@@ -1,36 +1,34 @@
import React, { useEffect } from 'react'; import React, { useContext, useEffect } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import classNames from 'classnames'; import classNames from 'classnames';
import { injectIntl } from '@edx/frontend-platform/i18n'; import { DiscussionContext } from '../../../common/context';
import CommentEditor from './CommentEditor'; import CommentEditor from './CommentEditor';
function ResponseEditor({ const ResponseEditor = ({
postId,
addWrappingDiv, addWrappingDiv,
handleCloseEditor, handleCloseEditor,
addingResponse, addingResponse,
}) { }) => {
const { postId } = useContext(DiscussionContext);
useEffect(() => { useEffect(() => {
handleCloseEditor(); handleCloseEditor();
}, [postId]); }, [postId]);
return addingResponse return addingResponse && (
&& ( <div className={classNames({ 'bg-white p-4 mb-4 rounded mt-2': addWrappingDiv })}>
<div className={classNames({ 'bg-white p-4 mb-4 rounded mt-2': addWrappingDiv })}> <CommentEditor
<CommentEditor comment={{ threadId: postId }}
comment={{ threadId: postId }} edit={false}
edit={false} onCloseEditor={handleCloseEditor}
onCloseEditor={handleCloseEditor} />
/> </div>
</div> );
); };
}
ResponseEditor.propTypes = { ResponseEditor.propTypes = {
postId: PropTypes.string.isRequired,
addWrappingDiv: PropTypes.bool, addWrappingDiv: PropTypes.bool,
handleCloseEditor: PropTypes.func.isRequired, handleCloseEditor: PropTypes.func.isRequired,
addingResponse: PropTypes.bool.isRequired, addingResponse: PropTypes.bool.isRequired,
@@ -40,4 +38,4 @@ ResponseEditor.defaultProps = {
addWrappingDiv: false, addWrappingDiv: false,
}; };
export default injectIntl(ResponseEditor); export default React.memo(ResponseEditor);

View File

@@ -27,6 +27,7 @@ export async function getThreadComments(
pageSize, pageSize,
reverseOrder, reverseOrder,
enableInContextSidebar = false, enableInContextSidebar = false,
signal,
} = {}, } = {},
) { ) {
const params = snakeCaseObject({ const params = snakeCaseObject({
@@ -40,7 +41,7 @@ export async function getThreadComments(
}); });
const { data } = await getAuthenticatedHttpClient() const { data } = await getAuthenticatedHttpClient()
.get(getCommentsApiUrl(), { params }); .get(getCommentsApiUrl(), { params: { ...params, signal } });
return data; return data;
} }

View File

@@ -1,4 +1,6 @@
import { useContext, useEffect } from 'react'; import {
useCallback, useContext, useEffect, useMemo,
} from 'react';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
@@ -9,20 +11,21 @@ import { useDispatchWithState } from '../../../data/hooks';
import { DiscussionContext } from '../../common/context'; import { DiscussionContext } from '../../common/context';
import { selectThread } from '../../posts/data/selectors'; import { selectThread } from '../../posts/data/selectors';
import { markThreadAsRead } from '../../posts/data/thunks'; import { markThreadAsRead } from '../../posts/data/thunks';
import { filterPosts } from '../../utils';
import { import {
selectCommentSortOrder, selectThreadComments, selectThreadCurrentPage, selectThreadHasMorePages, selectCommentSortOrder, selectThreadComments, selectThreadCurrentPage, selectThreadHasMorePages,
} from './selectors'; } from './selectors';
import { fetchThreadComments } from './thunks'; import { fetchThreadComments } from './thunks';
function trackLoadMoreEvent(postId, params) { const trackLoadMoreEvent = (postId, params) => (
sendTrackEvent( sendTrackEvent(
'edx.forum.responses.loadMore', 'edx.forum.responses.loadMore',
{ {
postId, postId,
params, params,
}, },
); )
} );
export function usePost(postId) { export function usePost(postId) {
const dispatch = useDispatch(); const dispatch = useDispatch();
@@ -34,18 +37,26 @@ export function usePost(postId) {
} }
}, [postId]); }, [postId]);
return thread; return thread || {};
} }
export function usePostComments(postId, endorsed = null) { export function usePostComments(endorsed = null) {
const { enableInContextSidebar, postId } = useContext(DiscussionContext);
const [isLoading, dispatch] = useDispatchWithState(); const [isLoading, dispatch] = useDispatchWithState();
const comments = useSelector(selectThreadComments(postId, endorsed)); const comments = useSelector(selectThreadComments(postId, endorsed));
const reverseOrder = useSelector(selectCommentSortOrder); const reverseOrder = useSelector(selectCommentSortOrder);
const hasMorePages = useSelector(selectThreadHasMorePages(postId, endorsed)); const hasMorePages = useSelector(selectThreadHasMorePages(postId, endorsed));
const currentPage = useSelector(selectThreadCurrentPage(postId, endorsed)); const currentPage = useSelector(selectThreadCurrentPage(postId, endorsed));
const { enableInContextSidebar } = useContext(DiscussionContext);
const handleLoadMoreResponses = async () => { const endorsedCommentsIds = useMemo(() => (
[...filterPosts(comments, 'endorsed')].map(comment => comment.id)
), [comments]);
const unEndorsedCommentsIds = useMemo(() => (
[...filterPosts(comments, 'unendorsed')].map(comment => comment.id)
), [comments]);
const handleLoadMoreResponses = useCallback(async () => {
const params = { const params = {
endorsed, endorsed,
page: currentPage + 1, page: currentPage + 1,
@@ -53,19 +64,27 @@ export function usePostComments(postId, endorsed = null) {
}; };
await dispatch(fetchThreadComments(postId, params)); await dispatch(fetchThreadComments(postId, params));
trackLoadMoreEvent(postId, params); trackLoadMoreEvent(postId, params);
}; }, [currentPage, endorsed, postId, reverseOrder]);
useEffect(() => { useEffect(() => {
const abortController = new AbortController();
dispatch(fetchThreadComments(postId, { dispatch(fetchThreadComments(postId, {
endorsed, endorsed,
page: 1, page: 1,
reverseOrder, reverseOrder,
enableInContextSidebar, enableInContextSidebar,
signal: abortController.signal,
})); }));
}, [postId, reverseOrder]);
return () => {
abortController.abort();
};
}, [postId, endorsed, reverseOrder, enableInContextSidebar]);
return { return {
comments, endorsedCommentsIds,
unEndorsedCommentsIds,
hasMorePages, hasMorePages,
isLoading, isLoading,
handleLoadMoreResponses, handleLoadMoreResponses,
@@ -77,5 +96,9 @@ export function useCommentsCount(postId) {
const endorsedQuestions = useSelector(selectThreadComments(postId, EndorsementStatus.ENDORSED)); const endorsedQuestions = useSelector(selectThreadComments(postId, EndorsementStatus.ENDORSED));
const unendorsedQuestions = useSelector(selectThreadComments(postId, EndorsementStatus.UNENDORSED)); const unendorsedQuestions = useSelector(selectThreadComments(postId, EndorsementStatus.UNENDORSED));
return [...discussions, ...endorsedQuestions, ...unendorsedQuestions].length; const commentsLength = useMemo(() => (
[...discussions, ...endorsedQuestions, ...unendorsedQuestions].length
), [discussions, endorsedQuestions, unendorsedQuestions]);
return commentsLength;
} }

View File

@@ -4,6 +4,11 @@ import { createSelector } from '@reduxjs/toolkit';
const selectCommentsById = state => state.comments.commentsById; const selectCommentsById = state => state.comments.commentsById;
const mapIdToComment = (ids, comments) => ids.map(id => comments[id]); 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( export const selectThreadComments = (threadId, endorsed = null) => createSelector(
[ [
state => state.comments.commentsInThreads[threadId]?.[endorsed] || [], state => state.comments.commentsInThreads[threadId]?.[endorsed] || [],
@@ -12,6 +17,10 @@ export const selectThreadComments = (threadId, endorsed = null) => createSelecto
mapIdToComment, mapIdToComment,
); );
export const selectCommentResponsesIds = commentId => (
state => state.comments.commentsInComments[commentId] || []
);
export const selectCommentResponses = commentId => createSelector( export const selectCommentResponses = commentId => createSelector(
[ [
state => state.comments.commentsInComments[commentId] || [], state => state.comments.commentsInComments[commentId] || [],

View File

@@ -81,13 +81,14 @@ export function fetchThreadComments(
reverseOrder, reverseOrder,
endorsed = EndorsementStatus.DISCUSSION, endorsed = EndorsementStatus.DISCUSSION,
enableInContextSidebar, enableInContextSidebar,
signal,
} = {}, } = {},
) { ) {
return async (dispatch) => { return async (dispatch) => {
try { try {
dispatch(fetchCommentsRequest()); dispatch(fetchCommentsRequest());
const data = await getThreadComments(threadId, { const data = await getThreadComments(threadId, {
page, reverseOrder, endorsed, enableInContextSidebar, page, reverseOrder, endorsed, enableInContextSidebar, signal,
}); });
dispatch(fetchCommentsSuccess({ dispatch(fetchCommentsSuccess({
...normaliseComments(camelCaseObject(data)), ...normaliseComments(camelCaseObject(data)),

View File

@@ -0,0 +1,8 @@
/* eslint-disable import/prefer-default-export */
import React from 'react';
export const PostCommentsContext = React.createContext({
isClosed: false,
postType: 'discussion',
postId: '',
});

View File

@@ -1,13 +1,16 @@
import React from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { selectAreThreadsFiltered } from '../data/selectors'; import { selectAreThreadsFiltered } from '../data/selectors';
import { selectTopicFilter } from '../in-context-topics/data/selectors'; import { selectTopicFilter } from '../in-context-topics/data/selectors';
import messages from '../messages'; import messages from '../messages';
function NoResults({ intl }) { const NoResults = () => {
const intl = useIntl();
const postsFiltered = useSelector(selectAreThreadsFiltered); const postsFiltered = useSelector(selectAreThreadsFiltered);
const inContextTopicsFilter = useSelector(selectTopicFilter); const inContextTopicsFilter = useSelector(selectTopicFilter);
const topicsFilter = useSelector(({ topics }) => topics.filter); const topicsFilter = useSelector(({ topics }) => topics.filter);
@@ -17,6 +20,7 @@ function NoResults({ intl }) {
|| (learnersFilter !== null) || (inContextTopicsFilter !== ''); || (learnersFilter !== null) || (inContextTopicsFilter !== '');
let helpMessage = messages.removeFilters; let helpMessage = messages.removeFilters;
if (!isFiltered) { if (!isFiltered) {
return null; return null;
} if (filters.search || learnersFilter) { } if (filters.search || learnersFilter) {
@@ -24,6 +28,7 @@ function NoResults({ intl }) {
} if (topicsFilter || inContextTopicsFilter) { } if (topicsFilter || inContextTopicsFilter) {
helpMessage = messages.removeKeywordsOnly; helpMessage = messages.removeKeywordsOnly;
} }
const titleCssClasses = classNames( const titleCssClasses = classNames(
{ 'font-weight-normal text-primary-500': topicsFilter || learnersFilter }, { 'font-weight-normal text-primary-500': topicsFilter || learnersFilter },
); );
@@ -37,10 +42,6 @@ function NoResults({ intl }) {
<small className={textCssClasses}>{intl.formatMessage(helpMessage)}</small> <small className={textCssClasses}>{intl.formatMessage(helpMessage)}</small>
</div> </div>
); );
}
NoResults.propTypes = {
intl: intlShape.isRequired,
}; };
export default injectIntl(NoResults); export default NoResults;

View File

@@ -5,7 +5,7 @@ import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { AppContext } from '@edx/frontend-platform/react'; import { AppContext } from '@edx/frontend-platform/react';
import { Button, Spinner } from '@edx/paragon'; import { Button, Spinner } from '@edx/paragon';
@@ -14,7 +14,7 @@ import { DiscussionContext } from '../common/context';
import { selectconfigLoadingStatus, selectUserHasModerationPrivileges, selectUserIsStaff } from '../data/selectors'; import { selectconfigLoadingStatus, selectUserHasModerationPrivileges, selectUserIsStaff } from '../data/selectors';
import { fetchUserPosts } from '../learners/data/thunks'; import { fetchUserPosts } from '../learners/data/thunks';
import messages from '../messages'; import messages from '../messages';
import { filterPosts } from '../utils'; import { usePostList } from './data/hooks';
import { import {
selectThreadFilters, selectThreadNextPage, selectThreadSorting, threadsLoadingStatus, selectThreadFilters, selectThreadNextPage, selectThreadSorting, threadsLoadingStatus,
} from './data/selectors'; } from './data/selectors';
@@ -22,25 +22,24 @@ import { fetchThreads } from './data/thunks';
import NoResults from './NoResults'; import NoResults from './NoResults';
import { PostLink } from './post'; import { PostLink } from './post';
function PostsList({ const PostsList = ({
posts, topics, intl, isTopicTab, parentIsLoading, postsIds, topicsIds, isTopicTab, parentIsLoading,
}) { }) => {
const intl = useIntl();
const dispatch = useDispatch(); const dispatch = useDispatch();
const {
courseId,
page,
} = useContext(DiscussionContext);
const loadingStatus = useSelector(threadsLoadingStatus());
const { authenticatedUser } = useContext(AppContext); const { authenticatedUser } = useContext(AppContext);
const { courseId, page } = useContext(DiscussionContext);
const loadingStatus = useSelector(threadsLoadingStatus());
const orderBy = useSelector(selectThreadSorting()); const orderBy = useSelector(selectThreadSorting());
const filters = useSelector(selectThreadFilters()); const filters = useSelector(selectThreadFilters());
const nextPage = useSelector(selectThreadNextPage()); const nextPage = useSelector(selectThreadNextPage());
const showOwnPosts = page === 'my-posts';
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges); const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
const userIsStaff = useSelector(selectUserIsStaff); const userIsStaff = useSelector(selectUserIsStaff);
const configStatus = useSelector(selectconfigLoadingStatus); const configStatus = useSelector(selectconfigLoadingStatus);
const sortedPostsIds = usePostList(postsIds);
const showOwnPosts = page === 'my-posts';
const loadThreads = (topicIds, pageNum = undefined, isFilterChanged = false) => { const loadThreads = useCallback((topicIds, pageNum = undefined, isFilterChanged = false) => {
const params = { const params = {
orderBy, orderBy,
filters, filters,
@@ -50,75 +49,68 @@ function PostsList({
topicIds, topicIds,
isFilterChanged, isFilterChanged,
}; };
if (showOwnPosts && filters.search === '') { if (showOwnPosts && filters.search === '') {
dispatch(fetchUserPosts(courseId, params)); dispatch(fetchUserPosts(courseId, params));
} else { } else {
dispatch(fetchThreads(courseId, params)); dispatch(fetchThreads(courseId, params));
} }
}; }, [courseId, orderBy, filters, showOwnPosts, authenticatedUser.username, userHasModerationPrivileges, userIsStaff]);
useEffect(() => { useEffect(() => {
if (topics !== undefined && configStatus === RequestStatus.SUCCESSFUL) { if (topicsIds !== undefined && configStatus === RequestStatus.SUCCESSFUL) {
loadThreads(topics); loadThreads(topicsIds);
} }
}, [courseId, filters, orderBy, page, JSON.stringify(topics), configStatus]); }, [courseId, filters, orderBy, page, JSON.stringify(topicsIds), configStatus]);
useEffect(() => { useEffect(() => {
if (isTopicTab) { loadThreads(topics, 1, true); } if (isTopicTab) {
loadThreads(topicsIds, 1, true);
}
}, [filters]); }, [filters]);
const checkIsSelected = (id) => window.location.pathname.includes(id); const postInstances = useMemo(() => (
const pinnedPosts = useMemo(() => filterPosts(posts, 'pinned'), [posts]); sortedPostsIds?.map((postId, idx) => (
const unpinnedPosts = useMemo(() => filterPosts(posts, 'unpinned'), [posts]);
const postInstances = useCallback((sortedPosts) => (
sortedPosts.map((post, idx) => (
<PostLink <PostLink
post={post} postId={postId}
key={post.id}
isSelected={checkIsSelected}
idx={idx} idx={idx}
showDivider={(sortedPosts.length - 1) !== idx} key={postId}
showDivider={(sortedPostsIds.length - 1) !== idx}
/> />
)) ))
), []); ), [sortedPostsIds]);
return ( return (
<> <>
{!parentIsLoading && postInstances(pinnedPosts)} {!parentIsLoading && postInstances}
{!parentIsLoading && postInstances(unpinnedPosts)} {sortedPostsIds?.length === 0 && loadingStatus === RequestStatus.SUCCESSFUL && <NoResults />}
{posts?.length === 0 && loadingStatus === RequestStatus.SUCCESSFUL && <NoResults />}
{loadingStatus === RequestStatus.IN_PROGRESS || parentIsLoading ? ( {loadingStatus === RequestStatus.IN_PROGRESS || parentIsLoading ? (
<div className="d-flex justify-content-center p-4 mx-auto my-auto"> <div className="d-flex justify-content-center p-4 mx-auto my-auto">
<Spinner animation="border" variant="primary" size="lg" /> <Spinner animation="border" variant="primary" size="lg" />
</div> </div>
) : ( ) : (
nextPage && loadingStatus === RequestStatus.SUCCESSFUL && ( nextPage && loadingStatus === RequestStatus.SUCCESSFUL && (
<Button onClick={() => loadThreads(topics, nextPage)} variant="primary" size="md"> <Button onClick={() => loadThreads(topicsIds, nextPage)} variant="primary" size="md">
{intl.formatMessage(messages.loadMorePosts)} {intl.formatMessage(messages.loadMorePosts)}
</Button> </Button>
) )
)} )}
</> </>
); );
} };
PostsList.propTypes = { PostsList.propTypes = {
posts: PropTypes.arrayOf(PropTypes.shape({ postsIds: PropTypes.arrayOf(PropTypes.string),
pinned: PropTypes.bool.isRequired, topicsIds: PropTypes.arrayOf(PropTypes.string),
id: PropTypes.string.isRequired,
})),
topics: PropTypes.arrayOf(PropTypes.string),
isTopicTab: PropTypes.bool, isTopicTab: PropTypes.bool,
parentIsLoading: PropTypes.bool, parentIsLoading: PropTypes.bool,
intl: intlShape.isRequired,
}; };
PostsList.defaultProps = { PostsList.defaultProps = {
posts: [], postsIds: [],
topics: undefined, topicsIds: undefined,
isTopicTab: false, isTopicTab: false,
parentIsLoading: undefined, parentIsLoading: undefined,
}; };
export default injectIntl(PostsList); export default React.memo(PostsList);

View File

@@ -1,4 +1,6 @@
import React, { useContext, useEffect } from 'react'; import React, {
useCallback, useContext, useEffect, useMemo,
} from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import isEmpty from 'lodash/isEmpty'; import isEmpty from 'lodash/isEmpty';
@@ -13,39 +15,42 @@ import { fetchCourseTopicsV3 } from '../in-context-topics/data/thunks';
import { selectTopics } from '../topics/data/selectors'; import { selectTopics } from '../topics/data/selectors';
import { fetchCourseTopics } from '../topics/data/thunks'; import { fetchCourseTopics } from '../topics/data/thunks';
import { handleKeyDown } from '../utils'; import { handleKeyDown } from '../utils';
import { selectAllThreads, selectTopicThreads } from './data/selectors'; import { selectAllThreadsIds, selectTopicThreadsIds } from './data/selectors';
import { setSearchQuery } from './data/slices'; import { setSearchQuery } from './data/slices';
import PostFilterBar from './post-filter-bar/PostFilterBar'; import PostFilterBar from './post-filter-bar/PostFilterBar';
import PostsList from './PostsList'; import PostsList from './PostsList';
function AllPostsList() { const AllPostsList = () => {
const posts = useSelector(selectAllThreads); const postsIds = useSelector(selectAllThreadsIds);
return <PostsList posts={posts} topics={null} />;
}
function TopicPostsList({ topicId }) { return <PostsList postsIds={postsIds} topicsIds={null} />;
const posts = useSelector(selectTopicThreads([topicId])); };
return <PostsList posts={posts} topics={[topicId]} isTopicTab />;
} const TopicPostsList = React.memo(({ topicId }) => {
const postsIds = useSelector(selectTopicThreadsIds([topicId]));
return <PostsList postsIds={postsIds} topicsIds={[topicId]} isTopicTab />;
});
TopicPostsList.propTypes = { TopicPostsList.propTypes = {
topicId: PropTypes.string.isRequired, topicId: PropTypes.string.isRequired,
}; };
function CategoryPostsList({ category }) { const CategoryPostsList = React.memo(({ category }) => {
const { enableInContextSidebar } = useContext(DiscussionContext); const { enableInContextSidebar } = useContext(DiscussionContext);
const groupedCategory = useSelector(selectCurrentCategoryGrouping)(category); const groupedCategory = useSelector(selectCurrentCategoryGrouping)(category);
// If grouping at subsection is enabled, only apply it when browsing discussions in context in the learning MFE. // 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 topicIds = useSelector(selectTopicsUnderCategory)(enableInContextSidebar ? groupedCategory : category);
const posts = useSelector(enableInContextSidebar ? selectAllThreads : selectTopicThreads(topicIds)); const postsIds = useSelector(enableInContextSidebar ? selectAllThreadsIds : selectTopicThreadsIds(topicIds));
return <PostsList posts={posts} topics={topicIds} />;
} return <PostsList postsIds={postsIds} topicsIds={topicIds} />;
});
CategoryPostsList.propTypes = { CategoryPostsList.propTypes = {
category: PropTypes.string.isRequired, category: PropTypes.string.isRequired,
}; };
function PostsView() { const PostsView = () => {
const { const {
topicId, topicId,
category, category,
@@ -68,15 +73,19 @@ function PostsView() {
} }
}, [topics]); }, [topics]);
let postsListComponent; const handleOnClear = useCallback(() => {
dispatch(setSearchQuery(''));
}, []);
if (topicId) { const postsListComponent = useMemo(() => {
postsListComponent = <TopicPostsList topicId={topicId} />; if (topicId) {
} else if (category) { return <TopicPostsList topicId={topicId} />;
postsListComponent = <CategoryPostsList category={category} />; }
} else { if (category) {
postsListComponent = <AllPostsList />; return <CategoryPostsList category={category} />;
} }
return <AllPostsList />;
}, [topicId, category]);
return ( return (
<div className="discussion-posts d-flex flex-column h-100"> <div className="discussion-posts d-flex flex-column h-100">
@@ -85,7 +94,7 @@ function PostsView() {
count={resultsFound} count={resultsFound}
text={searchString} text={searchString}
loadingStatus={loadingStatus} loadingStatus={loadingStatus}
onClear={() => dispatch(setSearchQuery(''))} onClear={handleOnClear}
textSearchRewrite={textSearchRewrite} textSearchRewrite={textSearchRewrite}
/> />
)} )}
@@ -96,9 +105,6 @@ function PostsView() {
</div> </div>
</div> </div>
); );
}
PostsView.propTypes = {
}; };
export default PostsView; export default PostsView;

View File

@@ -0,0 +1,26 @@
/* eslint-disable import/prefer-default-export */
import { useMemo } from 'react';
import { useSelector } from 'react-redux';
import { selectThreadsByIds } from './selectors';
export const usePostList = (ids) => {
const posts = useSelector(selectThreadsByIds(ids));
const pinnedPostsIds = [];
const unpinnedPostsIds = [];
const sortedIds = useMemo(() => {
posts.forEach((post) => {
if (post.pinned) {
pinnedPostsIds.push(post.id);
} else {
unpinnedPostsIds.push(post.id);
}
});
return [...pinnedPostsIds, ...unpinnedPostsIds];
}, [posts]);
return sortedIds;
};

View File

@@ -16,6 +16,15 @@ export const selectTopicThreads = topicIds => createSelector(
mapIdsToThreads, 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( export const selectThread = threadId => createSelector(
[selectThreads], [selectThreads],
(threads) => threads?.[threadId], (threads) => threads?.[threadId],
@@ -37,6 +46,11 @@ export const selectAllThreads = createSelector(
(pages, threads) => pages.flatMap(ids => mapIdsToThreads(ids, threads)), (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 threadsLoadingStatus = () => state => state.threads.status;
export const selectThreadSorting = () => state => state.threads.sortedBy; export const selectThreadSorting = () => state => state.threads.sortedBy;

View File

@@ -15,7 +15,10 @@ const mergeThreadsInTopics = (dataFromState, dataFromPayload) => {
const values = Object.values(obj); const values = Object.values(obj);
keys.forEach((key, index) => { keys.forEach((key, index) => {
if (!acc[key]) { acc[key] = []; } if (!acc[key]) { acc[key] = []; }
if (Array.isArray(acc[key])) { acc[key] = acc[key].concat(values[index]); } else { acc[key].push(values[index]); } if (Array.isArray(acc[key])) {
const uniqueValues = [...new Set(acc[key].concat(values[index]))];
acc[key] = uniqueValues;
} else { acc[key].push(values[index]); }
return acc; return acc;
}); });
return acc; return acc;
@@ -68,29 +71,29 @@ const threadsSlice = createSlice({
state.status = RequestStatus.IN_PROGRESS; state.status = RequestStatus.IN_PROGRESS;
}, },
fetchThreadsSuccess: (state, { payload }) => { fetchThreadsSuccess: (state, { payload }) => {
if (state.author !== payload.author) { const {
author, page, ids, threadsById, isFilterChanged, threadsInTopic, avatars, pagination, textSearchRewrite,
} = payload;
if (state.author !== author) {
state.pages = []; state.pages = [];
state.author = payload.author; state.author = author;
} }
if (state.pages[payload.page - 1]) { if (!state.pages[page - 1]) {
state.pages[payload.page - 1] = [...state.pages[payload.page - 1], ...payload.ids]; state.pages[page - 1] = ids;
} else { } else {
state.pages[payload.page - 1] = payload.ids; state.pages[page - 1] = [...new Set([...state.pages[page - 1], ...ids])];
} }
state.status = RequestStatus.SUCCESSFUL; state.status = RequestStatus.SUCCESSFUL;
state.threadsById = { ...state.threadsById, ...payload.threadsById }; state.threadsById = { ...state.threadsById, ...threadsById };
// filter state.threadsInTopic = (isFilterChanged || page === 1)
if (payload.isFilterChanged) { ? { ...threadsInTopic }
state.threadsInTopic = { ...payload.threadsInTopic }; : mergeThreadsInTopics(state.threadsInTopic, threadsInTopic);
} else { state.avatars = { ...state.avatars, ...avatars };
state.threadsInTopic = mergeThreadsInTopics(state.threadsInTopic, payload.threadsInTopic); state.nextPage = (page < pagination.numPages) ? page + 1 : null;
} state.totalPages = pagination.numPages;
state.totalThreads = pagination.count;
state.avatars = { ...state.avatars, ...payload.avatars }; state.textSearchRewrite = textSearchRewrite;
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) => { fetchThreadsFailed: (state) => {
state.status = RequestStatus.FAILED; state.status = RequestStatus.FAILED;

View File

@@ -1,9 +1,9 @@
import React, { useContext } from 'react'; import React, { useCallback, useContext } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { import {
Button, Icon, IconButton, Button, Icon, IconButton,
} from '@edx/paragon'; } from '@edx/paragon';
@@ -21,18 +21,21 @@ import messages from './messages';
import './actionBar.scss'; import './actionBar.scss';
function PostActionsBar({ const PostActionsBar = () => {
intl, const intl = useIntl();
}) {
const dispatch = useDispatch(); const dispatch = useDispatch();
const loadingStatus = useSelector(selectconfigLoadingStatus); const loadingStatus = useSelector(selectconfigLoadingStatus);
const enableInContext = useSelector(selectEnableInContext); const enableInContext = useSelector(selectEnableInContext);
const userCanAddThreadInBlackoutDate = useUserCanAddThreadInBlackoutDate(); const userCanAddThreadInBlackoutDate = useUserCanAddThreadInBlackoutDate();
const { enableInContextSidebar, page } = useContext(DiscussionContext); const { enableInContextSidebar, page } = useContext(DiscussionContext);
const handleCloseInContext = () => { const handleCloseInContext = useCallback(() => {
postMessageToParent('learning.events.sidebar.close'); postMessageToParent('learning.events.sidebar.close');
}; }, []);
const handleAddPost = useCallback(() => {
dispatch(showPostEditor());
}, []);
return ( return (
<div className={classNames('d-flex justify-content-end flex-grow-1', { 'py-1': !enableInContextSidebar })}> <div className={classNames('d-flex justify-content-end flex-grow-1', { 'py-1': !enableInContextSidebar })}>
@@ -53,7 +56,7 @@ function PostActionsBar({
variant={enableInContextSidebar ? 'plain' : 'brand'} variant={enableInContextSidebar ? 'plain' : 'brand'}
className={classNames('my-0 font-style border-0 line-height-24', className={classNames('my-0 font-style border-0 line-height-24',
{ 'px-3 py-10px border-0': enableInContextSidebar })} { 'px-3 py-10px border-0': enableInContextSidebar })}
onClick={() => dispatch(showPostEditor())} onClick={handleAddPost}
size={enableInContextSidebar ? 'md' : 'sm'} size={enableInContextSidebar ? 'md' : 'sm'}
> >
{intl.formatMessage(messages.addAPost)} {intl.formatMessage(messages.addAPost)}
@@ -77,10 +80,6 @@ function PostActionsBar({
)} )}
</div> </div>
); );
}
PostActionsBar.propTypes = {
intl: intlShape.isRequired,
}; };
export default injectIntl(PostActionsBar); export default PostActionsBar;

View File

@@ -1,9 +1,8 @@
import React, { import React, {
useContext, useEffect, useRef, useCallback, useContext, useEffect, useRef,
} from 'react'; } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import classNames from 'classnames';
import { Formik } from 'formik'; import { Formik } from 'formik';
import { isEmpty } from 'lodash'; import { isEmpty } from 'lodash';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
@@ -13,7 +12,7 @@ import * as Yup from 'yup';
import { useIntl } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { AppContext } from '@edx/frontend-platform/react'; import { AppContext } from '@edx/frontend-platform/react';
import { import {
Button, Card, Form, Spinner, StatefulButton, Button, Form, Spinner, StatefulButton,
} from '@edx/paragon'; } from '@edx/paragon';
import { Help, Post } from '@edx/paragon/icons'; import { Help, Post } from '@edx/paragon/icons';
@@ -49,58 +48,22 @@ import { hidePostEditor } from '../data';
import { selectThread } from '../data/selectors'; import { selectThread } from '../data/selectors';
import { createNewThread, fetchThread, updateExistingThread } from '../data/thunks'; import { createNewThread, fetchThread, updateExistingThread } from '../data/thunks';
import messages from './messages'; import messages from './messages';
import PostTypeCard from './PostTypeCard';
function DiscussionPostType({ const PostEditor = ({
value,
type,
selected,
icon,
}) {
const { enableInContextSidebar } = useContext(DiscussionContext);
// Need to use regular label since Form.Label doesn't support overriding htmlFor
return (
<label htmlFor={`post-type-${value}`} className="d-flex p-0 my-0 mr-3">
<Form.Radio value={value} id={`post-type-${value}`} className="sr-only">{type}</Form.Radio>
<Card
className={classNames('border-2 shadow-none', {
'border-primary': selected,
'border-light-400': !selected,
})}
style={{ cursor: 'pointer', width: `${enableInContextSidebar ? '10.021rem' : '14.25rem'}` }}
>
<Card.Section className="px-4 py-3 d-flex flex-column align-items-center">
<span className="text-primary-300 mb-0.5">{icon}</span>
<span className="text-gray-700">{type}</span>
</Card.Section>
</Card>
</label>
);
}
DiscussionPostType.propTypes = {
value: PropTypes.string.isRequired,
type: PropTypes.string.isRequired,
selected: PropTypes.bool.isRequired,
icon: PropTypes.element.isRequired,
};
function PostEditor({
editExisting, editExisting,
}) { }) => {
const intl = useIntl(); const intl = useIntl();
const { authenticatedUser } = useContext(AppContext);
const dispatch = useDispatch();
const editorRef = useRef(null);
const [submitting, dispatchSubmit] = useDispatchWithState();
const history = useHistory(); const history = useHistory();
const location = useLocation(); const location = useLocation();
const commentsPagePath = useCommentsPagePath(); const dispatch = useDispatch();
const { const editorRef = useRef(null);
courseId, const { courseId, postId } = useParams();
postId, const { authenticatedUser } = useContext(AppContext);
} = useParams();
const { category, enableInContextSidebar } = useContext(DiscussionContext); const { category, enableInContextSidebar } = useContext(DiscussionContext);
const topicId = useCurrentDiscussionTopic(); const topicId = useCurrentDiscussionTopic();
const commentsPagePath = useCommentsPagePath();
const [submitting, dispatchSubmit] = useDispatchWithState();
const enableInContext = useSelector(selectEnableInContext); const enableInContext = useSelector(selectEnableInContext);
const nonCoursewareTopics = useSelector(enableInContext ? inContextNonCourseware : selectNonCoursewareTopics); const nonCoursewareTopics = useSelector(enableInContext ? inContextNonCourseware : selectNonCoursewareTopics);
const nonCoursewareIds = useSelector(enableInContext ? inContextCoursewareIds : selectNonCoursewareIds); const nonCoursewareIds = useSelector(enableInContext ? inContextCoursewareIds : selectNonCoursewareIds);
@@ -114,6 +77,7 @@ function PostEditor({
const { reasonCodesEnabled, editReasons } = useSelector(selectModerationSettings); const { reasonCodesEnabled, editReasons } = useSelector(selectModerationSettings);
const userIsStaff = useSelector(selectUserIsStaff); const userIsStaff = useSelector(selectUserIsStaff);
const archivedTopics = useSelector(selectArchivedTopics); const archivedTopics = useSelector(selectArchivedTopics);
const postEditorId = `post-editor-${editExisting ? postId : 'new'}`;
const canDisplayEditReason = (reasonCodesEnabled && editExisting const canDisplayEditReason = (reasonCodesEnabled && editExisting
&& (userHasModerationPrivileges || userIsGroupTa || userIsStaff) && (userHasModerationPrivileges || userIsGroupTa || userIsStaff)
@@ -124,7 +88,7 @@ function PostEditor({
editReasonCode: Yup.string().required(intl.formatMessage(messages.editReasonCodeError)), editReasonCode: Yup.string().required(intl.formatMessage(messages.editReasonCodeError)),
}; };
const canSelectCohort = (tId) => { const canSelectCohort = useCallback((tId) => {
// If the user isn't privileged, they can't edit the cohort. // If the user isn't privileged, they can't edit the cohort.
// If the topic is being edited the cohort can't be changed. // If the topic is being edited the cohort can't be changed.
if (!userHasModerationPrivileges) { if (!userHasModerationPrivileges) {
@@ -135,7 +99,7 @@ function PostEditor({
} }
const isCohorting = settings.alwaysDivideInlineDiscussions || settings.dividedInlineDiscussions.includes(tId); const isCohorting = settings.alwaysDivideInlineDiscussions || settings.dividedInlineDiscussions.includes(tId);
return isCohorting; return isCohorting;
}; }, [nonCoursewareIds, settings, userHasModerationPrivileges]);
const initialValues = { const initialValues = {
postType: post?.type || 'discussion', postType: post?.type || 'discussion',
@@ -145,11 +109,13 @@ function PostEditor({
follow: isEmpty(post?.following) ? true : post?.following, follow: isEmpty(post?.following) ? true : post?.following,
anonymous: allowAnonymous ? false : undefined, anonymous: allowAnonymous ? false : undefined,
anonymousToPeers: allowAnonymousToPeers ? false : undefined, anonymousToPeers: allowAnonymousToPeers ? false : undefined,
editReasonCode: post?.lastEdit?.reasonCode || (userIsStaff && canDisplayEditReason ? 'violates-guidelines' : undefined),
cohort: post?.cohort || 'default', cohort: post?.cohort || 'default',
editReasonCode: post?.lastEdit?.reasonCode || (
userIsStaff && canDisplayEditReason ? 'violates-guidelines' : undefined
),
}; };
const hideEditor = (resetForm) => { const hideEditor = useCallback((resetForm) => {
resetForm({ values: initialValues }); resetForm({ values: initialValues });
if (editExisting) { if (editExisting) {
const newLocation = discussionsPath(commentsPagePath, { const newLocation = discussionsPath(commentsPagePath, {
@@ -162,10 +128,14 @@ function PostEditor({
history.push(newLocation); history.push(newLocation);
} }
dispatch(hidePostEditor()); dispatch(hidePostEditor());
}; }, [postId, topicId, post?.author, category, editExisting, commentsPagePath, location]);
// null stands for no cohort restriction ("All learners" option) // null stands for no cohort restriction ("All learners" option)
const selectedCohort = (cohort) => (cohort === 'default' ? null : cohort); const selectedCohort = useCallback((cohort) => (
const submitForm = async (values, { resetForm }) => { cohort === 'default' ? null : cohort),
[]);
const submitForm = useCallback(async (values, { resetForm }) => {
if (editExisting) { if (editExisting) {
await dispatchSubmit(updateExistingThread(postId, { await dispatchSubmit(updateExistingThread(postId, {
topicId: values.topic, topicId: values.topic,
@@ -195,7 +165,10 @@ function PostEditor({
editorRef.current.plugins.autosave.removeDraft(); editorRef.current.plugins.autosave.removeDraft();
} }
hideEditor(resetForm); hideEditor(resetForm);
}; }, [
allowAnonymous, allowAnonymousToPeers, canSelectCohort, editExisting,
enableInContextSidebar, hideEditor, postId, selectedCohort, topicId,
]);
useEffect(() => { useEffect(() => {
if (userHasModerationPrivileges && isEmpty(cohorts)) { if (userHasModerationPrivileges && isEmpty(cohorts)) {
@@ -246,8 +219,6 @@ function PostEditor({
...editReasonCodeValidation, ...editReasonCodeValidation,
}); });
const postEditorId = `post-editor-${editExisting ? postId : 'new'}`;
const handleInContextSelectLabel = (section, subsection) => ( const handleInContextSelectLabel = (section, subsection) => (
`${section.displayName} / ${subsection.displayName}` || intl.formatMessage(messages.unnamedTopics) `${section.displayName} / ${subsection.displayName}` || intl.formatMessage(messages.unnamedTopics)
); );
@@ -258,66 +229,65 @@ function PostEditor({
initialValues={initialValues} initialValues={initialValues}
validationSchema={validationSchema} validationSchema={validationSchema}
onSubmit={submitForm} onSubmit={submitForm}
>{ >{({
({ values,
values, errors,
errors, touched,
touched, handleSubmit,
handleSubmit, handleBlur,
handleBlur, handleChange,
handleChange, resetForm,
resetForm, }) => (
}) => ( <Form className="m-4 card p-4 post-form" onSubmit={handleSubmit}>
<Form className="m-4 card p-4 post-form" onSubmit={handleSubmit}> <h4 className="mb-4 font-style font-size-16" style={{ lineHeight: '16px' }}>
<h4 className="mb-4 font-style font-size-16" style={{ lineHeight: '16px' }}> {editExisting
{editExisting ? intl.formatMessage(messages.editPostHeading)
? intl.formatMessage(messages.editPostHeading) : intl.formatMessage(messages.addPostHeading)}
: intl.formatMessage(messages.addPostHeading)} </h4>
</h4> <Form.RadioSet
<Form.RadioSet name="postType"
name="postType" className="d-flex flex-row flex-wrap"
className="d-flex flex-row flex-wrap" value={values.postType}
value={values.postType} onChange={handleChange}
onChange={handleChange} onBlur={handleBlur}
onBlur={handleBlur} aria-label={intl.formatMessage(messages.postTitle)}
aria-label={intl.formatMessage(messages.postTitle)} >
> <PostTypeCard
<DiscussionPostType value="discussion"
value="discussion" selected={values.postType === 'discussion'}
selected={values.postType === 'discussion'} type={intl.formatMessage(messages.discussionType)}
type={intl.formatMessage(messages.discussionType)} icon={<Post />}
icon={<Post />} />
/> <PostTypeCard
<DiscussionPostType value="question"
value="question" selected={values.postType === 'question'}
selected={values.postType === 'question'} type={intl.formatMessage(messages.questionType)}
type={intl.formatMessage(messages.questionType)} icon={<Help />}
icon={<Help />} />
/> </Form.RadioSet>
</Form.RadioSet> <div className="d-flex flex-row my-4.5 justify-content-between">
<div className="d-flex flex-row my-4.5 justify-content-between"> <Form.Group className="w-100 m-0">
<Form.Group className="w-100 m-0"> <Form.Control
<Form.Control className="m-0"
className="m-0" name="topic"
name="topic" as="select"
as="select" value={values.topic}
value={values.topic} onChange={handleChange}
onChange={handleChange} onBlur={handleBlur}
onBlur={handleBlur} aria-describedby="topicAreaInput"
aria-describedby="topicAreaInput" floatingLabel={intl.formatMessage(messages.topicArea)}
floatingLabel={intl.formatMessage(messages.topicArea)} disabled={enableInContextSidebar}
disabled={enableInContextSidebar} >
> {nonCoursewareTopics.map(topic => (
{nonCoursewareTopics.map(topic => ( <option
<option key={topic.id}
key={topic.id} value={topic.id}
value={topic.id} >{topic.name || intl.formatMessage(messages.unnamedSubTopics)}
>{topic.name || intl.formatMessage(messages.unnamedSubTopics)} </option>
</option> ))}
))} {enableInContext ? (
{enableInContext ? ( <>
<> {coursewareTopics?.map(section => (
{coursewareTopics?.map(section => (
section?.children?.map(subsection => ( section?.children?.map(subsection => (
<optgroup <optgroup
label={handleInContextSelectLabel(section, subsection)} label={handleInContextSelectLabel(section, subsection)}
@@ -330,177 +300,172 @@ function PostEditor({
))} ))}
</optgroup> </optgroup>
)) ))
))}
{(userIsStaff || userIsGroupTa || userHasModerationPrivileges) && (
<optgroup label={intl.formatMessage(messages.archivedTopics)}>
{archivedTopics.map(topic => (
<option key={topic.id} value={topic.id}>
{topic.name || intl.formatMessage(messages.unnamedSubTopics)}
</option>
))} ))}
{(userIsStaff || userIsGroupTa || userHasModerationPrivileges) && ( </optgroup>
<optgroup label={intl.formatMessage(messages.archivedTopics)}> )}
{archivedTopics.map(topic => ( </>
<option key={topic.id} value={topic.id}> ) : (
{topic.name || intl.formatMessage(messages.unnamedSubTopics)} coursewareTopics.map(categoryObj => (
</option> <optgroup
))} label={categoryObj.name || intl.formatMessage(messages.unnamedTopics)}
</optgroup> key={categoryObj.id}
)} >
</> {categoryObj.topics.map(subtopic => (
) : ( <option key={subtopic.id} value={subtopic.id}>
coursewareTopics.map(categoryObj => ( {subtopic.name || intl.formatMessage(messages.unnamedSubTopics)}
<optgroup </option>
label={categoryObj.name || intl.formatMessage(messages.unnamedTopics)} ))}
key={categoryObj.id} </optgroup>
> ))
{categoryObj.topics.map(subtopic => ( )}
<option key={subtopic.id} value={subtopic.id}> </Form.Control>
{subtopic.name || intl.formatMessage(messages.unnamedSubTopics)} </Form.Group>
</option> {canSelectCohort(values.topic) && (
))} <Form.Group className="w-100 ml-3 mb-0">
</optgroup> <Form.Control
)) className="m-0"
)} name="cohort"
</Form.Control> as="select"
</Form.Group> value={values.cohort}
{canSelectCohort(values.topic) && ( onChange={handleChange}
<Form.Group className="w-100 ml-3 mb-0"> onBlur={handleBlur}
<Form.Control aria-describedby="cohortAreaInput"
className="m-0" floatingLabel={intl.formatMessage(messages.cohortVisibility)}
name="cohort"
as="select"
value={values.cohort}
onChange={handleChange}
onBlur={handleBlur}
aria-describedby="cohortAreaInput"
floatingLabel={intl.formatMessage(messages.cohortVisibility)}
>
<option value="default">{intl.formatMessage(messages.cohortVisibilityAllLearners)}</option>
{cohorts.map(cohort => (
<option key={cohort.id} value={cohort.id}>{cohort.name}</option>
))}
</Form.Control>
</Form.Group>
)}
</div>
<div className="d-flex flex-row mb-4.5 justify-content-between">
<Form.Group
className="w-100 m-0"
isInvalid={isFormikFieldInvalid('title', {
errors,
touched,
})}
> >
<Form.Control <option value="default">{intl.formatMessage(messages.cohortVisibilityAllLearners)}</option>
className="m-0" {cohorts.map(cohort => (
name="title" <option key={cohort.id} value={cohort.id}>{cohort.name}</option>
type="text" ))}
onChange={handleChange} </Form.Control>
onBlur={handleBlur} </Form.Group>
aria-describedby="titleInput" )}
floatingLabel={intl.formatMessage(messages.postTitle)} </div>
value={values.title} <div className="d-flex flex-row mb-4.5 justify-content-between">
/> <Form.Group
<FormikErrorFeedback name="title" /> className="w-100 m-0"
</Form.Group> isInvalid={isFormikFieldInvalid('title', {
{canDisplayEditReason && ( errors,
<Form.Group touched,
className="w-100 ml-4 mb-0" })}
isInvalid={isFormikFieldInvalid('editReasonCode', { >
errors, <Form.Control
touched, className="m-0"
})} name="title"
> type="text"
<Form.Control onChange={handleChange}
name="editReasonCode" onBlur={handleBlur}
className="m-0" aria-describedby="titleInput"
as="select" floatingLabel={intl.formatMessage(messages.postTitle)}
value={values.editReasonCode} value={values.title}
onChange={handleChange} />
onBlur={handleBlur} <FormikErrorFeedback name="title" />
aria-describedby="editReasonCodeInput" </Form.Group>
floatingLabel={intl.formatMessage(messages.editReasonCode)} {canDisplayEditReason && (
> <Form.Group
<option key="empty" value="">---</option> className="w-100 ml-4 mb-0"
{editReasons.map(({ code, label }) => ( isInvalid={isFormikFieldInvalid('editReasonCode', {
<option key={code} value={code}>{label}</option> errors,
))} touched,
</Form.Control> })}
<FormikErrorFeedback name="editReasonCode" /> >
</Form.Group> <Form.Control
)} name="editReasonCode"
</div> className="m-0"
<div className="mb-3"> as="select"
<TinyMCEEditor value={values.editReasonCode}
onInit={ onChange={handleChange}
onBlur={handleBlur}
aria-describedby="editReasonCodeInput"
floatingLabel={intl.formatMessage(messages.editReasonCode)}
>
<option key="empty" value="">---</option>
{editReasons.map(({ code, label }) => (
<option key={code} value={code}>{label}</option>
))}
</Form.Control>
<FormikErrorFeedback name="editReasonCode" />
</Form.Group>
)}
</div>
<div className="mb-3">
<TinyMCEEditor
onInit={
/* istanbul ignore next: TinyMCE is mocked so this cannot be easily tested */ /* istanbul ignore next: TinyMCE is mocked so this cannot be easily tested */
(_, editor) => { (_, editor) => {
editorRef.current = editor; editorRef.current = editor;
} }
} }
id={postEditorId} id={postEditorId}
value={values.comment} value={values.comment}
onEditorChange={formikCompatibleHandler(handleChange, 'comment')} onEditorChange={formikCompatibleHandler(handleChange, 'comment')}
onBlur={formikCompatibleHandler(handleBlur, 'comment')} onBlur={formikCompatibleHandler(handleBlur, 'comment')}
/> />
<FormikErrorFeedback name="comment" /> <FormikErrorFeedback name="comment" />
</div> </div>
<PostPreviewPanel htmlNode={values.comment} isPost editExisting={editExisting} />
<PostPreviewPanel htmlNode={values.comment} isPost editExisting={editExisting} /> <div className="d-flex flex-row mt-n4 w-75 text-primary font-style">
{!editExisting && (
<div className="d-flex flex-row mt-n4 w-75 text-primary font-style"> <>
{!editExisting && ( <Form.Group>
<> <Form.Checkbox
<Form.Group> name="follow"
<Form.Checkbox checked={values.follow}
name="follow" onChange={handleChange}
checked={values.follow} onBlur={handleBlur}
onChange={handleChange} className="mr-4.5"
onBlur={handleBlur} >
className="mr-4.5" <span className="font-size-14">
> {intl.formatMessage(messages.followPost)}
<span className="font-size-14"> </span>
{intl.formatMessage(messages.followPost)} </Form.Checkbox>
</span> </Form.Group>
</Form.Checkbox> {allowAnonymousToPeers && (
</Form.Group> <Form.Group>
{allowAnonymousToPeers && ( <Form.Checkbox
<Form.Group> name="anonymousToPeers"
<Form.Checkbox checked={values.anonymousToPeers}
name="anonymousToPeers" onChange={handleChange}
checked={values.anonymousToPeers} onBlur={handleBlur}
onChange={handleChange} >
onBlur={handleBlur} <span className="font-size-14">
> {intl.formatMessage(messages.anonymousToPeersPost)}
<span className="font-size-14"> </span>
{intl.formatMessage(messages.anonymousToPeersPost)} </Form.Checkbox>
</span> </Form.Group>
</Form.Checkbox>
</Form.Group>
)}
</>
)} )}
</div> </>
)}
<div className="d-flex justify-content-end"> </div>
<Button <div className="d-flex justify-content-end">
variant="outline-primary" <Button
onClick={() => hideEditor(resetForm)} variant="outline-primary"
> onClick={() => hideEditor(resetForm)}
{intl.formatMessage(messages.cancel)} >
</Button> {intl.formatMessage(messages.cancel)}
<StatefulButton </Button>
labels={{ <StatefulButton
default: intl.formatMessage(messages.submit), labels={{
pending: intl.formatMessage(messages.submitting), default: intl.formatMessage(messages.submit),
}} pending: intl.formatMessage(messages.submitting),
state={submitting ? 'pending' : 'default'} }}
className="ml-2" state={submitting ? 'pending' : 'default'}
variant="primary" className="ml-2"
onClick={handleSubmit} variant="primary"
/> onClick={handleSubmit}
</div> />
</Form> </div>
) </Form>
} )}
</Formik> </Formik>
); );
} };
PostEditor.propTypes = { PostEditor.propTypes = {
editExisting: PropTypes.bool, editExisting: PropTypes.bool,
@@ -510,4 +475,4 @@ PostEditor.defaultProps = {
editExisting: false, editExisting: false,
}; };
export default PostEditor; export default React.memo(PostEditor);

View File

@@ -0,0 +1,44 @@
import React, { useContext } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { Card, Form } from '@edx/paragon';
import { DiscussionContext } from '../../common/context';
const PostTypeCard = ({
value,
type,
selected,
icon,
}) => {
const { enableInContextSidebar } = useContext(DiscussionContext);
// Need to use regular label since Form.Label doesn't support overriding htmlFor
return (
<label htmlFor={`post-type-${value}`} className="d-flex p-0 my-0 mr-3">
<Form.Radio value={value} id={`post-type-${value}`} className="sr-only">{type}</Form.Radio>
<Card
className={classNames('border-2 shadow-none', {
'border-primary': selected,
'border-light-400': !selected,
})}
style={{ cursor: 'pointer', width: `${enableInContextSidebar ? '10.021rem' : '14.25rem'}` }}
>
<Card.Section className="px-4 py-3 d-flex flex-column align-items-center">
<span className="text-primary-300 mb-0.5">{icon}</span>
<span className="text-gray-700">{type}</span>
</Card.Section>
</Card>
</label>
);
};
PostTypeCard.propTypes = {
value: PropTypes.string.isRequired,
type: PropTypes.string.isRequired,
selected: PropTypes.bool.isRequired,
icon: PropTypes.element.isRequired,
};
export default React.memo(PostTypeCard);

View File

@@ -1,5 +1,5 @@
import React, { import React, {
useContext, useEffect, useMemo, useState, useCallback, useContext, useEffect, useMemo, useState,
} from 'react'; } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
@@ -9,7 +9,7 @@ import { useDispatch, useSelector } from 'react-redux';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { sendTrackEvent } from '@edx/frontend-platform/analytics'; import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { import {
Collapsible, Form, Icon, Spinner, Collapsible, Form, Icon, Spinner,
} from '@edx/paragon'; } from '@edx/paragon';
@@ -29,7 +29,7 @@ import {
import { selectThreadFilters, selectThreadSorting } from '../data/selectors'; import { selectThreadFilters, selectThreadSorting } from '../data/selectors';
import messages from './messages'; import messages from './messages';
export const ActionItem = ({ export const ActionItem = React.memo(({
id, id,
label, label,
value, value,
@@ -52,7 +52,7 @@ export const ActionItem = ({
{label} {label}
</span> </span>
</label> </label>
); ));
ActionItem.propTypes = { ActionItem.propTypes = {
id: PropTypes.string.isRequired, id: PropTypes.string.isRequired,
@@ -61,9 +61,8 @@ ActionItem.propTypes = {
selected: PropTypes.string.isRequired, selected: PropTypes.string.isRequired,
}; };
function PostFilterBar({ const PostFilterBar = () => {
intl, const intl = useIntl();
}) {
const dispatch = useDispatch(); const dispatch = useDispatch();
const { courseId } = useParams(); const { courseId } = useParams();
const { page } = useContext(DiscussionContext); const { page } = useContext(DiscussionContext);
@@ -75,11 +74,13 @@ function PostFilterBar({
const cohorts = useSelector(selectCourseCohorts); const cohorts = useSelector(selectCourseCohorts);
const [isOpen, setOpen] = useState(false); const [isOpen, setOpen] = useState(false);
const selectedCohort = useMemo(() => cohorts.find(cohort => ( const selectedCohort = useMemo(() => (
toString(cohort.id) === currentFilters.cohort)), cohorts.find(cohort => (
[currentFilters.cohort]); toString(cohort.id) === currentFilters.cohort
))
), [cohorts, currentFilters.cohort]);
const handleSortFilterChange = (event) => { const handleSortFilterChange = useCallback((event) => {
const currentType = currentFilters.postType; const currentType = currentFilters.postType;
const currentStatus = currentFilters.status; const currentStatus = currentFilters.status;
const { const {
@@ -93,6 +94,7 @@ function PostFilterBar({
cohortFilter: selectedCohort, cohortFilter: selectedCohort,
triggeredBy: name, triggeredBy: name,
}; };
if (name === 'type') { if (name === 'type') {
dispatch(setPostsTypeFilter(value)); dispatch(setPostsTypeFilter(value));
if ( if (
@@ -103,6 +105,7 @@ function PostFilterBar({
} }
filterContentEventProperties.threadTypeFilter = value; filterContentEventProperties.threadTypeFilter = value;
} }
if (name === 'status') { if (name === 'status') {
dispatch(setStatusFilter(value)); dispatch(setStatusFilter(value));
if (value === PostsStatusFilter.UNANSWERED && currentType !== ThreadType.QUESTION) { if (value === PostsStatusFilter.UNANSWERED && currentType !== ThreadType.QUESTION) {
@@ -115,16 +118,23 @@ function PostFilterBar({
} }
filterContentEventProperties.statusFilter = value; filterContentEventProperties.statusFilter = value;
} }
if (name === 'sort') { if (name === 'sort') {
dispatch(setSortedBy(value)); dispatch(setSortedBy(value));
filterContentEventProperties.sortFilter = value; filterContentEventProperties.sortFilter = value;
} }
if (name === 'cohort') { if (name === 'cohort') {
dispatch(setCohortFilter(value)); dispatch(setCohortFilter(value));
filterContentEventProperties.cohortFilter = value; filterContentEventProperties.cohortFilter = value;
} }
sendTrackEvent('edx.forum.filter.content', filterContentEventProperties); sendTrackEvent('edx.forum.filter.content', filterContentEventProperties);
}; }, [currentFilters, currentSorting, dispatch, selectedCohort]);
const handleToggle = useCallback(() => {
setOpen(!isOpen);
}, [isOpen]);
useEffect(() => { useEffect(() => {
if (userHasModerationPrivileges && isEmpty(cohorts)) { if (userHasModerationPrivileges && isEmpty(cohorts)) {
@@ -132,10 +142,48 @@ function PostFilterBar({
} }
}, [courseId, userHasModerationPrivileges]); }, [courseId, userHasModerationPrivileges]);
const renderCohortFilter = useMemo(() => (
userHasModerationPrivileges && (
<>
<div className="border-bottom my-2" />
{status === RequestStatus.IN_PROGRESS ? (
<div className="d-flex justify-content-center p-4">
<Spinner animation="border" variant="primary" size="lg" />
</div>
) : (
<div className="d-flex flex-row pt-2">
<Form.RadioSet
name="cohort"
className="d-flex flex-column list-group list-group-flush w-100"
value={currentFilters.cohort}
onChange={handleSortFilterChange}
>
<ActionItem
id="all-groups"
label="All groups"
value=""
selected={currentFilters.cohort}
/>
{cohorts.map(cohort => (
<ActionItem
key={cohort.id}
id={toString(cohort.id)}
label={capitalize(cohort.name)}
value={toString(cohort.id)}
selected={currentFilters.cohort}
/>
))}
</Form.RadioSet>
</div>
)}
</>
)
), [cohorts, currentFilters.cohort, handleSortFilterChange, status, userHasModerationPrivileges]);
return ( return (
<Collapsible.Advanced <Collapsible.Advanced
open={isOpen} open={isOpen}
onToggle={() => setOpen(!isOpen)} onToggle={handleToggle}
className="filter-bar collapsible-card-lg border-0" className="filter-bar collapsible-card-lg border-0"
> >
<Collapsible.Trigger className="collapsible-trigger border-0"> <Collapsible.Trigger className="collapsible-trigger border-0">
@@ -157,7 +205,6 @@ function PostFilterBar({
<Icon src={Tune} /> <Icon src={Tune} />
</Collapsible.Visible> </Collapsible.Visible>
</span> </span>
</Collapsible.Trigger> </Collapsible.Trigger>
<Collapsible.Body className="collapsible-body px-4 pb-3 pt-0"> <Collapsible.Body className="collapsible-body px-4 pb-3 pt-0">
<Form> <Form>
@@ -260,49 +307,11 @@ function PostFilterBar({
/> />
</Form.RadioSet> </Form.RadioSet>
</div> </div>
{userHasModerationPrivileges && ( {renderCohortFilter}
<>
<div className="border-bottom my-2" />
{status === RequestStatus.IN_PROGRESS ? (
<div className="d-flex justify-content-center p-4">
<Spinner animation="border" variant="primary" size="lg" />
</div>
) : (
<div className="d-flex flex-row pt-2">
<Form.RadioSet
name="cohort"
className="d-flex flex-column list-group list-group-flush w-100"
value={currentFilters.cohort}
onChange={handleSortFilterChange}
>
<ActionItem
id="all-groups"
label="All groups"
value=""
selected={currentFilters.cohort}
/>
{cohorts.map(cohort => (
<ActionItem
key={cohort.id}
id={cohort.id}
label={capitalize(cohort.name)}
value={toString(cohort.id)}
selected={currentFilters.cohort}
/>
))}
</Form.RadioSet>
</div>
)}
</>
)}
</Form> </Form>
</Collapsible.Body> </Collapsible.Body>
</Collapsible.Advanced> </Collapsible.Advanced>
); );
}
PostFilterBar.propTypes = {
intl: intlShape.isRequired,
}; };
export default injectIntl(PostFilterBar); export default React.memo(PostFilterBar);

View File

@@ -1,9 +1,11 @@
import React, { useEffect, useRef, useState } from 'react'; import React, {
useCallback, useEffect, useRef, useState,
} from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { import {
ActionRow, ActionRow,
Button, Button,
@@ -14,24 +16,23 @@ import {
import { selectModerationSettings } from '../../data/selectors'; import { selectModerationSettings } from '../../data/selectors';
import messages from './messages'; import messages from './messages';
function ClosePostReasonModal({ const ClosePostReasonModal = ({
intl,
isOpen, isOpen,
onCancel, onCancel,
onConfirm, onConfirm,
}) { }) => {
const intl = useIntl();
const scrollTo = useRef(null); const scrollTo = useRef(null);
const [reasonCode, setReasonCode] = useState(null); const [reasonCode, setReasonCode] = useState(null);
const { postCloseReasons } = useSelector(selectModerationSettings); const { postCloseReasons } = useSelector(selectModerationSettings);
const onChange = event => { const onChange = useCallback(event => {
if (event.target.value) { if (event.target.value) {
setReasonCode(event.target.value); setReasonCode(event.target.value);
} else { } else {
setReasonCode(null); setReasonCode(null);
} }
}; }, []);
useEffect(() => { useEffect(() => {
/* istanbul ignore if: This API is not available in the test environment. */ /* istanbul ignore if: This API is not available in the test environment. */
@@ -87,13 +88,12 @@ function ClosePostReasonModal({
</ModalDialog.Footer> </ModalDialog.Footer>
</ModalDialog> </ModalDialog>
); );
} };
ClosePostReasonModal.propTypes = { ClosePostReasonModal.propTypes = {
intl: intlShape.isRequired,
isOpen: PropTypes.bool.isRequired, isOpen: PropTypes.bool.isRequired,
onCancel: PropTypes.func.isRequired, onCancel: PropTypes.func.isRequired,
onConfirm: PropTypes.func.isRequired, onConfirm: PropTypes.func.isRequired,
}; };
export default injectIntl(ClosePostReasonModal); export default React.memo(ClosePostReasonModal);

View File

@@ -1,7 +1,7 @@
import React from 'react'; import React, { useCallback } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { import {
Icon, IconButton, OverlayTrigger, Tooltip, Icon, IconButton, OverlayTrigger, Tooltip,
} from '@edx/paragon'; } from '@edx/paragon';
@@ -9,19 +9,16 @@ import {
import { ThumbUpFilled, ThumbUpOutline } from '../../../components/icons'; import { ThumbUpFilled, ThumbUpOutline } from '../../../components/icons';
import messages from './messages'; import messages from './messages';
function LikeButton({ const LikeButton = ({ count, onClick, voted }) => {
count, const intl = useIntl();
intl,
onClick, const handleClick = useCallback((e) => {
voted,
}) {
const handleClick = (e) => {
e.preventDefault(); e.preventDefault();
if (onClick) { if (onClick) {
onClick(); onClick();
} }
return false; return false;
}; }, []);
return ( return (
<div className="d-flex align-items-center mr-36px text-primary-500"> <div className="d-flex align-items-center mr-36px text-primary-500">
@@ -47,11 +44,10 @@ function LikeButton({
</div> </div>
); );
} };
LikeButton.propTypes = { LikeButton.propTypes = {
count: PropTypes.number.isRequired, count: PropTypes.number.isRequired,
intl: intlShape.isRequired,
onClick: PropTypes.func, onClick: PropTypes.func,
voted: PropTypes.bool, voted: PropTypes.bool,
}; };
@@ -61,4 +57,4 @@ LikeButton.defaultProps = {
onClick: undefined, onClick: undefined,
}; };
export default injectIntl(LikeButton); export default React.memo(LikeButton);

View File

@@ -2,11 +2,12 @@ import React, { useCallback, useContext, useMemo } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import classNames from 'classnames'; import classNames from 'classnames';
import { toString } from 'lodash';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { useHistory, useLocation } from 'react-router-dom'; import { useHistory, useLocation } from 'react-router-dom';
import { getConfig } from '@edx/frontend-platform'; import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { Hyperlink, useToggle } from '@edx/paragon'; import { Hyperlink, useToggle } from '@edx/paragon';
import HTMLLoader from '../../../components/HTMLLoader'; import HTMLLoader from '../../../components/HTMLLoader';
@@ -15,102 +16,119 @@ import { selectorForUnitSubsection, selectTopicContext } from '../../../data/sel
import { AlertBanner, Confirmation } from '../../common'; import { AlertBanner, Confirmation } from '../../common';
import { DiscussionContext } from '../../common/context'; import { DiscussionContext } from '../../common/context';
import HoverCard from '../../common/HoverCard'; import HoverCard from '../../common/HoverCard';
import { ContentTypes } from '../../data/constants';
import { selectModerationSettings, selectUserHasModerationPrivileges } from '../../data/selectors'; import { selectModerationSettings, selectUserHasModerationPrivileges } from '../../data/selectors';
import { selectTopic } from '../../topics/data/selectors'; import { selectTopic } from '../../topics/data/selectors';
import { selectThread } from '../data/selectors';
import { removeThread, updateExistingThread } from '../data/thunks'; import { removeThread, updateExistingThread } from '../data/thunks';
import ClosePostReasonModal from './ClosePostReasonModal'; import ClosePostReasonModal from './ClosePostReasonModal';
import messages from './messages'; import messages from './messages';
import PostFooter from './PostFooter'; import PostFooter from './PostFooter';
import PostHeader from './PostHeader'; import PostHeader from './PostHeader';
import { postShape } from './proptypes';
function Post({ const Post = ({ handleAddResponseButton }) => {
post, const { enableInContextSidebar, postId } = useContext(DiscussionContext);
intl, const {
handleAddResponseButton, topicId, abuseFlagged, closed, pinned, voted, hasEndorsed, following, closedBy, voteCount, groupId, groupName,
}) { closeReason, authorLabel, type: postType, author, title, createdAt, renderedBody, lastEdit, editByLabel,
closedByLabel,
} = useSelector(selectThread(postId));
const intl = useIntl();
const location = useLocation(); const location = useLocation();
const history = useHistory(); const history = useHistory();
const dispatch = useDispatch(); const dispatch = useDispatch();
const { enableInContextSidebar } = useContext(DiscussionContext);
const courseId = useSelector((state) => state.config.id); const courseId = useSelector((state) => state.config.id);
const topic = useSelector(selectTopic(post.topicId)); const topic = useSelector(selectTopic(topicId));
const getTopicSubsection = useSelector(selectorForUnitSubsection); const getTopicSubsection = useSelector(selectorForUnitSubsection);
const topicContext = useSelector(selectTopicContext(post.topicId)); const topicContext = useSelector(selectTopicContext(topicId));
const { reasonCodesEnabled } = useSelector(selectModerationSettings); const { reasonCodesEnabled } = useSelector(selectModerationSettings);
const [isDeleting, showDeleteConfirmation, hideDeleteConfirmation] = useToggle(false); const [isDeleting, showDeleteConfirmation, hideDeleteConfirmation] = useToggle(false);
const [isReporting, showReportConfirmation, hideReportConfirmation] = useToggle(false); const [isReporting, showReportConfirmation, hideReportConfirmation] = useToggle(false);
const [isClosing, showClosePostModal, hideClosePostModal] = useToggle(false); const [isClosing, showClosePostModal, hideClosePostModal] = useToggle(false);
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges); const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
const displayPostFooter = post.following || post.voteCount || post.closed const displayPostFooter = following || voteCount || closed || (groupId && userHasModerationPrivileges);
|| (post.groupId && userHasModerationPrivileges);
const handleAbusedFlag = useCallback(() => { const handleDeleteConfirmation = useCallback(async () => {
if (post.abuseFlagged) { await dispatch(removeThread(postId));
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({ history.push({
pathname: '.', pathname: '.',
search: enableInContextSidebar && '?inContextSidebar', search: enableInContextSidebar && '?inContextSidebar',
}); });
hideDeleteConfirmation(); hideDeleteConfirmation();
}; }, [enableInContextSidebar, postId, hideDeleteConfirmation]);
const handleReportConfirmation = () => { const handleReportConfirmation = useCallback(() => {
dispatch(updateExistingThread(post.id, { flagged: !post.abuseFlagged })); dispatch(updateExistingThread(postId, { flagged: !abuseFlagged }));
hideReportConfirmation(); 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(() => ({ const actionHandlers = useMemo(() => ({
[ContentActions.EDIT_CONTENT]: () => history.push({ [ContentActions.EDIT_CONTENT]: handlePostContentEdit,
...location,
pathname: `${location.pathname}/edit`,
}),
[ContentActions.DELETE]: showDeleteConfirmation, [ContentActions.DELETE]: showDeleteConfirmation,
[ContentActions.CLOSE]: () => { [ContentActions.CLOSE]: handlePostClose,
if (post.closed) { [ContentActions.COPY_LINK]: handlePostCopyLink,
dispatch(updateExistingThread(post.id, { closed: false })); [ContentActions.PIN]: handlePostPin,
} else if (reasonCodesEnabled) { [ContentActions.REPORT]: handlePostReport,
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(),
}), [ }), [
showDeleteConfirmation, handlePostClose, handlePostContentEdit, handlePostCopyLink, handlePostPin, handlePostReport, showDeleteConfirmation,
history,
location,
post.closed,
post.id,
post.pinned,
reasonCodesEnabled,
dispatch,
showClosePostModal,
courseId,
handleAbusedFlag,
]); ]);
const getTopicCategoryName = topicData => ( const handleClosePostConfirmation = useCallback((closeReasonCode) => {
topicData.usageKey ? getTopicSubsection(topicData.usageKey)?.displayName : topicData.categoryId dispatch(updateExistingThread(postId, { closed: true, closeReasonCode }));
); hideClosePostModal();
}, [postId, hideClosePostModal]);
const getTopicInfo = topicData => ( const handlePostLike = useCallback(() => {
dispatch(updateExistingThread(postId, { voted: !voted }));
}, [postId, voted]);
const handlePostFollow = useCallback(() => {
dispatch(updateExistingThread(postId, { following: !following }));
}, [postId, following]);
const getTopicCategoryName = useCallback(topicData => (
topicData.usageKey ? getTopicSubsection(topicData.usageKey)?.displayName : topicData.categoryId
), [getTopicSubsection]);
const getTopicInfo = useCallback(topicData => (
getTopicCategoryName(topicData) ? `${getTopicCategoryName(topicData)} / ${topicData.name}` : `${topicData.name}` getTopicCategoryName(topicData) ? `${getTopicCategoryName(topicData)} / ${topicData.name}` : `${topicData.name}`
); ), [getTopicCategoryName]);
return ( return (
<div <div
className="d-flex flex-column w-100 mw-100 post-card-comment" className="d-flex flex-column w-100 mw-100 post-card-comment"
data-testid={`post-${post.id}`} data-testid={`post-${postId}`}
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex // eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
tabIndex="0" tabIndex="0"
> >
@@ -123,7 +141,7 @@ function Post({
closeButtonVaraint="tertiary" closeButtonVaraint="tertiary"
confirmButtonText={intl.formatMessage(messages.deleteConfirmationDelete)} confirmButtonText={intl.formatMessage(messages.deleteConfirmationDelete)}
/> />
{!post.abuseFlagged && ( {!abuseFlagged && (
<Confirmation <Confirmation
isOpen={isReporting} isOpen={isReporting}
title={intl.formatMessage(messages.reportPostTitle)} title={intl.formatMessage(messages.reportPostTitle)}
@@ -134,18 +152,39 @@ function Post({
/> />
)} )}
<HoverCard <HoverCard
commentOrPost={post} id={postId}
contentType={ContentTypes.POST}
actionHandlers={actionHandlers} actionHandlers={actionHandlers}
handleResponseCommentButton={handleAddResponseButton} handleResponseCommentButton={handleAddResponseButton}
addResponseCommentButtonMessage={intl.formatMessage(messages.addResponse)} addResponseCommentButtonMessage={intl.formatMessage(messages.addResponse)}
onLike={() => dispatch(updateExistingThread(post.id, { voted: !post.voted }))} onLike={handlePostLike}
onFollow={() => dispatch(updateExistingThread(post.id, { following: !post.following }))} onFollow={handlePostFollow}
isClosedPost={post.closed} voted={voted}
following={following}
/>
<AlertBanner
author={author}
abuseFlagged={abuseFlagged}
lastEdit={lastEdit}
closed={closed}
closedBy={closedBy}
closeReason={closeReason}
editByLabel={editByLabel}
closedByLabel={closedByLabel}
/>
<PostHeader
abuseFlagged={abuseFlagged}
author={author}
authorLabel={authorLabel}
closed={closed}
createdAt={createdAt}
hasEndorsed={hasEndorsed}
lastEdit={lastEdit}
postType={postType}
title={title}
/> />
<AlertBanner content={post} />
<PostHeader post={post} />
<div className="d-flex mt-14px text-break font-style text-primary-500"> <div className="d-flex mt-14px text-break font-style text-primary-500">
<HTMLLoader htmlNode={post.renderedBody} componentId="post" cssClassName="html-loader" testId={post.id} /> <HTMLLoader htmlNode={renderedBody} componentId="post" cssClassName="html-loader" testId={postId} />
</div> </div>
{(topicContext || topic) && ( {(topicContext || topic) && (
<div <div
@@ -153,42 +192,54 @@ function Post({
{ 'w-100': enableInContextSidebar, 'mb-1': !displayPostFooter })} { 'w-100': enableInContextSidebar, 'mb-1': !displayPostFooter })}
style={{ lineHeight: '20px' }} style={{ lineHeight: '20px' }}
> >
<span className="text-gray-500" style={{ lineHeight: '20px' }}>{intl.formatMessage(messages.relatedTo)}{' '}</span> <span className="text-gray-500" style={{ lineHeight: '20px' }}>
{intl.formatMessage(messages.relatedTo)}{' '}
</span>
<Hyperlink <Hyperlink
destination={topicContext ? topicContext.unitLink : `${getConfig().BASE_URL}/${courseId}/topics/${post.topicId}`}
target="_top" target="_top"
destination={topicContext ? (
topicContext.unitLink
) : (
`${getConfig().BASE_URL}/${courseId}/topics/${topicId}`
)}
> >
{(topicContext && !topic) {(topicContext && !topic) ? (
? ( <>
<> <span className="w-auto">{topicContext.chapterName}</span>
<span className="w-auto">{topicContext.chapterName}</span> <span className="mx-1">/</span>
<span className="mx-1">/</span> <span className="w-auto">{topicContext.verticalName}</span>
<span className="w-auto">{topicContext.verticalName}</span> <span className="mx-1">/</span>
<span className="mx-1">/</span> <span className="w-auto">{topicContext.unitName}</span>
<span className="w-auto">{topicContext.unitName}</span> </>
</> ) : (
) getTopicInfo(topic)
: getTopicInfo(topic)} )}
</Hyperlink> </Hyperlink>
</div> </div>
)} )}
{displayPostFooter && <PostFooter post={post} userHasModerationPrivileges={userHasModerationPrivileges} />} {displayPostFooter && (
<PostFooter
id={postId}
voteCount={voteCount}
voted={voted}
following={following}
groupId={toString(groupId)}
groupName={groupName}
closed={closed}
userHasModerationPrivileges={userHasModerationPrivileges}
/>
)}
<ClosePostReasonModal <ClosePostReasonModal
isOpen={isClosing} isOpen={isClosing}
onCancel={hideClosePostModal} onCancel={hideClosePostModal}
onConfirm={closeReasonCode => { onConfirm={handleClosePostConfirmation}
dispatch(updateExistingThread(post.id, { closed: true, closeReasonCode }));
hideClosePostModal();
}}
/> />
</div> </div>
); );
} };
Post.propTypes = { Post.propTypes = {
intl: intlShape.isRequired,
post: postShape.isRequired,
handleAddResponseButton: PropTypes.func.isRequired, handleAddResponseButton: PropTypes.func.isRequired,
}; };
export default injectIntl(Post); export default React.memo(Post);

View File

@@ -1,9 +1,9 @@
import React from 'react'; import React, { useCallback } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { import {
Icon, IconButton, OverlayTrigger, Tooltip, Icon, IconButton, OverlayTrigger, Tooltip,
} from '@edx/paragon'; } from '@edx/paragon';
@@ -13,36 +13,46 @@ import { StarFilled, StarOutline } from '../../../components/icons';
import { updateExistingThread } from '../data/thunks'; import { updateExistingThread } from '../data/thunks';
import LikeButton from './LikeButton'; import LikeButton from './LikeButton';
import messages from './messages'; import messages from './messages';
import { postShape } from './proptypes';
function PostFooter({ const PostFooter = ({
intl, closed,
post, following,
groupId,
groupName,
id,
userHasModerationPrivileges, userHasModerationPrivileges,
}) { voted,
voteCount,
}) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const intl = useIntl();
const handlePostLike = useCallback(() => {
dispatch(updateExistingThread(id, { voted: !voted }));
}, [id, voted]);
return ( return (
<div className="d-flex align-items-center ml-n1.5 mt-10px" style={{ height: '32px' }} data-testid="post-footer"> <div className="d-flex align-items-center ml-n1.5 mt-10px" style={{ height: '32px' }} data-testid="post-footer">
{post.voteCount !== 0 && ( {voteCount !== 0 && (
<LikeButton <LikeButton
count={post.voteCount} count={voteCount}
onClick={() => dispatch(updateExistingThread(post.id, { voted: !post.voted }))} onClick={handlePostLike}
voted={post.voted} voted={voted}
/> />
)} )}
{post.following && ( {following && (
<OverlayTrigger <OverlayTrigger
overlay={( overlay={(
<Tooltip id={`follow-${post.id}-tooltip`}> <Tooltip id={`follow-${id}-tooltip`}>
{intl.formatMessage(post.following ? messages.unFollow : messages.follow)} {intl.formatMessage(following ? messages.unFollow : messages.follow)}
</Tooltip> </Tooltip>
)} )}
> >
<IconButton <IconButton
src={post.following ? StarFilled : StarOutline} src={following ? StarFilled : StarOutline}
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
dispatch(updateExistingThread(post.id, { following: !post.following })); dispatch(updateExistingThread(id, { following: !following }));
return true; return true;
}} }}
iconAs={Icon} iconAs={Icon}
@@ -53,10 +63,10 @@ function PostFooter({
</OverlayTrigger> </OverlayTrigger>
)} )}
<div className="d-flex flex-fill justify-content-end align-items-center"> <div className="d-flex flex-fill justify-content-end align-items-center">
{post.groupId && userHasModerationPrivileges && ( {groupId && userHasModerationPrivileges && (
<OverlayTrigger <OverlayTrigger
overlay={( overlay={(
<Tooltip id={`visibility-${post.id}-tooltip`}>{post.groupName}</Tooltip> <Tooltip id={`visibility-${id}-tooltip`}>{groupName}</Tooltip>
)} )}
> >
<span data-testid="cohort-icon"> <span data-testid="cohort-icon">
@@ -71,36 +81,43 @@ function PostFooter({
</span> </span>
</OverlayTrigger> </OverlayTrigger>
)} )}
{closed && (
{post.closed <OverlayTrigger
&& ( overlay={(
<OverlayTrigger <Tooltip id={`closed-${id}-tooltip`}>
overlay={( {intl.formatMessage(messages.postClosed)}
<Tooltip id={`closed-${post.id}-tooltip`}> </Tooltip>
{intl.formatMessage(messages.postClosed)} )}
</Tooltip> >
)} <Icon
> src={Locked}
<Icon style={{
src={Locked} width: '1rem',
className="text-primary-500" height: '1rem',
style={{ marginLeft: '19.5px',
width: '1rem', }}
height: '1rem', />
marginLeft: '19.5px', </OverlayTrigger>
}} )}
/>
</OverlayTrigger>
)}
</div> </div>
</div> </div>
); );
} };
PostFooter.propTypes = { PostFooter.propTypes = {
intl: intlShape.isRequired, voteCount: PropTypes.number.isRequired,
post: postShape.isRequired, voted: PropTypes.bool.isRequired,
following: PropTypes.bool.isRequired,
id: PropTypes.string.isRequired,
groupId: PropTypes.string,
groupName: PropTypes.string,
closed: PropTypes.bool.isRequired,
userHasModerationPrivileges: PropTypes.bool.isRequired, userHasModerationPrivileges: PropTypes.bool.isRequired,
}; };
export default injectIntl(PostFooter); PostFooter.defaultProps = {
groupId: null,
groupName: null,
};
export default React.memo(PostFooter);

View File

@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
import classNames from 'classnames'; import classNames from 'classnames';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { Avatar, Badge, Icon } from '@edx/paragon'; import { Avatar, Badge, Icon } from '@edx/paragon';
import { Issue, Question } from '../../../components/icons'; import { Issue, Question } from '../../../components/icons';
@@ -11,36 +11,35 @@ import { AvatarOutlineAndLabelColors, ThreadType } from '../../../data/constants
import { AuthorLabel } from '../../common'; import { AuthorLabel } from '../../common';
import { useAlertBannerVisible } from '../../data/hooks'; import { useAlertBannerVisible } from '../../data/hooks';
import messages from './messages'; import messages from './messages';
import { postShape } from './proptypes';
export function PostAvatar({ export const PostAvatar = React.memo(({
post, authorLabel, fromPostLink, read, author, postType, authorLabel, fromPostLink, read,
}) { }) => {
const outlineColor = AvatarOutlineAndLabelColors[authorLabel]; const outlineColor = AvatarOutlineAndLabelColors[authorLabel];
const avatarSize = useMemo(() => { const avatarSize = useMemo(() => {
let size = '2rem'; let size = '2rem';
if (post.type === ThreadType.DISCUSSION && !fromPostLink) { if (postType === ThreadType.DISCUSSION && !fromPostLink) {
size = '2rem'; size = '2rem';
} else if (post.type === ThreadType.QUESTION) { } else if (postType === ThreadType.QUESTION) {
size = '1.5rem'; size = '1.5rem';
} }
return size; return size;
}, [post.type]); }, [postType]);
const avatarSpacing = useMemo(() => { const avatarSpacing = useMemo(() => {
let spacing = 'mr-3 '; let spacing = 'mr-3 ';
if (post.type === ThreadType.DISCUSSION && fromPostLink) { if (postType === ThreadType.DISCUSSION && fromPostLink) {
spacing += 'pt-2 ml-0.5'; spacing += 'pt-2 ml-0.5';
} else if (post.type === ThreadType.DISCUSSION) { } else if (postType === ThreadType.DISCUSSION) {
spacing += 'ml-0.5 mt-0.5'; spacing += 'ml-0.5 mt-0.5';
} }
return spacing; return spacing;
}, [post.type]); }, [postType]);
return ( return (
<div className={avatarSpacing}> <div className={avatarSpacing}>
{post.type === ThreadType.QUESTION && ( {postType === ThreadType.QUESTION && (
<Icon <Icon
src={read ? Issue : Question} src={read ? Issue : Question}
className={classNames('position-absolute bg-white rounded-circle question-icon-size', { className={classNames('position-absolute bg-white rounded-circle question-icon-size', {
@@ -52,21 +51,22 @@ export function PostAvatar({
className={classNames('border-0 mt-1', { className={classNames('border-0 mt-1', {
[`outline-${outlineColor}`]: outlineColor, [`outline-${outlineColor}`]: outlineColor,
'outline-anonymous': !outlineColor, 'outline-anonymous': !outlineColor,
'mt-3 ml-2': post.type === ThreadType.QUESTION && fromPostLink, 'mt-3 ml-2': postType === ThreadType.QUESTION && fromPostLink,
'avarat-img-position mt-17px': post.type === ThreadType.QUESTION, 'avarat-img-position mt-17px': postType === ThreadType.QUESTION,
})} })}
style={{ style={{
height: avatarSize, height: avatarSize,
width: avatarSize, width: avatarSize,
}} }}
alt={post.author} alt={author}
/> />
</div> </div>
); );
} });
PostAvatar.propTypes = { PostAvatar.propTypes = {
post: postShape.isRequired, author: PropTypes.string.isRequired,
postType: PropTypes.string.isRequired,
authorLabel: PropTypes.string, authorLabel: PropTypes.string,
fromPostLink: PropTypes.bool, fromPostLink: PropTypes.bool,
read: PropTypes.bool, read: PropTypes.bool,
@@ -78,65 +78,86 @@ PostAvatar.defaultProps = {
read: false, read: false,
}; };
function PostHeader({ const PostHeader = ({
intl, abuseFlagged,
post, author,
authorLabel,
closed,
createdAt,
hasEndorsed,
lastEdit,
title,
postType,
preview, preview,
}) { }) => {
const showAnsweredBadge = preview && post.hasEndorsed && post.type === ThreadType.QUESTION; const intl = useIntl();
const authorLabelColor = AvatarOutlineAndLabelColors[post.authorLabel]; const showAnsweredBadge = preview && hasEndorsed && postType === ThreadType.QUESTION;
const hasAnyAlert = useAlertBannerVisible(post); const authorLabelColor = AvatarOutlineAndLabelColors[authorLabel];
const hasAnyAlert = useAlertBannerVisible({
author, abuseFlagged, lastEdit, closed,
});
return ( return (
<div className={classNames('d-flex flex-fill mw-100', { 'mt-10px': hasAnyAlert && !preview })}> <div className={classNames('d-flex flex-fill mw-100', { 'mt-10px': hasAnyAlert && !preview })}>
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<PostAvatar post={post} authorLabel={post.authorLabel} /> <PostAvatar postType={postType} author={author} authorLabel={authorLabel} />
</div> </div>
<div className="align-items-center d-flex flex-row"> <div className="align-items-center d-flex flex-row">
<div className="d-flex flex-column justify-content-start mw-100"> <div className="d-flex flex-column justify-content-start mw-100">
{preview {preview ? (
? ( <div className="h4 d-flex align-items-center pb-0 mb-0 flex-fill">
<div className="h4 d-flex align-items-center pb-0 mb-0 flex-fill"> <div className="flex-fill text-truncate" role="heading" aria-level="1">
<div className="flex-fill text-truncate" role="heading" aria-level="1"> {title}
{post.title}
</div>
{showAnsweredBadge
&& <Badge variant="success">{intl.formatMessage(messages.answered)}</Badge>}
</div> </div>
) {showAnsweredBadge
: ( && <Badge variant="success">{intl.formatMessage(messages.answered)}</Badge>}
<h5 </div>
className="mb-0 font-style text-primary-500" ) : (
style={{ lineHeight: '21px' }} <h5
aria-level="1" className="mb-0 font-style text-primary-500"
tabIndex="-1" style={{ lineHeight: '21px' }}
accessKey="h" aria-level="1"
> tabIndex="-1"
{post.title} accessKey="h"
</h5> >
)} {title}
</h5>
)}
<AuthorLabel <AuthorLabel
author={post.author || intl.formatMessage(messages.anonymous)} author={author || intl.formatMessage(messages.anonymous)}
authorLabel={post.authorLabel} authorLabel={authorLabel}
labelColor={authorLabelColor && `text-${authorLabelColor}`} labelColor={authorLabelColor && `text-${authorLabelColor}`}
linkToProfile linkToProfile
postCreatedAt={post.createdAt} postCreatedAt={createdAt}
postOrComment postOrComment
/> />
</div> </div>
</div> </div>
</div> </div>
); );
} };
PostHeader.propTypes = { PostHeader.propTypes = {
intl: intlShape.isRequired,
post: postShape.isRequired,
preview: PropTypes.bool, 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 = { PostHeader.defaultProps = {
authorLabel: null,
preview: false, preview: false,
abuseFlagged: false,
lastEdit: {},
closed: false,
}; };
export default injectIntl(PostHeader); export default React.memo(PostHeader);

View File

@@ -1,8 +1,9 @@
/* eslint-disable react/no-unknown-property */ /* eslint-disable react/no-unknown-property */
import React, { useContext } from 'react'; import React, { useContext, useMemo } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import classNames from 'classnames'; import classNames from 'classnames';
import { useSelector } from 'react-redux';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useIntl } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
@@ -14,37 +15,45 @@ import { AvatarOutlineAndLabelColors, Routes, ThreadType } from '../../../data/c
import AuthorLabel from '../../common/AuthorLabel'; import AuthorLabel from '../../common/AuthorLabel';
import { DiscussionContext } from '../../common/context'; import { DiscussionContext } from '../../common/context';
import { discussionsPath, isPostPreviewAvailable } from '../../utils'; import { discussionsPath, isPostPreviewAvailable } from '../../utils';
import { selectThread } from '../data/selectors';
import messages from './messages'; import messages from './messages';
import { PostAvatar } from './PostHeader'; import { PostAvatar } from './PostHeader';
import PostSummaryFooter from './PostSummaryFooter'; import PostSummaryFooter from './PostSummaryFooter';
import { postShape } from './proptypes';
function PostLink({ const PostLink = ({
post,
isSelected,
showDivider,
idx, idx,
}) { postId,
showDivider,
}) => {
const intl = useIntl(); const intl = useIntl();
const { const {
courseId,
postId: selectedPostId,
page, page,
postId,
enableInContextSidebar, enableInContextSidebar,
category, category,
learnerUsername, learnerUsername,
} = useContext(DiscussionContext); } = 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], { const linkUrl = discussionsPath(Routes.COMMENTS.PAGES[page], {
0: enableInContextSidebar ? 'in-context' : undefined, 0: enableInContextSidebar ? 'in-context' : undefined,
courseId: post.courseId, courseId,
topicId: post.topicId, topicId,
postId: post.id, postId,
category, category,
learnerUsername, learnerUsername,
}); });
const showAnsweredBadge = post.hasEndorsed && post.type === ThreadType.QUESTION; const showAnsweredBadge = hasEndorsed && type === ThreadType.QUESTION;
const authorLabelColor = AvatarOutlineAndLabelColors[post.authorLabel]; const authorLabelColor = AvatarOutlineAndLabelColors[authorLabel];
const canSeeReportedBadge = post.abuseFlagged || post.abuseFlaggedCount; const canSeeReportedBadge = abuseFlagged || abuseFlaggedCount;
const read = post.read || (!post.read && post.commentCount !== post.unreadCommentCount); const isPostRead = read || (!read && commentCount !== unreadCommentCount);
const checkIsSelected = useMemo(() => (
window.location.pathname.includes(postId)),
[window.location.pathname]);
return ( return (
<> <>
@@ -55,19 +64,24 @@ function PostLink({
}) })
} }
to={linkUrl} to={linkUrl}
onClick={() => isSelected(post.id)} aria-current={checkIsSelected ? 'page' : undefined}
aria-current={isSelected(post.id) ? 'page' : undefined}
role="option" role="option"
tabIndex={(isSelected(post.id) || idx === 0) ? 0 : -1} tabIndex={(checkIsSelected || idx === 0) ? 0 : -1}
> >
<div <div
className={ className={
classNames('d-flex flex-row pt-2 pb-2 px-4 border-primary-500 position-relative', classNames('d-flex flex-row pt-2 pb-2 px-4 border-primary-500 position-relative',
{ 'bg-light-300': read }, { 'bg-light-300': isPostRead },
{ 'post-summary-card-selected': post.id === postId }) { 'post-summary-card-selected': id === selectedPostId })
} }
> >
<PostAvatar post={post} authorLabel={post.authorLabel} fromPostLink read={read} /> <PostAvatar
postType={type}
author={author}
authorLabel={authorLabel}
fromPostLink
read={isPostRead}
/>
<div className="d-flex flex-column flex-fill" style={{ minWidth: 0 }}> <div className="d-flex flex-column flex-fill" style={{ minWidth: 0 }}>
<div className="d-flex flex-column justify-content-start mw-100 flex-fill" style={{ marginBottom: '-3px' }}> <div className="d-flex flex-column justify-content-start mw-100 flex-fill" style={{ marginBottom: '-3px' }}>
<div className="d-flex align-items-center pb-0 mb-0 flex-fill font-weight-500"> <div className="d-flex align-items-center pb-0 mb-0 flex-fill font-weight-500">
@@ -78,14 +92,14 @@ function PostLink({
{ 'font-weight-bolder': !read }) { 'font-weight-bolder': !read })
} }
> >
{post.title} {title}
</span> </span>
<span class="align-bottom"> </span> <span class="align-bottom"> </span>
<span <span
class="text-gray-700 font-weight-normal font-size-14 font-style align-bottom" class="text-gray-700 font-weight-normal font-size-14 font-style align-bottom"
> >
{isPostPreviewAvailable(post.previewBody) {isPostPreviewAvailable(previewBody)
? post.previewBody ? previewBody
: intl.formatMessage(messages.postWithoutPreview)} : intl.formatMessage(messages.postWithoutPreview)}
</span> </span>
</Truncate> </Truncate>
@@ -94,7 +108,6 @@ function PostLink({
<span className="sr-only">{' '}answered</span> <span className="sr-only">{' '}answered</span>
</Icon> </Icon>
)} )}
{canSeeReportedBadge && ( {canSeeReportedBadge && (
<Badge <Badge
variant="danger" variant="danger"
@@ -105,8 +118,7 @@ function PostLink({
<span className="sr-only">{' '}reported</span> <span className="sr-only">{' '}reported</span>
</Badge> </Badge>
)} )}
{pinned && (
{post.pinned && (
<Icon <Icon
src={PushPin} src={PushPin}
className={`post-summary-icons-dimensions text-gray-700 className={`post-summary-icons-dimensions text-gray-700
@@ -116,29 +128,40 @@ function PostLink({
</div> </div>
</div> </div>
<AuthorLabel <AuthorLabel
author={post.author || intl.formatMessage(messages.anonymous)} author={author || intl.formatMessage(messages.anonymous)}
authorLabel={post.authorLabel} authorLabel={authorLabel}
labelColor={authorLabelColor && `text-${authorLabelColor}`} labelColor={authorLabelColor && `text-${authorLabelColor}`}
/> />
<PostSummaryFooter post={post} preview showNewCountLabel={read} /> <PostSummaryFooter
postId={id}
voted={voted}
voteCount={voteCount}
following={following}
commentCount={commentCount}
unreadCommentCount={unreadCommentCount}
groupId={groupId}
groupName={groupName}
createdAt={createdAt}
preview
showNewCountLabel={isPostRead}
/>
</div> </div>
</div> </div>
{!showDivider && post.pinned && <div className="pt-1 bg-light-500 border-top border-light-700" />} {!showDivider && pinned && <div className="pt-1 bg-light-500 border-top border-light-700" />}
</Link> </Link>
</> </>
); );
} };
PostLink.propTypes = { PostLink.propTypes = {
post: postShape.isRequired,
isSelected: PropTypes.func.isRequired,
showDivider: PropTypes.bool,
idx: PropTypes.number, idx: PropTypes.number,
postId: PropTypes.string.isRequired,
showDivider: PropTypes.bool,
}; };
PostLink.defaultProps = { PostLink.defaultProps = {
showDivider: true,
idx: -1, idx: -1,
showDivider: true,
}; };
export default PostLink; export default React.memo(PostLink);

View File

@@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import * as timeago from 'timeago.js'; import * as timeago from 'timeago.js';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { import {
Badge, Icon, OverlayTrigger, Tooltip, Badge, Icon, OverlayTrigger, Tooltip,
} from '@edx/paragon'; } from '@edx/paragon';
@@ -16,78 +16,86 @@ import { People, QuestionAnswer, QuestionAnswerOutline } from '../../../componen
import timeLocale from '../../common/time-locale'; import timeLocale from '../../common/time-locale';
import { selectUserHasModerationPrivileges } from '../../data/selectors'; import { selectUserHasModerationPrivileges } from '../../data/selectors';
import messages from './messages'; import messages from './messages';
import { postShape } from './proptypes';
function PostSummaryFooter({ const PostSummaryFooter = ({
post, postId,
intl, voted,
voteCount,
following,
commentCount,
unreadCommentCount,
groupId,
groupName,
createdAt,
preview, preview,
showNewCountLabel, showNewCountLabel,
}) { }) => {
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
timeago.register('time-locale', timeLocale); timeago.register('time-locale', timeLocale);
const intl = useIntl();
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
return ( return (
<div className="d-flex align-items-center text-gray-700" style={{ height: '24px' }}> <div className="d-flex align-items-center text-gray-700" style={{ height: '24px' }}>
<div className="d-flex align-items-center mr-4.5"> <div className="d-flex align-items-center mr-4.5">
<OverlayTrigger <OverlayTrigger
overlay={( overlay={(
<Tooltip id={`liked-${post.id}-tooltip`}> <Tooltip id={`liked-${postId}-tooltip`}>
{intl.formatMessage(post.voted ? messages.likedPost : messages.postLikes)} {intl.formatMessage(voted ? messages.likedPost : messages.postLikes)}
</Tooltip> </Tooltip>
)} )}
> >
<Icon src={post.voted ? ThumbUpFilled : ThumbUpOutline} className="post-summary-like-dimensions mr-0.5"> <Icon src={voted ? ThumbUpFilled : ThumbUpOutline} className="post-summary-like-dimensions mr-0.5">
<span className="sr-only">{' '}{intl.formatMessage(post.voted ? messages.likedPost : messages.postLikes)}</span> <span className="sr-only">{' '}{intl.formatMessage(voted ? messages.likedPost : messages.postLikes)}</span>
</Icon> </Icon>
</OverlayTrigger> </OverlayTrigger>
<div className="font-style"> <div className="font-style">
{(post.voteCount && post.voteCount > 0) ? post.voteCount : null} {(voteCount && voteCount > 0) ? voteCount : null}
</div> </div>
</div> </div>
<OverlayTrigger <OverlayTrigger
overlay={( overlay={(
<Tooltip id={`follow-${post.id}-tooltip`}> <Tooltip id={`follow-${postId}-tooltip`}>
{intl.formatMessage(post.following ? messages.followed : messages.notFollowed)} {intl.formatMessage(following ? messages.followed : messages.notFollowed)}
</Tooltip> </Tooltip>
)} )}
> >
<Icon src={post.following ? StarFilled : StarOutline} className="post-summary-icons-dimensions mr-0.5"> <Icon src={following ? StarFilled : StarOutline} className="post-summary-icons-dimensions mr-0.5">
<span className="sr-only"> <span className="sr-only">
{' '}{intl.formatMessage(post.following ? messages.srOnlyFollowDescription : messages.srOnlyUnFollowDescription)} {' '}{intl.formatMessage(following ? messages.srOnlyFollowDescription : messages.srOnlyUnFollowDescription)}
</span> </span>
</Icon> </Icon>
</OverlayTrigger> </OverlayTrigger>
{preview && post.commentCount > 1 && ( {preview && commentCount > 1 && (
<div className="d-flex align-items-center ml-4.5 text-gray-700 font-style font-size-12"> <div className="d-flex align-items-center ml-4.5 text-gray-700 font-style font-size-12">
<OverlayTrigger <OverlayTrigger
overlay={( overlay={(
<Tooltip id={`follow-${post.id}-tooltip`}> <Tooltip id={`follow-${postId}-tooltip`}>
{intl.formatMessage(messages.activity)} {intl.formatMessage(messages.activity)}
</Tooltip> </Tooltip>
)} )}
> >
<Icon <Icon
src={post.unreadCommentCount ? QuestionAnswer : QuestionAnswerOutline} src={unreadCommentCount ? QuestionAnswer : QuestionAnswerOutline}
className="post-summary-comment-count-dimensions mr-0.5" className="post-summary-comment-count-dimensions mr-0.5"
> >
<span className="sr-only">{' '} {intl.formatMessage(messages.activity)}</span> <span className="sr-only">{' '} {intl.formatMessage(messages.activity)}</span>
</Icon> </Icon>
</OverlayTrigger> </OverlayTrigger>
{post.commentCount} {commentCount}
</div> </div>
)} )}
{showNewCountLabel && preview && post?.unreadCommentCount > 0 && post.commentCount > 1 && ( {showNewCountLabel && preview && unreadCommentCount > 0 && commentCount > 1 && (
<Badge variant="light" className="ml-2"> <Badge variant="light" className="ml-2">
{intl.formatMessage(messages.newLabel, { count: post.unreadCommentCount })} {intl.formatMessage(messages.newLabel, { count: unreadCommentCount })}
</Badge> </Badge>
)} )}
<div className="d-flex flex-fill justify-content-end align-items-center"> <div className="d-flex flex-fill justify-content-end align-items-center">
{post.groupId && userHasModerationPrivileges && ( {groupId && userHasModerationPrivileges && (
<OverlayTrigger <OverlayTrigger
overlay={( overlay={(
<Tooltip id={`visibility-${post.id}-tooltip`}>{post.groupName}</Tooltip> <Tooltip id={`visibility-${postId}-tooltip`}>{groupName}</Tooltip>
)} )}
> >
<span data-testid="cohort-icon" className="mr-2"> <span data-testid="cohort-icon" className="mr-2">
@@ -98,17 +106,24 @@ function PostSummaryFooter({
</span> </span>
</OverlayTrigger> </OverlayTrigger>
)} )}
<span title={post.createdAt} className="text-gray-700 post-summary-timestamp ml-0.5"> <span title={createdAt} className="text-gray-700 post-summary-timestamp ml-0.5">
{timeago.format(post.createdAt, 'time-locale')} {timeago.format(createdAt, 'time-locale')}
</span> </span>
</div> </div>
</div> </div>
); );
} };
PostSummaryFooter.propTypes = { PostSummaryFooter.propTypes = {
intl: intlShape.isRequired, postId: PropTypes.string.isRequired,
post: postShape.isRequired, voted: PropTypes.bool.isRequired,
voteCount: PropTypes.number.isRequired,
following: PropTypes.bool.isRequired,
commentCount: PropTypes.number.isRequired,
unreadCommentCount: PropTypes.number.isRequired,
groupId: PropTypes.number,
groupName: PropTypes.string,
createdAt: PropTypes.string.isRequired,
preview: PropTypes.bool, preview: PropTypes.bool,
showNewCountLabel: PropTypes.bool, showNewCountLabel: PropTypes.bool,
}; };
@@ -116,6 +131,8 @@ PostSummaryFooter.propTypes = {
PostSummaryFooter.defaultProps = { PostSummaryFooter.defaultProps = {
preview: false, preview: false,
showNewCountLabel: false, showNewCountLabel: false,
groupId: null,
groupName: null,
}; };
export default injectIntl(PostSummaryFooter); export default React.memo(PostSummaryFooter);

View File

@@ -1,4 +1,6 @@
import React, { useContext, useEffect } from 'react'; import React, {
useCallback, useContext, useEffect, useMemo,
} from 'react';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { useParams } from 'react-router'; import { useParams } from 'react-router';
@@ -16,49 +18,57 @@ import LegacyTopicGroup from './topic-group/LegacyTopicGroup';
import Topic from './topic-group/topic/Topic'; import Topic from './topic-group/topic/Topic';
import countFilteredTopics from './utils'; import countFilteredTopics from './utils';
function CourseWideTopics() { const CourseWideTopics = () => {
const { category } = useParams(); const { category } = useParams();
const filter = useSelector(selectTopicFilter); const filter = useSelector(selectTopicFilter);
const nonCoursewareTopics = useSelector(selectNonCoursewareTopics); const nonCoursewareTopics = useSelector(selectNonCoursewareTopics);
const filteredNonCoursewareTopics = nonCoursewareTopics.filter(item => (filter
? item.name.toLowerCase().includes(filter) const filteredNonCoursewareTopics = useMemo(() => (
: true nonCoursewareTopics.filter(item => (
)); filter ? item.name.toLowerCase().includes(filter) : true
))), [nonCoursewareTopics, filter]);
return (nonCoursewareTopics && category === undefined) return (nonCoursewareTopics && category === undefined)
&& filteredNonCoursewareTopics.map((topic, index) => ( && filteredNonCoursewareTopics.map((topic, index) => (
<Topic <Topic
topic={topic} topicId={topic.id}
key={topic.id} key={topic.id}
index={index} index={index}
showDivider={(filteredNonCoursewareTopics.length - 1) !== index} showDivider={(filteredNonCoursewareTopics.length - 1) !== index}
/> />
)); ));
} };
function LegacyCoursewareTopics() { const LegacyCoursewareTopics = () => {
const { category } = useParams(); const { category } = useParams();
const categories = useSelector(selectCategories) const categories = useSelector(selectCategories);
.filter(cat => (category ? cat === category : true));
return categories?.map( const filteredCategories = useMemo(() => (
topicGroup => ( categories.filter(cat => (category ? cat === category : true))
), [categories, category]);
return filteredCategories?.map(
categoryId => (
<LegacyTopicGroup <LegacyTopicGroup
id={topicGroup} categoryId={categoryId}
category={topicGroup} key={categoryId}
key={topicGroup}
/> />
), ),
); );
} };
function TopicsView() { const TopicsView = () => {
const dispatch = useDispatch();
const provider = useSelector(selectDiscussionProvider); const provider = useSelector(selectDiscussionProvider);
const topicFilter = useSelector(selectTopicFilter); const topicFilter = useSelector(selectTopicFilter);
const topicsSelector = useSelector(({ topics }) => topics); const topicsSelector = useSelector(({ topics }) => topics);
const filteredTopicsCount = useSelector(({ topics }) => topics.results.count); const filteredTopicsCount = useSelector(({ topics }) => topics.results.count);
const loadingStatus = useSelector(({ topics }) => topics.status); const loadingStatus = useSelector(({ topics }) => topics.status);
const { courseId } = useContext(DiscussionContext); const { courseId } = useContext(DiscussionContext);
const dispatch = useDispatch();
const handleOnClear = useCallback(() => {
dispatch(setFilter(''));
}, []);
useEffect(() => { useEffect(() => {
// Don't load till the provider information is available // Don't load till the provider information is available
@@ -79,7 +89,7 @@ function TopicsView() {
text={topicFilter} text={topicFilter}
count={filteredTopicsCount} count={filteredTopicsCount}
loadingStatus={loadingStatus} loadingStatus={loadingStatus}
onClear={() => dispatch(setFilter(''))} onClear={handleOnClear}
/> />
)} )}
<div className="list-group list-group-flush flex-fill" role="list" onKeyDown={e => handleKeyDown(e)}> <div className="list-group list-group-flush flex-fill" role="list" onKeyDown={e => handleKeyDown(e)}>
@@ -94,8 +104,6 @@ function TopicsView() {
} }
</div> </div>
); );
} };
TopicsView.propTypes = {};
export default TopicsView; export default TopicsView;

View File

@@ -13,6 +13,10 @@ export const selectTopicsInCategory = (categoryId) => state => (
state.topics.topicsInCategory[categoryId]?.map(id => state.topics.topics[id]) || [] 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 selectTopics = state => state.topics.topics;
export const selectCoursewareTopics = createSelector( export const selectCoursewareTopics = createSelector(
selectCategories, selectCategories,

View File

@@ -3,27 +3,23 @@ import PropTypes from 'prop-types';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { selectTopicsInCategory } from '../data/selectors'; import { selectTopicsInCategoryIds } from '../data/selectors';
import TopicGroupBase from './TopicGroupBase'; import TopicGroupBase from './TopicGroupBase';
function LegacyTopicGroup({ const LegacyTopicGroup = ({ categoryId }) => {
id, const topicsIds = useSelector(selectTopicsInCategoryIds(categoryId));
category,
}) {
const topics = useSelector(selectTopicsInCategory(category));
return ( return (
<TopicGroupBase groupId={id} groupTitle={category} topics={topics} /> <TopicGroupBase groupId={categoryId} groupTitle={categoryId} topicsIds={topicsIds} />
); );
} };
LegacyTopicGroup.propTypes = { LegacyTopicGroup.propTypes = {
id: PropTypes.string, categoryId: PropTypes.string,
category: PropTypes.string,
}; };
LegacyTopicGroup.defaultProps = { LegacyTopicGroup.defaultProps = {
id: null, categoryId: null,
category: null,
}; };
export default LegacyTopicGroup; export default React.memo(LegacyTopicGroup);

View File

@@ -1,43 +1,63 @@
import React, { useContext } from 'react'; import React, { useContext, useMemo } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { Routes } from '../../../data/constants'; import { Routes } from '../../../data/constants';
import { DiscussionContext } from '../../common/context'; import { DiscussionContext } from '../../common/context';
import { discussionsPath } from '../../utils'; import { discussionsPath } from '../../utils';
import { selectTopicFilter } from '../data/selectors'; import { selectTopicFilter, selectTopicsById } from '../data/selectors';
import messages from '../messages'; import messages from '../messages';
import Topic, { topicShape } from './topic/Topic'; import Topic from './topic/Topic';
function TopicGroupBase({ const TopicGroupBase = ({
groupId, groupId,
groupTitle, groupTitle,
linkToGroup, linkToGroup,
topics, topicsIds,
intl, }) => {
}) { const intl = useIntl();
const { courseId } = useContext(DiscussionContext); const { courseId } = useContext(DiscussionContext);
const filter = useSelector(selectTopicFilter); const filter = useSelector(selectTopicFilter);
const topics = useSelector(selectTopicsById(topicsIds));
const hasTopics = topics.length > 0; const hasTopics = topics.length > 0;
const matchesFilter = filter
? groupTitle?.toLowerCase().includes(filter)
: true;
const filteredTopicElements = topics.filter( const matchesFilter = useMemo(() => (
topic => (filter filter ? groupTitle?.toLowerCase().includes(filter) : true
? (topic.name.toLowerCase().includes(filter) || matchesFilter) ), [filter, groupTitle]);
: true
), const filteredTopicElements = useMemo(() => (
); topics.filter(topic => (
filter ? (topic.name.toLowerCase().includes(filter) || matchesFilter) : true
))
), [topics, filter, matchesFilter]);
const hasFilteredSubtopics = (filteredTopicElements.length > 0); const hasFilteredSubtopics = (filteredTopicElements.length > 0);
const renderFilteredTopics = useMemo(() => {
if (!hasFilteredSubtopics) {
return <></>;
}
return (
filteredTopicElements.map((topic, index) => (
<Topic
topicId={topic.id}
key={topic.id}
index={index}
showDivider={(filteredTopicElements.length - 1) !== index}
/>
))
);
}, [filteredTopicElements]);
if (!hasTopics || (!matchesFilter && !hasFilteredSubtopics)) { if (!hasTopics || (!matchesFilter && !hasFilteredSubtopics)) {
return null; return null;
} }
return ( return (
<div <div
className="discussion-topic-group d-flex flex-column text-primary-500" className="discussion-topic-group d-flex flex-column text-primary-500"
@@ -45,43 +65,34 @@ function TopicGroupBase({
data-testid="topic-group" data-testid="topic-group"
> >
<div className="pt-2.5 px-4 font-weight-bold"> <div className="pt-2.5 px-4 font-weight-bold">
{linkToGroup && groupId {linkToGroup && groupId ? (
? ( <Link
<Link className="text-decoration-none text-primary-500"
className="text-decoration-none text-primary-500" to={discussionsPath(Routes.TOPICS.CATEGORY, {
to={discussionsPath(Routes.TOPICS.CATEGORY, { courseId,
courseId, category: groupId,
category: groupId, })}
})} >
> {groupTitle}
{groupTitle} </Link>
</Link> ) : (
) : ( groupTitle || intl.formatMessage(messages.unnamedTopicCategories)
groupTitle || intl.formatMessage(messages.unnamedTopicCategories) )}
)}
</div> </div>
{filteredTopicElements.map((topic, index) => ( {renderFilteredTopics}
<Topic
topic={topic}
key={topic.id}
index={index}
showDivider={(filteredTopicElements.length - 1) !== index}
/>
))}
</div> </div>
); );
} };
TopicGroupBase.propTypes = { TopicGroupBase.propTypes = {
groupId: PropTypes.string.isRequired, groupId: PropTypes.string.isRequired,
groupTitle: PropTypes.string.isRequired, groupTitle: PropTypes.string.isRequired,
topics: PropTypes.arrayOf(topicShape).isRequired, topicsIds: PropTypes.arrayOf(PropTypes.string).isRequired,
linkToGroup: PropTypes.bool, linkToGroup: PropTypes.bool,
intl: intlShape.isRequired,
}; };
TopicGroupBase.defaultProps = { TopicGroupBase.defaultProps = {
linkToGroup: true, linkToGroup: true,
}; };
export default injectIntl(TopicGroupBase); export default React.memo(TopicGroupBase);

View File

@@ -1,5 +1,5 @@
/* eslint-disable no-unused-vars, react/forbid-prop-types */ /* eslint-disable no-unused-vars, react/forbid-prop-types */
import React from 'react'; import React, { useCallback } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import classNames from 'classnames'; import classNames from 'classnames';
@@ -7,31 +7,31 @@ import { useSelector } from 'react-redux';
import { useParams } from 'react-router'; import { useParams } from 'react-router';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { Icon, OverlayTrigger, Tooltip } from '@edx/paragon'; import { Icon, OverlayTrigger, Tooltip } from '@edx/paragon';
import { HelpOutline, PostOutline, Report } from '@edx/paragon/icons'; import { HelpOutline, PostOutline, Report } from '@edx/paragon/icons';
import { Routes } from '../../../../data/constants'; import { Routes } from '../../../../data/constants';
import { selectUserHasModerationPrivileges, selectUserIsGroupTa } from '../../../data/selectors'; import { selectUserHasModerationPrivileges, selectUserIsGroupTa } from '../../../data/selectors';
import { discussionsPath } from '../../../utils'; import { discussionsPath } from '../../../utils';
import { selectTopic } from '../../data/selectors';
import messages from '../../messages'; import messages from '../../messages';
function Topic({ const Topic = ({ topicId, showDivider, index }) => {
topic, const intl = useIntl();
showDivider,
index,
intl,
}) {
const { courseId } = useParams(); const { courseId } = useParams();
const topicUrl = discussionsPath(Routes.TOPICS.TOPIC, { const topic = useSelector(selectTopic(topicId));
courseId, const {
topicId: topic.id, id, inactiveFlags, activeFlags, name, threadCounts,
}); } = topic;
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges); const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
const userIsGroupTa = useSelector(selectUserIsGroupTa); const userIsGroupTa = useSelector(selectUserIsGroupTa);
const { inactiveFlags, activeFlags } = topic;
const canSeeReportedStats = (activeFlags || inactiveFlags) && (userHasModerationPrivileges || userIsGroupTa); const canSeeReportedStats = (activeFlags || inactiveFlags) && (userHasModerationPrivileges || userIsGroupTa);
const isSelected = (id) => window.location.pathname.includes(id); const topicUrl = discussionsPath(Routes.TOPICS.TOPIC, { courseId, topicId });
const isSelected = useCallback((selectedId) => (
window.location.pathname.includes(selectedId)
), []);
return ( return (
<Link <Link
@@ -40,18 +40,18 @@ function Topic({
'border-bottom border-light-400': showDivider, 'border-bottom border-light-400': showDivider,
}) })
} }
data-topic-id={topic.id} data-topic-id={id}
to={topicUrl} to={topicUrl}
onClick={() => isSelected(topic.id)} onClick={() => isSelected(id)}
aria-current={isSelected(topic.id) ? 'page' : undefined} aria-current={isSelected(id) ? 'page' : undefined}
role="option" role="option"
tabIndex={(isSelected(topic.id) || index === 0) ? 0 : -1} tabIndex={(isSelected(id) || index === 0) ? 0 : -1}
> >
<div className="d-flex flex-row pt-2.5 pb-2 px-4"> <div className="d-flex flex-row pt-2.5 pb-2 px-4">
<div className="d-flex flex-column flex-fill" style={{ minWidth: 0 }}> <div className="d-flex flex-column flex-fill" style={{ minWidth: 0 }}>
<div className="d-flex flex-column justify-content-start mw-100 flex-fill"> <div className="d-flex flex-column justify-content-start mw-100 flex-fill">
<div className="topic-name text-truncate"> <div className="topic-name text-truncate">
{topic.name || intl.formatMessage(messages.unnamedTopicSubCategories)} {name || intl.formatMessage(messages.unnamedTopicSubCategories)}
</div> </div>
</div> </div>
<div className="d-flex align-items-center mt-2.5" style={{ marginBottom: '2px' }}> <div className="d-flex align-items-center mt-2.5" style={{ marginBottom: '2px' }}>
@@ -61,7 +61,7 @@ function Topic({
<Tooltip> <Tooltip>
<div className="d-flex flex-column align-items-start"> <div className="d-flex flex-column align-items-start">
{intl.formatMessage(messages.discussions, { {intl.formatMessage(messages.discussions, {
count: topic.threadCounts?.discussion || 0, count: threadCounts?.discussion || 0,
})} })}
</div> </div>
</Tooltip> </Tooltip>
@@ -69,7 +69,7 @@ function Topic({
> >
<div className="d-flex align-items-center mr-3.5"> <div className="d-flex align-items-center mr-3.5">
<Icon src={PostOutline} className="icon-size mr-2" /> <Icon src={PostOutline} className="icon-size mr-2" />
{topic.threadCounts?.discussion || 0} {threadCounts?.discussion || 0}
</div> </div>
</OverlayTrigger> </OverlayTrigger>
<OverlayTrigger <OverlayTrigger
@@ -78,7 +78,7 @@ function Topic({
<Tooltip> <Tooltip>
<div className="d-flex flex-column align-items-start"> <div className="d-flex flex-column align-items-start">
{intl.formatMessage(messages.questions, { {intl.formatMessage(messages.questions, {
count: topic.threadCounts?.question || 0, count: threadCounts?.question || 0,
})} })}
</div> </div>
</Tooltip> </Tooltip>
@@ -86,7 +86,7 @@ function Topic({
> >
<div className="d-flex align-items-center mr-3.5"> <div className="d-flex align-items-center mr-3.5">
<Icon src={HelpOutline} className="icon-size mr-2" /> <Icon src={HelpOutline} className="icon-size mr-2" />
{topic.threadCounts?.question || 0} {threadCounts?.question || 0}
</div> </div>
</OverlayTrigger> </OverlayTrigger>
{Boolean(canSeeReportedStats) && ( {Boolean(canSeeReportedStats) && (
@@ -121,7 +121,7 @@ function Topic({
{!showDivider && <div className="divider pt-1 bg-light-500 border-top border-light-700" />} {!showDivider && <div className="divider pt-1 bg-light-500 border-top border-light-700" />}
</Link> </Link>
); );
} };
export const topicShape = PropTypes.shape({ export const topicShape = PropTypes.shape({
name: PropTypes.string, name: PropTypes.string,
@@ -130,9 +130,9 @@ export const topicShape = PropTypes.shape({
discussions: PropTypes.number, discussions: PropTypes.number,
flags: PropTypes.number, flags: PropTypes.number,
}); });
Topic.propTypes = { Topic.propTypes = {
intl: intlShape.isRequired, topicId: PropTypes.string.isRequired,
topic: topicShape.isRequired,
showDivider: PropTypes.bool, showDivider: PropTypes.bool,
index: PropTypes.number, index: PropTypes.number,
}; };
@@ -142,4 +142,4 @@ Topic.defaultProps = {
index: -1, index: -1,
}; };
export default injectIntl(Topic); export default React.memo(Topic);

View File

@@ -1,17 +1,17 @@
import { useEffect } from 'react'; import React, { useEffect } from 'react';
import isEmpty from 'lodash/isEmpty'; import isEmpty from 'lodash/isEmpty';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { ProductTour } from '@edx/paragon'; import { ProductTour } from '@edx/paragon';
import { useTourConfiguration } from '../data/hooks'; import { useTourConfiguration } from '../data/hooks';
import { fetchDiscussionTours } from './data/thunks'; import { fetchDiscussionTours } from './data/thunks';
function DiscussionsProductTour({ intl }) { const DiscussionsProductTour = () => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const config = useTourConfiguration(intl); const config = useTourConfiguration();
useEffect(() => { useEffect(() => {
dispatch(fetchDiscussionTours()); dispatch(fetchDiscussionTours());
}, []); }, []);
@@ -25,10 +25,6 @@ function DiscussionsProductTour({ intl }) {
)} )}
</> </>
); );
}
DiscussionsProductTour.propTypes = {
intl: intlShape.isRequired,
}; };
export default injectIntl(DiscussionsProductTour); export default DiscussionsProductTour;

View File

@@ -1,6 +1,8 @@
/* eslint-disable import/prefer-default-export */ import { useCallback, useContext, useMemo } from 'react';
import { getIn } from 'formik'; import { getIn } from 'formik';
import { uniqBy } from 'lodash'; import { uniqBy } from 'lodash';
import { useSelector } from 'react-redux';
import { generatePath, useRouteMatch } from 'react-router'; import { generatePath, useRouteMatch } from 'react-router';
import { getConfig } from '@edx/frontend-platform'; import { getConfig } from '@edx/frontend-platform';
@@ -10,6 +12,8 @@ import {
import { InsertLink } from '../components/icons'; import { InsertLink } from '../components/icons';
import { ContentActions, Routes, ThreadType } from '../data/constants'; import { ContentActions, Routes, ThreadType } from '../data/constants';
import { ContentSelectors } from './data/constants';
import { PostCommentsContext } from './post-comments/postCommentsContext';
import messages from './messages'; import messages from './messages';
/** /**
@@ -175,20 +179,26 @@ export const ACTIONS_LIST = [
}, },
]; ];
export function useActions(content) { export function useActions(contentType, id) {
const checkConditions = (item, conditions) => ( const postType = useContext(PostCommentsContext);
const content = { ...useSelector(ContentSelectors[contentType](id)), postType };
const checkConditions = useCallback((item, conditions) => (
conditions conditions
? Object.keys(conditions) ? Object.keys(conditions)
.map(key => item[key] === conditions[key]) .map(key => item[key] === conditions[key])
.every(condition => condition === true) .every(condition => condition === true)
: true : true
); ), []);
return ACTIONS_LIST.filter(
const actions = useMemo(() => ACTIONS_LIST.filter(
({ ({
action, action,
conditions = null, conditions = null,
}) => checkPermissions(content, action) && checkConditions(content, conditions), }) => checkPermissions(content, action) && checkConditions(content, conditions),
); ), [content]);
return actions;
} }
export const formikCompatibleHandler = (formikHandler, name) => (value) => formikHandler({ export const formikCompatibleHandler = (formikHandler, name) => (value) => formikHandler({

View File

@@ -178,18 +178,18 @@ $fa-font-path: "~font-awesome/fonts";
background-color: unset !important; background-color: unset !important;
} }
.learner>a:hover { .learner > a:hover {
background-color: #F2F0EF; background-color: #F2F0EF;
} }
.py-10px { .py-10px {
padding-top: 10px; padding-top: 10px !important;
padding-bottom: 10px; padding-bottom: 10px !important;
} }
.py-8px { .py-8px {
padding-top: 8px; padding-top: 8px !important;
padding-bottom: 8px; padding-bottom: 8px !important;
} }
.pb-10px { .pb-10px {
@@ -530,3 +530,10 @@ header {
position: relative; position: relative;
background-color: #fff; background-color: #fff;
} }
.spinner-container {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}