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",
"react": "16.14.0",
"react-dom": "16.14.0",
"react-mathjax-preview": "2.2.6",
"react-redux": "7.2.6",
"react-router": "5.2.1",
"react-router-dom": "5.3.0",
@@ -22327,19 +22326,6 @@
"react": ">=16.8.0"
}
},
"node_modules/react-mathjax-preview": {
"version": "2.2.6",
"resolved": "https://registry.npmjs.org/react-mathjax-preview/-/react-mathjax-preview-2.2.6.tgz",
"integrity": "sha512-lS+wQ49jd/XyXu8tTWWZ38qfNJrAlxsDjkUrQoldfM1MdS6s18DxFV4dwP9xoAsq86kuSb/raXU8qJrH5dtV5w==",
"dependencies": {
"dompurify": "^2.0.8"
},
"peerDependencies": {
"prop-types": "^15",
"react": "^16 || ^17",
"react-dom": "^16 || ^17"
}
},
"node_modules/react-overlays": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/react-overlays/-/react-overlays-5.2.0.tgz",
@@ -43849,14 +43835,6 @@
"integrity": "sha512-j1U1CWWs68nBPOg7tkQqnlFcAMFF6oEK6MgqAo15f8A5p7mjH6xyKn2gHbkcimpwfO0VQXqxAswnSYVr8lWzjw==",
"requires": {}
},
"react-mathjax-preview": {
"version": "2.2.6",
"resolved": "https://registry.npmjs.org/react-mathjax-preview/-/react-mathjax-preview-2.2.6.tgz",
"integrity": "sha512-lS+wQ49jd/XyXu8tTWWZ38qfNJrAlxsDjkUrQoldfM1MdS6s18DxFV4dwP9xoAsq86kuSb/raXU8qJrH5dtV5w==",
"requires": {
"dompurify": "^2.0.8"
}
},
"react-overlays": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/react-overlays/-/react-overlays-5.2.0.tgz",

View File

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

View File

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

View File

@@ -12,9 +12,9 @@ const defaultSanitizeOptions = {
ADD_ATTR: ['columnalign'],
};
function HTMLLoader({
const HTMLLoader = ({
htmlNode, componentId, cssClassName, testId, delay,
}) {
}) => {
const sanitizedMath = DOMPurify.sanitize(htmlNode, { ...defaultSanitizeOptions });
const previewRef = useRef(null);
const debouncedPostContent = useDebounce(htmlNode, delay);
@@ -45,7 +45,7 @@ function HTMLLoader({
return (
<div ref={previewRef} className={cssClassName} id={componentId} data-testid={testId} />
);
}
};
HTMLLoader.propTypes = {
htmlNode: PropTypes.node,
@@ -63,4 +63,4 @@ HTMLLoader.defaultProps = {
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 { useDispatch, useSelector } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import { fetchTab } from './data/thunks';
import Tabs from './tabs/Tabs';
@@ -12,12 +12,13 @@ import messages from './messages';
import './navBar.scss';
function CourseTabsNavigation({
activeTab, className, intl, courseId, rootSlug,
}) {
const CourseTabsNavigation = ({
activeTab, className, courseId, rootSlug,
}) => {
const dispatch = useDispatch();
const intl = useIntl();
const tabs = useSelector(state => state.courseTabs.tabs);
useEffect(() => {
dispatch(fetchTab(courseId, rootSlug));
}, [courseId]);
@@ -25,8 +26,7 @@ function CourseTabsNavigation({
return (
<div id="courseTabsNavigation" className={classNames('course-tabs-navigation', className)}>
<div className="container-xl">
{!!tabs.length
&& (
{!!tabs.length && (
<Tabs
className="nav-underline-tabs"
aria-label={intl.formatMessage(messages.courseMaterial)}
@@ -41,18 +41,17 @@ function CourseTabsNavigation({
</a>
))}
</Tabs>
)}
)}
</div>
</div>
);
}
};
CourseTabsNavigation.propTypes = {
activeTab: PropTypes.string,
className: PropTypes.string,
rootSlug: PropTypes.string,
courseId: PropTypes.string.isRequired,
intl: intlShape.isRequired,
};
CourseTabsNavigation.defaultProps = {
@@ -61,4 +60,4 @@ CourseTabsNavigation.defaultProps = {
rootSlug: 'outline',
};
export default injectIntl(CourseTabsNavigation);
export default React.memo(CourseTabsNavigation);

View File

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

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 { useDispatch, useSelector } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Icon, SearchField } from '@edx/paragon';
import { Search as SearchIcon } from '@edx/paragon/icons';
@@ -13,7 +15,8 @@ import { setSearchQuery } from '../discussions/posts/data';
import postsMessages from '../discussions/posts/post-actions-bar/messages';
import { setFilter as setTopicFilter } from '../discussions/topics/data/slices';
function Search({ intl }) {
const Search = () => {
const intl = useIntl();
const dispatch = useDispatch();
const { page } = useContext(DiscussionContext);
const postSearch = useSelector(({ threads }) => threads.filters.search);
@@ -21,8 +24,9 @@ function Search({ intl }) {
const learnerSearch = useSelector(({ learners }) => learners.usernameSearch);
const isPostSearch = ['posts', 'my-posts'].includes(page);
const isTopicSearch = 'topics'.includes(page);
let searchValue = '';
const [searchValue, setSearchValue] = useState('');
let currentValue = '';
if (isPostSearch) {
currentValue = postSearch;
} else if (isTopicSearch) {
@@ -31,20 +35,21 @@ function Search({ intl }) {
currentValue = learnerSearch;
}
const onClear = () => {
const onClear = useCallback(() => {
dispatch(setSearchQuery(''));
dispatch(setTopicFilter(''));
dispatch(setUsernameSearch(''));
};
}, []);
const onChange = (query) => {
searchValue = query;
};
const onChange = useCallback((query) => {
setSearchValue(query);
}, []);
const onSubmit = (query) => {
const onSubmit = useCallback((query) => {
if (query === '') {
return;
}
if (isPostSearch) {
dispatch(setSearchQuery(query));
} else if (page === 'topics') {
@@ -52,36 +57,36 @@ function Search({ intl }) {
} else if (page === 'learners') {
dispatch(setUsernameSearch(query));
}
};
}, [page, searchValue]);
const handleIconClick = useCallback((e) => {
e.preventDefault();
onSubmit(searchValue);
}, [searchValue]);
useEffect(() => onClear(), [page]);
return (
<>
<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 = {
intl: intlShape.isRequired,
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="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 PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Button, Icon } from '@edx/paragon';
import { Search } from '@edx/paragon/icons';
import { RequestStatus } from '../data/constants';
import messages from '../discussions/posts/post-actions-bar/messages';
function SearchInfo({
intl,
const SearchInfo = ({
count,
text,
loadingStatus,
onClear,
textSearchRewrite,
}) {
}) => {
const intl = useIntl();
return (
<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" />
<Button variant="" size="inline" className="text-justify p-2">
{loadingStatus === RequestStatus.SUCCESSFUL && (
textSearchRewrite ? intl.formatMessage(messages.searchRewriteInfo, {
searchString: text,
count,
textSearchRewrite,
})
: intl.formatMessage(messages.searchInfo, { count, text })
textSearchRewrite ? (
intl.formatMessage(messages.searchRewriteInfo, {
searchString: text,
count,
textSearchRewrite,
})
) : (
intl.formatMessage(messages.searchInfo, { count, text })
)
)}
{loadingStatus !== RequestStatus.SUCCESSFUL && intl.formatMessage(messages.searchInfoSearching)}
</Button>
@@ -35,10 +39,9 @@ function SearchInfo({
</Button>
</div>
);
}
};
SearchInfo.propTypes = {
intl: intlShape.isRequired,
count: PropTypes.number.isRequired,
text: PropTypes.string.isRequired,
loadingStatus: PropTypes.string.isRequired,
@@ -51,4 +54,4 @@ SearchInfo.defaultProps = {
textSearchRewrite: null,
};
export default injectIntl(SearchInfo);
export default React.memo(SearchInfo);

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

View File

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

View File

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

View File

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

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 { useSelector } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import { logError } from '@edx/frontend-platform/logging';
import {
Button, Dropdown, Icon, IconButton, ModalPopup, useToggle,
@@ -13,22 +15,22 @@ import { MoreHoriz } from '@edx/paragon/icons';
import { ContentActions } from '../../data/constants';
import { selectBlackoutDate } from '../data/selectors';
import messages from '../messages';
import { commentShape } from '../post-comments/comments/comment/proptypes';
import { postShape } from '../posts/post/proptypes';
import { inBlackoutDateRange, useActions } from '../utils';
function ActionsDropdown({
intl,
commentOrPost,
disabled,
actionHandlers,
iconSize,
contentType,
disabled,
dropDownIconSize,
iconSize,
id,
}) {
const buttonRef = useRef();
const intl = useIntl();
const [isOpen, open, close] = useToggle(false);
const [target, setTarget] = useState(null);
const actions = useActions(commentOrPost);
const blackoutDateRange = useSelector(selectBlackoutDate);
const actions = useActions(contentType, id);
const handleActions = useCallback((action) => {
const actionFunction = actionHandlers[action];
@@ -39,11 +41,12 @@ function ActionsDropdown({
}
}, [actionHandlers]);
const blackoutDateRange = useSelector(selectBlackoutDate);
// Find and remove edit action if in blackout date range.
if (inBlackoutDateRange(blackoutDateRange)) {
actions.splice(actions.findIndex(action => action.id === 'edit'), 1);
}
useMemo(() => {
if (inBlackoutDateRange(blackoutDateRange)) {
actions.splice(actions.findIndex(action => action.id === 'edit'), 1);
}
}, [actions, blackoutDateRange]);
const onClickButton = useCallback(() => {
setTarget(buttonRef.current);
@@ -80,9 +83,7 @@ function ActionsDropdown({
>
{actions.map(action => (
<React.Fragment key={action.id}>
{(action.action === ContentActions.DELETE)
&& <Dropdown.Divider />}
{(action.action === ContentActions.DELETE) && <Dropdown.Divider />}
<Dropdown.Item
as={Button}
variant="tertiary"
@@ -111,12 +112,12 @@ function ActionsDropdown({
}
ActionsDropdown.propTypes = {
intl: intlShape.isRequired,
commentOrPost: PropTypes.oneOfType([commentShape, postShape]).isRequired,
id: PropTypes.string.isRequired,
disabled: PropTypes.bool,
actionHandlers: PropTypes.objectOf(PropTypes.func).isRequired,
iconSize: PropTypes.string,
dropDownIconSize: PropTypes.bool,
contentType: PropTypes.oneOf(['POST', 'COMMENT']).isRequired,
};
ActionsDropdown.defaultProps = {
@@ -125,4 +126,4 @@ ActionsDropdown.defaultProps = {
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 { 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 { Report } from '@edx/paragon/icons';
@@ -12,26 +12,31 @@ import { AvatarOutlineAndLabelColors } from '../../data/constants';
import {
selectModerationSettings, selectUserHasModerationPrivileges, selectUserIsGroupTa, selectUserIsStaff,
} from '../data/selectors';
import { commentShape } from '../post-comments/comments/comment/proptypes';
import messages from '../post-comments/messages';
import { postShape } from '../posts/post/proptypes';
import AlertBar from './AlertBar';
function AlertBanner({
intl,
content,
}) {
const AlertBanner = ({
author,
abuseFlagged,
lastEdit,
closed,
closedBy,
closeReason,
editByLabel,
closedByLabel,
}) => {
const intl = useIntl();
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
const userIsGroupTa = useSelector(selectUserIsGroupTa);
const userIsGlobalStaff = useSelector(selectUserIsStaff);
const { reasonCodesEnabled } = useSelector(selectModerationSettings);
const userIsContentAuthor = getAuthenticatedUser().username === content.author;
const canSeeReportedBanner = content?.abuseFlagged;
const userIsContentAuthor = getAuthenticatedUser().username === author;
const canSeeReportedBanner = abuseFlagged;
const canSeeLastEditOrClosedAlert = (userHasModerationPrivileges || userIsGroupTa
|| userIsGlobalStaff || userIsContentAuthor
);
const editByLabelColor = AvatarOutlineAndLabelColors[content.editByLabel];
const closedByLabelColor = AvatarOutlineAndLabelColors[content.closedByLabel];
const editByLabelColor = AvatarOutlineAndLabelColors[editByLabel];
const closedByLabelColor = AvatarOutlineAndLabelColors[closedByLabel];
return (
<>
@@ -42,33 +47,52 @@ function AlertBanner({
)}
{reasonCodesEnabled && canSeeLastEditOrClosedAlert && (
<>
{content.lastEdit?.reason && (
{lastEdit?.reason && (
<AlertBar
message={messages.editedBy}
author={content.lastEdit.editorUsername}
authorLabel={content.editByLabel}
message={intl.formatMessage(messages.editedBy)}
author={lastEdit.editorUsername}
authorLabel={editByLabel}
labelColor={editByLabelColor && `text-${editByLabelColor}`}
reason={content.lastEdit.reason}
reason={lastEdit.reason}
/>
)}
{content.closed && (
<AlertBar
message={messages.closedBy}
author={content.closedBy}
authorLabel={content.closedByLabel}
labelColor={closedByLabelColor && `text-${closedByLabelColor}`}
reason={content.closeReason}
/>
{closed && (
<AlertBar
message={intl.formatMessage(messages.closedBy)}
author={closedBy}
authorLabel={closedByLabel}
labelColor={closedByLabelColor && `text-${closedByLabelColor}`}
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 PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Alert } from '@edx/paragon';
import messages from '../post-comments/messages';
import AuthorLabel from './AuthorLabel';
function AlertBar({
intl,
const AlertBar = ({
message,
author,
authorLabel,
labelColor,
reason,
}) {
}) => {
const intl = useIntl();
return (
<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">
{intl.formatMessage(message)}
{message}
<span className="ml-1">
<AuthorLabel
author={author}
@@ -38,10 +39,9 @@ function AlertBar({
</div>
</Alert>
);
}
};
AlertBar.propTypes = {
intl: intlShape.isRequired,
message: PropTypes.string,
author: PropTypes.string,
authorLabel: PropTypes.string,
@@ -57,4 +57,4 @@ AlertBar.defaultProps = {
reason: '',
};
export default injectIntl(AlertBar);
export default React.memo(AlertBar);

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -1,36 +1,39 @@
import React, { useState } from 'react';
import React, { useCallback, useMemo, useState } from 'react';
import { useSelector } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import { PageBanner } from '@edx/paragon';
import { selectBlackoutDate } from '../data/selectors';
import messages from '../messages';
import { inBlackoutDateRange } from '../utils';
function BlackoutInformationBanner({
intl,
}) {
const isDiscussionsBlackout = inBlackoutDateRange(useSelector(selectBlackoutDate));
const BlackoutInformationBanner = () => {
const intl = useIntl();
const blackoutDate = useSelector(selectBlackoutDate);
const [showBanner, setShowBanner] = useState(true);
const isDiscussionsBlackout = useMemo(() => (
inBlackoutDateRange(blackoutDate)
), [blackoutDate]);
const handleDismiss = useCallback(() => {
setShowBanner(false);
}, []);
return (
<PageBanner
variant="accentB"
show={isDiscussionsBlackout && showBanner}
dismissible
onDismiss={() => setShowBanner(false)}
onDismiss={handleDismiss}
>
<div className="font-weight-500">
{intl.formatMessage(messages.blackoutDiscussionInformation)}
</div>
</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 { Route, Switch } from 'react-router';
import { injectIntl } from '@edx/frontend-platform/i18n';
import Spinner from '../../components/Spinner';
import { Routes } from '../../data/constants';
import { PostCommentsView } from '../post-comments';
import { PostEditor } from '../posts';
function DiscussionContent() {
const PostEditor = lazy(() => import('../posts/post-editor/PostEditor'));
const PostCommentsView = lazy(() => import('../post-comments/PostCommentsView'));
const DiscussionContent = () => {
const postEditorVisible = useSelector((state) => state.threads.postEditorVisible);
return (
<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">
{postEditorVisible ? (
<Route path={Routes.POSTS.NEW_POST}>
<PostEditor />
</Route>
) : (
<Switch>
<Route path={Routes.POSTS.EDIT_POST}>
<PostEditor editExisting />
<Suspense fallback={(<Spinner />)}>
{postEditorVisible ? (
<Route path={Routes.POSTS.NEW_POST}>
<PostEditor />
</Route>
<Route path={Routes.COMMENTS.PATH}>
<PostCommentsView />
</Route>
</Switch>
)}
) : (
<Switch>
<Route path={Routes.POSTS.EDIT_POST}>
<PostEditor editExisting />
</Route>
<Route path={Routes.COMMENTS.PATH}>
<PostCommentsView />
</Route>
</Switch>
)}
</Suspense>
</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 classNames from 'classnames';
@@ -9,18 +11,22 @@ import {
import { useWindowSize } from '@edx/paragon';
import Spinner from '../../components/Spinner';
import { RequestStatus, Routes } from '../../data/constants';
import { DiscussionContext } from '../common/context';
import {
useContainerSize, useIsOnDesktop, useIsOnXLDesktop, useShowLearnersTab,
} from '../data/hooks';
import { selectconfigLoadingStatus, selectEnableInContext } from '../data/selectors';
import { TopicPostsView, TopicsView as InContextTopicsView } from '../in-context-topics';
import { LearnerPostsView, LearnersView } from '../learners';
import { PostsView } from '../posts';
import { TopicsView as LegacyTopicsView } from '../topics';
export default function DiscussionSidebar({ displaySidebar, postActionBarRef }) {
const TopicPostsView = lazy(() => import('../in-context-topics/TopicPostsView'));
const InContextTopicsView = lazy(() => import('../in-context-topics/TopicsView'));
const LearnerPostsView = lazy(() => import('../learners/LearnerPostsView'));
const LearnersView = lazy(() => import('../learners/LearnersView'));
const PostsView = lazy(() => import('../posts/PostsView'));
const LegacyTopicsView = lazy(() => import('../topics/TopicsView'));
const DiscussionSidebar = ({ displaySidebar, postActionBarRef }) => {
const location = useLocation();
const isOnDesktop = useIsOnDesktop();
const isOnXLDesktop = useIsOnXLDesktop();
@@ -55,15 +61,16 @@ export default function DiscussionSidebar({ displaySidebar, postActionBarRef })
})}
data-testid="sidebar"
>
<Switch>
{enableInContext && !enableInContextSidebar && (
<Suspense fallback={(<Spinner />)}>
<Switch>
{enableInContext && !enableInContextSidebar && (
<Route
path={Routes.TOPICS.ALL}
component={InContextTopicsView}
exact
/>
)}
{enableInContext && !enableInContextSidebar && (
)}
{enableInContext && !enableInContextSidebar && (
<Route
path={[
Routes.TOPICS.TOPIC,
@@ -74,19 +81,19 @@ export default function DiscussionSidebar({ displaySidebar, postActionBarRef })
component={TopicPostsView}
exact
/>
)}
<Route
path={[Routes.POSTS.ALL_POSTS, Routes.POSTS.MY_POSTS, Routes.POSTS.PATH, Routes.TOPICS.CATEGORY]}
component={PostsView}
/>
<Route path={Routes.TOPICS.PATH} component={LegacyTopicsView} />
{redirectToLearnersTab && (
)}
<Route
path={[Routes.POSTS.ALL_POSTS, Routes.POSTS.MY_POSTS, Routes.POSTS.PATH, Routes.TOPICS.CATEGORY]}
component={PostsView}
/>
<Route path={Routes.TOPICS.PATH} component={LegacyTopicsView} />
{redirectToLearnersTab && (
<Route path={Routes.LEARNERS.POSTS} component={LearnerPostsView} />
)}
{redirectToLearnersTab && (
)}
{redirectToLearnersTab && (
<Route path={Routes.LEARNERS.PATH} component={LearnersView} />
)}
{configStatus === RequestStatus.SUCCESSFUL && (
)}
{configStatus === RequestStatus.SUCCESSFUL && (
<Redirect
from={Routes.DISCUSSIONS.PATH}
to={{
@@ -94,15 +101,11 @@ export default function DiscussionSidebar({ displaySidebar, postActionBarRef })
pathname: Routes.POSTS.ALL_POSTS,
}}
/>
)}
</Switch>
)}
</Switch>
</Suspense>
</div>
);
}
DiscussionSidebar.defaultProps = {
displaySidebar: false,
postActionBarRef: null,
};
DiscussionSidebar.propTypes = {
@@ -112,3 +115,10 @@ DiscussionSidebar.propTypes = {
PropTypes.shape({ current: PropTypes.instanceOf(Element) }),
]),
};
DiscussionSidebar.defaultProps = {
displaySidebar: false,
postActionBarRef: null,
};
export default React.memo(DiscussionSidebar);

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useRef } from 'react';
import React, { lazy, Suspense, useRef } from 'react';
import classNames from 'classnames';
import { useSelector } from 'react-redux';
@@ -6,12 +6,10 @@ import {
Route, Switch, useLocation, useRouteMatch,
} from 'react-router';
import Footer from '@edx/frontend-component-footer';
import { LearningHeader as Header } from '@edx/frontend-component-header';
import { getConfig } from '@edx/frontend-platform';
import { PostActionsBar } from '../../components';
import { CourseTabsNavigation } from '../../components/NavigationBar';
import { Spinner } from '../../components';
import { selectCourseTabs } from '../../components/NavigationBar/data/selectors';
import { ALL_ROUTES, DiscussionProvider, Routes } from '../../data/constants';
import { DiscussionContext } from '../common/context';
@@ -22,17 +20,21 @@ import { selectDiscussionProvider, selectEnableInContext } from '../data/selecto
import { EmptyLearners, EmptyPosts, EmptyTopics } from '../empty-posts';
import { EmptyTopic as InContextEmptyTopics } from '../in-context-topics/components';
import messages from '../messages';
import { LegacyBreadcrumbMenu, NavigationBar } from '../navigation';
import { selectPostEditorVisible } from '../posts/data/selectors';
import DiscussionsProductTour from '../tours/DiscussionsProductTour';
import { postMessageToParent } from '../utils';
import BlackoutInformationBanner from './BlackoutInformationBanner';
import DiscussionContent from './DiscussionContent';
import DiscussionSidebar from './DiscussionSidebar';
import useFeedbackWrapper from './FeedbackWrapper';
import InformationBanner from './InformationBanner';
export default function DiscussionsHome() {
const Footer = lazy(() => import('@edx/frontend-component-footer'));
const PostActionsBar = lazy(() => import('../posts/post-actions-bar/PostActionsBar'));
const CourseTabsNavigation = lazy(() => import('../../components/NavigationBar/CourseTabsNavigation'));
const LegacyBreadcrumbMenu = lazy(() => import('../navigation/breadcrumb-menu/LegacyBreadcrumbMenu'));
const NavigationBar = lazy(() => import('../navigation/navigation-bar/NavigationBar'));
const DiscussionsProductTour = lazy(() => import('../tours/DiscussionsProductTour'));
const BlackoutInformationBanner = lazy(() => import('./BlackoutInformationBanner'));
const DiscussionContent = lazy(() => import('./DiscussionContent'));
const DiscussionSidebar = lazy(() => import('./DiscussionSidebar'));
const InformationBanner = lazy(() => import('./InformationBanner'));
const DiscussionsHome = () => {
const location = useLocation();
const postActionBarRef = useRef(null);
const postEditorVisible = useSelector(selectPostEditorVisible);
@@ -40,7 +42,6 @@ export default function DiscussionsHome() {
const enableInContext = useSelector(selectEnableInContext);
const { courseNumber, courseTitle, org } = useSelector(selectCourseTabs);
const { params: { page } } = useRouteMatch(`${Routes.COMMENTS.PAGE}?`);
const { params: { path } } = useRouteMatch(`${Routes.DISCUSSIONS.PATH}/:path*`);
const { params } = useRouteMatch(ALL_ROUTES);
const isRedirectToLearners = useShowLearnersTab();
const isOnDesktop = useIsOnDesktop();
@@ -60,54 +61,60 @@ export default function DiscussionsHome() {
const displayContentArea = (postId || postEditorVisible || (learnerUsername && postId));
if (displayContentArea) { displaySidebar = isOnDesktop; }
useEffect(() => {
if (path && path !== 'undefined') {
postMessageToParent('discussions.navigate', { path });
}
}, [path]);
return (
<DiscussionContext.Provider value={{
page,
courseId,
postId,
topicId,
enableInContextSidebar,
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 && <CourseTabsNavigation activeTab="discussion" courseId={courseId} />}
<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}
/>
<Suspense fallback={(<Spinner />)}>
<DiscussionContext.Provider value={{
page,
courseId,
postId,
topicId,
enableInContextSidebar,
category,
learnerUsername,
}}
>
{!enableInContextSidebar && (
<Header courseOrg={org} courseNumber={courseNumber} courseTitle={courseTitle} />
)}
<div className="d-flex flex-row">
<DiscussionSidebar displaySidebar={displaySidebar} postActionBarRef={postActionBarRef} />
{displayContentArea && <DiscussionContent />}
{!displayContentArea && (
<main className="container-fluid d-flex flex-column p-0 w-100" id="main" tabIndex="-1">
{!enableInContextSidebar && <CourseTabsNavigation activeTab="discussion" courseId={courseId} />}
<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 && (
<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>
<Route
path={Routes.TOPICS.PATH}
@@ -123,11 +130,16 @@ export default function DiscussionsHome() {
/>
{isRedirectToLearners && <Route path={Routes.LEARNERS.PATH} component={EmptyLearners} />}
</Switch>
)}
</div>
{!enableInContextSidebar && (
<DiscussionsProductTour />
)}
</div>
{!enableInContextSidebar && <DiscussionsProductTour />}
</main>
{!enableInContextSidebar && <Footer />}
</DiscussionContext.Provider>
</main>
{!enableInContextSidebar && <Footer />}
</DiscussionContext.Provider>
</Suspense>
);
}
};
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 { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Hyperlink, PageBanner } from '@edx/paragon';
import { selectUserIsStaff, selectUserRoles } from '../data/selectors';
import messages from '../messages';
function InformationBanner({
intl,
}) {
const InformationBanner = () => {
const intl = useIntl();
const [showBanner, setShowBanner] = useState(true);
const userRoles = useSelector(selectUserRoles);
const isAdmin = useSelector(selectUserIsStaff);
@@ -20,12 +19,16 @@ function InformationBanner({
const hideLearnMoreButton = ((userRoles.includes('Student') && userRoles.length === 1) || !userRoles.length) && !isAdmin;
const showStaffLink = isAdmin || userRoles.includes('Moderator') || userRoles.includes('Administrator');
const handleDismiss = useCallback(() => {
setShowBanner(false);
}, []);
return (
<PageBanner
variant="light"
show={showBanner}
dismissible
onDismiss={() => setShowBanner(false)}
onDismiss={handleDismiss}
>
<div className="font-weight-500">
{intl.formatMessage(messages.bannerMessage)}
@@ -55,10 +58,6 @@ function InformationBanner({
</div>
</PageBanner>
);
}
InformationBanner.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(InformationBanner);
export default InformationBanner;

View File

@@ -1,12 +1,13 @@
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 messages from '../messages';
import EmptyPage from './EmptyPage';
function EmptyLearners({ intl }) {
const EmptyLearners = () => {
const intl = useIntl();
const isOnDesktop = useIsOnDesktop();
if (!isOnDesktop) {
@@ -16,10 +17,6 @@ function EmptyLearners({ intl }) {
return (
<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';
function EmptyPage({
const EmptyPage = ({
title,
subTitle = null,
action = null,
actionText = null,
fullWidth = false,
}) {
}) => {
const containerClasses = classNames(
'min-content-height justify-content-center align-items-center d-flex w-100 flex-column',
{ 'bg-light-400': !fullWidth },
@@ -33,7 +33,7 @@ function EmptyPage({
</div>
</div>
);
}
};
EmptyPage.propTypes = {
title: propTypes.string.isRequired,
@@ -50,4 +50,4 @@ EmptyPage.defaultProps = {
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 { useDispatch, useSelector } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useIsOnDesktop } from '../data/hooks';
import { selectAreThreadsFiltered, selectPostThreadCount } from '../data/selectors';
@@ -11,16 +11,16 @@ import messages from '../messages';
import { messages as postMessages, showPostEditor } from '../posts';
import EmptyPage from './EmptyPage';
function EmptyPosts({ intl, subTitleMessage }) {
const EmptyPosts = ({ subTitleMessage }) => {
const intl = useIntl();
const dispatch = useDispatch();
const isOnDesktop = useIsOnDesktop();
const isFiltered = useSelector(selectAreThreadsFiltered);
const totalThreads = useSelector(selectPostThreadCount);
const isOnDesktop = useIsOnDesktop();
function addPost() {
return dispatch(showPostEditor());
}
const addPost = useCallback(() => (
dispatch(showPostEditor())
), []);
let title = messages.noPostSelected;
let subTitle = null;
@@ -49,7 +49,7 @@ function EmptyPosts({ intl, subTitleMessage }) {
fullWidth={fullWidth}
/>
);
}
};
EmptyPosts.propTypes = {
subTitleMessage: propTypes.shape({
@@ -57,7 +57,6 @@ EmptyPosts.propTypes = {
defaultMessage: propTypes.string,
description: propTypes.string,
}).isRequired,
intl: intlShape.isRequired,
};
export default injectIntl(EmptyPosts);
export default React.memo(EmptyPosts);

View File

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

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 { useLocation } from 'react-router-dom';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Spinner } from '@edx/paragon';
import { RequestStatus, Routes } from '../../data/constants';
import { DiscussionContext } from '../common/context';
import { selectDiscussionProvider } from '../data/selectors';
import { selectTopicThreads } from '../posts/data/selectors';
import { selectTopicThreadsIds } from '../posts/data/selectors';
import PostsList from '../posts/PostsList';
import { discussionsPath, handleKeyDown } from '../utils';
import {
@@ -21,19 +23,34 @@ import { BackButton, NoResults } from './components';
import messages from './messages';
import { Topic } from './topic';
function TopicPostsView({ intl }) {
const TopicPostsView = () => {
const intl = useIntl();
const location = useLocation();
const dispatch = useDispatch();
const { courseId, topicId, category } = useContext(DiscussionContext);
const provider = useSelector(selectDiscussionProvider);
const topicsStatus = useSelector(selectLoadingStatus);
const topicsInProgress = topicsStatus === RequestStatus.IN_PROGRESS;
const posts = useSelector(selectTopicThreads([topicId]));
const postsIds = useSelector(selectTopicThreadsIds([topicId]));
const selectedSubsectionUnits = useSelector(selectSubsectionUnits(category));
const selectedSubsection = useSelector(selectSubsection(category));
const selectedUnit = useSelector(selectUnits)?.find(unit => unit.id === topicId);
const selectedNonCoursewareTopic = useSelector(selectNonCoursewareTopics)?.find(topic => topic.id === topicId);
const units = useSelector(selectUnits);
const nonCoursewareTopics = useSelector(selectNonCoursewareTopics);
const selectedArchivedTopic = useSelector(selectArchivedTopic(topicId));
const topicsInProgress = topicsStatus === RequestStatus.IN_PROGRESS;
const selectedUnit = useMemo(() => (
units?.find(unit => unit.id === topicId)
), [units, topicId]);
const selectedNonCoursewareTopic = useMemo(() => (
nonCoursewareTopics?.find(topic => topic.id === topicId)
), [nonCoursewareTopics, topicId]);
const backButtonPath = useCallback(() => {
const path = selectedUnit ? Routes.TOPICS.CATEGORY : Routes.TOPICS.ALL;
const params = selectedUnit ? { courseId, category: selectedUnit?.parentId } : { courseId };
return discussionsPath(path, params)(location);
}, [selectedUnit]);
useEffect(() => {
if (provider && topicsStatus === RequestStatus.IDLE) {
@@ -41,12 +58,6 @@ function TopicPostsView({ intl }) {
}
}, [provider]);
const backButtonPath = () => {
const path = selectedUnit ? Routes.TOPICS.CATEGORY : Routes.TOPICS.ALL;
const params = selectedUnit ? { courseId, category: selectedUnit?.parentId } : { courseId };
return discussionsPath(path, params)(location);
};
return (
<div className="discussion-posts d-flex flex-column h-100">
{topicId ? (
@@ -67,8 +78,8 @@ function TopicPostsView({ intl }) {
<div className="list-group list-group-flush flex-fill" role="list" onKeyDown={e => handleKeyDown(e)}>
{topicId ? (
<PostsList
posts={posts}
topics={[topicId]}
postsIds={postsIds}
topicsIds={[topicId]}
parentIsLoading={topicsInProgress}
/>
) : (
@@ -90,10 +101,6 @@ function TopicPostsView({ intl }) {
</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 isEmpty from 'lodash/isEmpty';
@@ -21,30 +23,38 @@ import { setFilter } from './data/slices';
import { fetchCourseTopicsV3 } from './data/thunks';
import { ArchivedBaseGroup, SectionBaseGroup, Topic } from './topic';
function TopicsList() {
const TopicsList = () => {
const loadingStatus = useSelector(selectLoadingStatus);
const coursewareTopics = useSelector(selectCoursewareTopics);
const nonCoursewareTopics = useSelector(selectNonCoursewareTopics);
const archivedTopics = useSelector(selectArchivedTopics);
const renderNonCoursewareTopics = useMemo(() => (
nonCoursewareTopics?.map((topic, index) => (
<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 (
<>
{nonCoursewareTopics?.map((topic, index) => (
<Topic
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}
/>
))}
{renderNonCoursewareTopics}
{renderCoursewareTopics}
{!isEmpty(archivedTopics) && (
<ArchivedBaseGroup
archivedTopics={archivedTopics}
@@ -58,9 +68,9 @@ function TopicsList() {
)}
</>
);
}
};
function TopicsView() {
const TopicsView = () => {
const dispatch = useDispatch();
const { courseId } = useContext(DiscussionContext);
const provider = useSelector(selectDiscussionProvider);
@@ -83,6 +93,10 @@ function TopicsView() {
}
}, [isPostsFiltered]);
const handleOnClear = useCallback(() => {
dispatch(setFilter(''));
}, []);
return (
<div className="d-flex flex-column h-100" data-testid="inContext-topics-view">
{topicFilter && (
@@ -91,7 +105,7 @@ function TopicsView() {
text={topicFilter}
count={filteredTopics.length}
loadingStatus={loadingStatus}
onClear={() => dispatch(setFilter(''))}
onClear={handleOnClear}
/>
{filteredTopics.length === 0 && loadingStatus === RequestStatus.SUCCESSFUL && <NoResults />}
</>
@@ -116,6 +130,6 @@ function TopicsView() {
</div>
</div>
);
}
};
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 { useRouteMatch } from 'react-router';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import { ALL_ROUTES } from '../../../data/constants';
import { DiscussionContext } from '../../common/context';
@@ -14,20 +14,20 @@ import messages from '../../messages';
import { messages as postMessages, showPostEditor } from '../../posts';
import { selectCourseWareThreadsCount, selectTotalTopicsThreadsCount } from '../data/selectors';
function EmptyTopics({ intl }) {
const EmptyTopics = () => {
const intl = useIntl();
const match = useRouteMatch(ALL_ROUTES);
const dispatch = useDispatch();
const isOnDesktop = useIsOnDesktop();
const { enableInContextSidebar } = useContext(DiscussionContext);
const courseWareThreadsCount = useSelector(selectCourseWareThreadsCount(match.params.category));
const topicThreadsCount = useSelector(selectPostThreadCount);
// hasGlobalThreads is used to determine if there are any post available in courseware and non-courseware topics
const hasGlobalThreads = useSelector(selectTotalTopicsThreadsCount) > 0;
function addPost() {
return dispatch(showPostEditor());
}
const isOnDesktop = useIsOnDesktop();
const addPost = useCallback(() => (
dispatch(showPostEditor())
), []);
let title = messages.emptyTitle;
let fullWidth = false;
@@ -74,10 +74,6 @@ function EmptyTopics({ intl }) {
fullWidth={fullWidth}
/>
);
}
EmptyTopics.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(EmptyTopics);
export default EmptyTopics;

View File

@@ -1,11 +1,14 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import { selectTopics } from '../data/selectors';
import messages from '../messages';
function NoResults({ intl }) {
const NoResults = () => {
const intl = useIntl();
const topics = useSelector(selectTopics);
const title = messages.nothingHere;
@@ -20,10 +23,6 @@ function NoResults({ intl }) {
{ helpMessage && <small className="font-weight-normal text-gray-700">{intl.formatMessage(helpMessage)}</small>}
</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 { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Icon, SearchField } from '@edx/paragon';
import { Search as SearchIcon } from '@edx/paragon/icons';
@@ -10,56 +10,51 @@ import { DiscussionContext } from '../../common/context';
import postsMessages from '../../posts/post-actions-bar/messages';
import { setFilter as setTopicFilter } from '../data/slices';
function TopicSearchBar({ intl }) {
const TopicSearchBar = () => {
const intl = useIntl();
const dispatch = useDispatch();
const { page } = useContext(DiscussionContext);
const topicSearch = useSelector(({ inContextTopics }) => inContextTopics.filter);
let searchValue = '';
const onClear = () => {
const onClear = useCallback(() => {
dispatch(setTopicFilter(''));
};
}, []);
const onChange = (query) => {
const onChange = useCallback((query) => {
searchValue = query;
};
}, []);
const onSubmit = (query) => {
const onSubmit = useCallback((query) => {
if (query === '') {
return;
}
dispatch(setTopicFilter(query));
};
}, []);
useEffect(() => onClear(), [page]);
return (
<>
<SearchField.Advanced
onClear={onClear}
onChange={onChange}
onSubmit={onSubmit}
value={topicSearch}
>
<SearchField.Label />
<SearchField.Input
style={{ paddingRight: '1rem' }}
placeholder={intl.formatMessage(postsMessages.search, { page: 'topics' })}
<SearchField.Advanced
onClear={onClear}
onChange={onChange}
onSubmit={onSubmit}
value={topicSearch}
>
<SearchField.Label />
<SearchField.Input
style={{ paddingRight: '1rem' }}
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">
<Icon
src={SearchIcon}
onClick={() => onSubmit(searchValue)}
data-testid="search-icon"
/>
</span>
</SearchField.Advanced>
</>
</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 { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import messages from '../messages';
import Topic, { topicShape } from './Topic';
function ArchivedBaseGroup({
const ArchivedBaseGroup = ({
archivedTopics,
showDivider,
intl,
}) {
}) => {
const intl = useIntl();
const renderArchivedTopics = useMemo(() => (
archivedTopics?.map((topic, index) => (
<Topic
key={topic.id}
topic={topic}
showDivider={(archivedTopics.length - 1) !== index}
/>
))
), [archivedTopics]);
return (
<>
{showDivider && (
@@ -24,25 +35,18 @@ function ArchivedBaseGroup({
data-testid="archived-group"
>
<div className="pt-3 px-4 font-weight-bold">{intl.formatMessage(messages.archivedTopics)}</div>
{archivedTopics?.map((topic, index) => (
<Topic
key={topic.id}
topic={topic}
showDivider={(archivedTopics.length - 1) !== index}
/>
))}
{renderArchivedTopics}
</div>
</>
);
}
};
ArchivedBaseGroup.propTypes = {
archivedTopics: PropTypes.arrayOf(topicShape).isRequired,
showDivider: PropTypes.bool,
intl: intlShape.isRequired,
};
ArchivedBaseGroup.defaultProps = {
showDivider: false,
};
export default injectIntl(ArchivedBaseGroup);
export default React.memo(ArchivedBaseGroup);

View File

@@ -1,11 +1,11 @@
import React from 'react';
import React, { useCallback, useMemo } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useParams } from 'react-router';
import { Link } from 'react-router-dom';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import TopicStats from '../../../components/TopicStats';
import { Routes } from '../../../data/constants';
@@ -13,19 +13,52 @@ import { discussionsPath } from '../../utils';
import messages from '../messages';
import { topicShape } from './Topic';
function SectionBaseGroup({
const SectionBaseGroup = ({
section,
sectionTitle,
sectionId,
showDivider,
intl,
}) {
}) => {
const intl = useIntl();
const { courseId } = useParams();
const isSelected = (id) => window.location.pathname.includes(id);
const sectionUrl = (id) => discussionsPath(Routes.TOPICS.CATEGORY, {
const isSelected = useCallback((id) => (
window.location.pathname.includes(id)
), []);
const sectionUrl = useCallback((id) => discussionsPath(Routes.TOPICS.CATEGORY, {
courseId,
category: id,
});
}), [courseId]);
const renderSection = useMemo(() => (
section?.map((subsection, index) => (
<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 (
<div
@@ -36,32 +69,7 @@ function SectionBaseGroup({
<div className="pt-3 px-4 font-weight-bold">
{sectionTitle || intl.formatMessage(messages.unnamedSection)}
</div>
{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>
))}
{renderSection}
{showDivider && (
<>
<div className="divider border-top border-light-500" />
@@ -70,7 +78,7 @@ function SectionBaseGroup({
)}
</div>
);
}
};
SectionBaseGroup.propTypes = {
section: PropTypes.arrayOf(PropTypes.shape({
@@ -86,7 +94,6 @@ SectionBaseGroup.propTypes = {
sectionTitle: PropTypes.string.isRequired,
sectionId: PropTypes.string.isRequired,
showDivider: PropTypes.bool.isRequired,
intl: intlShape.isRequired,
};
export default injectIntl(SectionBaseGroup);
export default React.memo(SectionBaseGroup);

View File

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

View File

@@ -6,7 +6,7 @@ import capitalize from 'lodash/capitalize';
import { useDispatch, useSelector } from 'react-redux';
import { useHistory, useLocation } from 'react-router-dom';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
Button, Icon, IconButton, Spinner,
} from '@edx/paragon';
@@ -18,33 +18,36 @@ import {
} from '../../data/constants';
import { DiscussionContext } from '../common/context';
import { selectUserHasModerationPrivileges, selectUserIsStaff } from '../data/selectors';
import { usePostList } from '../posts/data/hooks';
import {
selectAllThreads,
selectAllThreadsIds,
selectThreadNextPage,
threadsLoadingStatus,
} from '../posts/data/selectors';
import { clearPostsPages } from '../posts/data/slices';
import NoResults from '../posts/NoResults';
import { PostLink } from '../posts/post';
import { discussionsPath, filterPosts } from '../utils';
import { discussionsPath } from '../utils';
import { fetchUserPosts } from './data/thunks';
import LearnerPostFilterBar from './learner-post-filter-bar/LearnerPostFilterBar';
import messages from './messages';
function LearnerPostsView({ intl }) {
const LearnerPostsView = () => {
const intl = useIntl();
const location = useLocation();
const history = useHistory();
const dispatch = useDispatch();
const posts = useSelector(selectAllThreads);
const postsIds = useSelector(selectAllThreadsIds);
const loadingStatus = useSelector(threadsLoadingStatus());
const postFilter = useSelector(state => state.learners.postFilter);
const { courseId, learnerUsername: username } = useContext(DiscussionContext);
const nextPage = useSelector(selectThreadNextPage());
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
const userIsStaff = useSelector(selectUserIsStaff);
const sortedPostsIds = usePostList(postsIds);
const loadMorePosts = (pageNum = undefined) => {
const loadMorePosts = useCallback((pageNum = undefined) => {
const params = {
author: username,
page: pageNum,
@@ -54,29 +57,24 @@ function LearnerPostsView({ intl }) {
};
dispatch(fetchUserPosts(courseId, params));
};
}, [courseId, postFilter, username, userHasModerationPrivileges, userIsStaff]);
const postInstances = useMemo(() => (
sortedPostsIds?.map((postId, idx) => (
<PostLink
postId={postId}
idx={idx}
key={postId}
showDivider={(sortedPostsIds.length - 1) !== idx}
/>
))
), [sortedPostsIds]);
useEffect(() => {
dispatch(clearPostsPages());
loadMorePosts();
}, [courseId, postFilter, username]);
const checkIsSelected = (id) => window.location.pathname.includes(id);
const pinnedPosts = useMemo(() => filterPosts(posts, 'pinned'), [posts]);
const unpinnedPosts = useMemo(() => filterPosts(posts, 'unpinned'), [posts]);
const postInstances = useCallback((sortedPosts) => (
sortedPosts.map((post, idx) => (
<PostLink
post={post}
key={post.id}
isSelected={checkIsSelected}
idx={idx}
showDivider={(sortedPosts.length - 1) !== idx}
/>
))
), []);
return (
<div className="discussion-posts d-flex flex-column">
<div className="d-flex align-items-center justify-content-between px-2.5">
@@ -97,9 +95,8 @@ function LearnerPostsView({ intl }) {
<LearnerPostFilterBar />
<div className="border-bottom border-light-400" />
<div className="list-group list-group-flush">
{postInstances(pinnedPosts)}
{postInstances(unpinnedPosts)}
{loadingStatus !== RequestStatus.IN_PROGRESS && posts?.length === 0 && <NoResults />}
{postInstances}
{loadingStatus !== RequestStatus.IN_PROGRESS && sortedPostsIds?.length === 0 && <NoResults />}
{loadingStatus === RequestStatus.IN_PROGRESS ? (
<div className="d-flex justify-content-center p-4">
<Spinner animation="border" variant="primary" size="lg" />
@@ -114,10 +111,6 @@ function LearnerPostsView({ intl }) {
</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 {
Redirect, useLocation, useParams,
} from 'react-router';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Button, Spinner } from '@edx/paragon';
import SearchInfo from '../../components/SearchInfo';
@@ -24,7 +24,8 @@ import { fetchLearners } from './data/thunks';
import { LearnerCard, LearnerFilterBar } from './learner';
import messages from './messages';
function LearnersView({ intl }) {
const LearnersView = () => {
const intl = useIntl();
const { courseId } = useParams();
const location = useLocation();
const dispatch = useDispatch();
@@ -46,7 +47,7 @@ function LearnersView({ intl }) {
}
}, [courseId, orderBy, learnersTabEnabled, usernameSearch]);
const loadPage = async () => {
const loadPage = useCallback(async () => {
if (nextPage) {
dispatch(fetchLearners(courseId, {
orderBy,
@@ -54,7 +55,19 @@ function LearnersView({ intl }) {
usernameSearch,
}));
}
};
}, [courseId, orderBy, nextPage, usernameSearch]);
const handleOnClear = useCallback(() => {
dispatch(setUsernameSearch(''));
}, []);
const renderLearnersList = useMemo(() => (
(
courseConfigLoadingStatus === RequestStatus.SUCCESSFUL && learnersTabEnabled && learners.map((learner) => (
<LearnerCard learner={learner} key={learner.username} />
))
) || <></>
), [courseConfigLoadingStatus, learnersTabEnabled, learners]);
return (
<div className="d-flex flex-column border-right border-light-400">
@@ -65,7 +78,7 @@ function LearnersView({ intl }) {
text={usernameSearch}
count={learners.length}
loadingStatus={loadingStatus}
onClear={() => dispatch(setUsernameSearch(''))}
onClear={handleOnClear}
/>
)}
<div className="list-group list-group-flush learner" role="list">
@@ -77,12 +90,7 @@ function LearnersView({ intl }) {
}}
/>
)}
{courseConfigLoadingStatus === RequestStatus.SUCCESSFUL
&& learnersTabEnabled
&& learners.map((learner, index) => (
// eslint-disable-next-line react/no-array-index-key
<LearnerCard learner={learner} key={index} courseId={courseId} />
))}
{renderLearnersList}
{loadingStatus === RequestStatus.IN_PROGRESS ? (
<div className="d-flex justify-content-center p-4">
<Spinner animation="border" variant="primary" size="lg" />
@@ -98,10 +106,6 @@ function LearnersView({ intl }) {
</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 PropTypes from 'prop-types';
import { Avatar } from '@edx/paragon';
import { learnerShape } from './proptypes';
function LearnerAvatar({ learner }) {
return (
<div className="mr-3 mt-1">
<Avatar
size="sm"
alt={learner.username}
style={{
height: '2rem',
width: '2rem',
}}
/>
</div>
);
}
const LearnerAvatar = ({ username }) => (
<div className="mr-3 mt-1">
<Avatar
size="sm"
alt={username}
style={{
height: '2rem',
width: '2rem',
}}
/>
</div>
);
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 PropTypes from 'prop-types';
import { Link } from 'react-router-dom';
import { injectIntl } from '@edx/frontend-platform/i18n';
import { Routes } from '../../../data/constants';
import { DiscussionContext } from '../../common/context';
import { discussionsPath } from '../../utils';
@@ -12,11 +9,11 @@ import LearnerAvatar from './LearnerAvatar';
import LearnerFooter from './LearnerFooter';
import { learnerShape } from './proptypes';
function LearnerCard({
learner,
courseId,
}) {
const { enableInContextSidebar, learnerUsername } = useContext(DiscussionContext);
const LearnerCard = ({ learner }) => {
const {
username, threads, inactiveFlags, activeFlags, responses, replies,
} = learner;
const { enableInContextSidebar, learnerUsername, courseId } = useContext(DiscussionContext);
const linkUrl = discussionsPath(Routes.LEARNERS.POSTS, {
0: enableInContextSidebar ? 'in-context' : undefined,
learnerUsername: learner.username,
@@ -30,32 +27,40 @@ function LearnerCard({
>
<div
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',
borderRightStyle: 'solid',
} : null}
>
<LearnerAvatar learner={learner} />
<LearnerAvatar username={username} />
<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 align-items-center flex-fill">
<div
className="text-truncate font-weight-500 font-size-14 text-primary-500 font-style"
>
{learner.username}
{username}
</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>
</Link>
);
}
};
LearnerCard.propTypes = {
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 classNames from 'classnames';
import { useDispatch, useSelector } from 'react-redux';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Collapsible, Form, Icon } from '@edx/paragon';
import { Check, Tune } from '@edx/paragon/icons';
@@ -15,7 +15,7 @@ import { setSortedBy } from '../data';
import { selectLearnerSorting } from '../data/selectors';
import messages from '../messages';
const ActionItem = ({
const ActionItem = React.memo(({
id,
label,
value,
@@ -38,7 +38,7 @@ const ActionItem = ({
{label}
</span>
</label>
);
));
ActionItem.propTypes = {
id: PropTypes.string.isRequired,
@@ -47,16 +47,15 @@ ActionItem.propTypes = {
selected: PropTypes.string.isRequired,
};
function LearnerFilterBar({
intl,
}) {
const LearnerFilterBar = () => {
const intl = useIntl();
const dispatch = useDispatch();
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
const userIsGroupTa = useSelector(selectUserIsGroupTa);
const currentSorting = useSelector(selectLearnerSorting());
const [isOpen, setOpen] = useState(false);
const handleSortFilterChange = (event) => {
const handleSortFilterChange = useCallback((event) => {
const { name, value } = event.currentTarget;
if (name === 'sort') {
@@ -68,12 +67,16 @@ function LearnerFilterBar({
},
);
}
};
}, []);
const handleOnToggle = useCallback(() => {
setOpen(!isOpen);
}, [isOpen]);
return (
<Collapsible.Advanced
open={isOpen}
onToggle={() => setOpen(!isOpen)}
onToggle={handleOnToggle}
className="filter-bar collapsible-card-lg border-0"
>
<Collapsible.Trigger className="collapsible-trigger border-0">
@@ -124,10 +127,6 @@ function LearnerFilterBar({
</Collapsible.Body>
</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 PropTypes from 'prop-types';
import { useSelector } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Icon, OverlayTrigger, Tooltip } from '@edx/paragon';
import { Edit, Report, ReportGmailerrorred } from '@edx/paragon/icons';
import { QuestionAnswerOutline } from '../../../components/icons';
import { selectUserHasModerationPrivileges, selectUserIsGroupTa } from '../../data/selectors';
import messages from '../messages';
import { learnerShape } from './proptypes';
function LearnerFooter({
learner,
intl,
}) {
const LearnerFooter = ({
inactiveFlags, activeFlags, threads, responses, replies, username,
}) => {
const intl = useIntl();
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
const userIsGroupTa = useSelector(selectUserIsGroupTa);
const { inactiveFlags } = learner;
const { activeFlags } = learner;
const canSeeLearnerReportedStats = (activeFlags || inactiveFlags) && (userHasModerationPrivileges || userIsGroupTa);
return (
@@ -35,7 +33,7 @@ function LearnerFooter({
>
<div className="d-flex align-items-center">
<Icon src={QuestionAnswerOutline} className="icon-size mr-2" />
{learner.threads + learner.responses + learner.replies}
{threads + responses + replies}
</div>
</OverlayTrigger>
<OverlayTrigger
@@ -50,14 +48,14 @@ function LearnerFooter({
>
<div className="d-flex align-items-center">
<Icon src={Edit} className="icon-size mr-2 ml-4" />
{learner.threads}
{threads}
</div>
</OverlayTrigger>
{Boolean(canSeeLearnerReportedStats) && (
<OverlayTrigger
placement="right"
overlay={(
<Tooltip id={`learner-${learner.username}`}>
<Tooltip id={`learner-${username}`}>
<div className="d-flex flex-column align-items-start">
{Boolean(activeFlags)
&& (
@@ -83,11 +81,24 @@ function LearnerFooter({
)}
</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 { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Dropdown, DropdownButton } from '@edx/paragon';
import messages from './messages';
function BreadcrumbDropdown({
const BreadcrumbDropdown = ({
currentItem,
intl,
showAllPath,
items,
itemPathFunc,
itemLabelFunc,
itemActiveFunc,
itemFilterFunc,
}) {
}) => {
const intl = useIntl();
const showAllMsg = intl.formatMessage(messages.showAll);
return (
<DropdownButton
title={itemLabelFunc(currentItem) || showAllMsg}
@@ -46,12 +47,11 @@ function BreadcrumbDropdown({
))}
</DropdownButton>
);
}
};
BreadcrumbDropdown.propTypes = {
// eslint-disable-next-line react/forbid-prop-types
currentItem: PropTypes.any,
intl: intlShape.isRequired,
showAllPath: PropTypes.func.isRequired,
// eslint-disable-next-line react/forbid-prop-types
items: PropTypes.array.isRequired,
@@ -60,9 +60,10 @@ BreadcrumbDropdown.propTypes = {
itemActiveFunc: PropTypes.func.isRequired,
itemFilterFunc: PropTypes.func,
};
BreadcrumbDropdown.defaultProps = {
currentItem: null,
itemFilterFunc: null,
};
export default injectIntl(BreadcrumbDropdown);
export default React.memo(BreadcrumbDropdown);

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useCallback } from 'react';
import { useSelector } from 'react-redux';
import { useRouteMatch } from 'react-router';
@@ -13,7 +13,7 @@ import {
import { discussionsPath } from '../../utils';
import BreadcrumbDropdown from './BreadcrumbDropdown';
function LegacyBreadcrumbMenu() {
const LegacyBreadcrumbMenu = () => {
const {
params: {
courseId,
@@ -21,7 +21,6 @@ function LegacyBreadcrumbMenu() {
topicId: currentTopicId,
},
} = useRouteMatch([Routes.TOPICS.CATEGORY, Routes.TOPICS.TOPIC]);
const currentTopic = useSelector(selectTopic(currentTopicId));
const currentCategory = category || currentTopic?.categoryId;
const decodedCurrentCategory = String(currentCategory).replace('%23', '#');
@@ -30,31 +29,68 @@ function LegacyBreadcrumbMenu() {
const categories = useSelector(selectCategories);
const isNonCoursewareTopic = currentTopic && !currentCategory;
const nonCoursewareItemLabel = useCallback((item) => (
item?.name
), []);
const nonCoursewareActive = useCallback((topic) => (
topic?.id === currentTopicId
), [currentTopicId]);
const nonCoursewareItemPath = useCallback((topic) => (
discussionsPath(Routes.TOPICS.TOPIC, {
courseId,
topicId: topic.id,
})
), [courseId]);
const coursewareItemLabel = useCallback((catId) => (
catId
), []);
const coursewareActive = useCallback((catId) => (
catId === currentCategory
), [currentTopicId]);
const coursewareItemPath = useCallback((catId) => (
discussionsPath(Routes.TOPICS.CATEGORY, {
courseId,
category: catId,
})
), [courseId]);
const categoryItemLabel = useCallback((item) => item?.name, []);
const categoryActive = useCallback((topic) => (
topic?.id === currentTopicId
), [currentTopicId]);
const categoryItemPath = useCallback((topic) => (
discussionsPath(Routes.TOPICS.TOPIC, {
courseId,
topicId: topic.id,
})
), [courseId]);
return (
<div className="breadcrumb-menu d-flex flex-row bg-light-200 box-shadow-down-1 px-2.5 py-1">
{isNonCoursewareTopic ? (
<BreadcrumbDropdown
currentItem={currentTopic}
itemLabelFunc={(item) => item?.name}
itemActiveFunc={(topic) => topic?.id === currentTopicId}
itemLabelFunc={nonCoursewareItemLabel}
itemActiveFunc={nonCoursewareActive}
items={nonCoursewareTopics}
showAllPath={discussionsPath(Routes.TOPICS.ALL, { courseId })}
itemPathFunc={(topic) => discussionsPath(Routes.TOPICS.TOPIC, {
courseId,
topicId: topic.id,
})}
itemPathFunc={nonCoursewareItemPath}
/>
) : (
<BreadcrumbDropdown
currentItem={decodedCurrentCategory}
itemLabelFunc={(catId) => catId}
itemActiveFunc={(catId) => catId === currentCategory}
itemLabelFunc={coursewareItemLabel}
itemActiveFunc={coursewareActive}
items={categories}
showAllPath={discussionsPath(Routes.TOPICS.ALL, { courseId })}
itemPathFunc={(catId) => discussionsPath(Routes.TOPICS.CATEGORY, {
courseId,
category: catId,
})}
itemPathFunc={coursewareItemPath}
/>
)}
{currentCategory && (
@@ -62,24 +98,19 @@ function LegacyBreadcrumbMenu() {
<div className="d-flex py-2">/</div>
<BreadcrumbDropdown
currentItem={currentTopic}
itemLabelFunc={(item) => item?.name}
itemActiveFunc={(topic) => topic?.id === currentTopicId}
itemLabelFunc={categoryItemLabel}
itemActiveFunc={categoryActive}
items={topicsInCategory}
showAllPath={discussionsPath(Routes.TOPICS.CATEGORY, {
courseId,
category: currentCategory,
})}
itemPathFunc={(topic) => discussionsPath(Routes.TOPICS.TOPIC, {
courseId,
topicId: topic.id,
})}
itemPathFunc={categoryItemPath}
/>
</>
)}
</div>
);
}
LegacyBreadcrumbMenu.propTypes = {};
};
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 { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Nav } from '@edx/paragon';
import { Routes } from '../../../data/constants';
import { DiscussionContext } from '../../common/context';
import { useShowLearnersTab } from '../../data/hooks';
import { discussionsPath } from '../../utils';
import messages from './messages';
function NavigationBar({ intl }) {
const { courseId } = useParams();
const NavigationBar = () => {
const intl = useIntl();
const { courseId } = useContext(DiscussionContext);
const showLearnersTab = useShowLearnersTab();
const navLinks = [
const navLinks = useMemo(() => ([
{
route: Routes.POSTS.MY_POSTS,
labelMessage: messages.myPosts,
@@ -29,19 +31,23 @@ function NavigationBar({ intl }) {
isActive: (match, location) => Boolean(matchPath(location.pathname, { path: Routes.TOPICS.PATH })),
labelMessage: messages.allTopics,
},
];
if (showLearnersTab) {
navLinks.push({
route: Routes.LEARNERS.PATH,
labelMessage: messages.learners,
});
}
]), []);
useMemo(() => {
if (showLearnersTab) {
navLinks.push({
route: Routes.LEARNERS.PATH,
labelMessage: messages.learners,
});
}
}, [showLearnersTab]);
return (
<Nav variant="button-group" className="py-2">
<Nav variant="pills" className="py-2 nav-button-group">
{navLinks.map(link => (
<Nav.Item key={link.route}>
<Nav.Link
key={link.route}
as={NavLink}
to={discussionsPath(link.route, { courseId })}
isActive={link.isActive}
@@ -52,10 +58,6 @@ function NavigationBar({ intl }) {
))}
</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 { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import {
Button, Icon, IconButton, Spinner,
} from '@edx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Button, Icon, IconButton } from '@edx/paragon';
import { ArrowBack } from '@edx/paragon/icons';
import Spinner from '../../components/Spinner';
import { EndorsementStatus, PostsPages, ThreadType } from '../../data/constants';
import { useDispatchWithState } from '../../data/hooks';
import { DiscussionContext } from '../common/context';
@@ -18,30 +18,45 @@ import { Post } from '../posts';
import { fetchThread } from '../posts/data/thunks';
import { discussionsPath } from '../utils';
import { ResponseEditor } from './comments/comment';
import CommentsSort from './comments/CommentsSort';
import CommentsView from './comments/CommentsView';
import { useCommentsCount, usePost } from './data/hooks';
import messages from './messages';
import { PostCommentsContext } from './postCommentsContext';
function PostCommentsView({ intl }) {
const [isLoading, submitDispatch] = useDispatchWithState();
const { postId } = useParams();
const thread = usePost(postId);
const commentsCount = useCommentsCount(postId);
const CommentsSort = React.lazy(() => import('./comments/CommentsSort'));
const CommentsView = React.lazy(() => import('./comments/CommentsView'));
const PostCommentsView = () => {
const intl = useIntl();
const history = useHistory();
const location = useLocation();
const isOnDesktop = useIsOnDesktop();
const [addingResponse, setAddingResponse] = useState(false);
const [isLoading, submitDispatch] = useDispatchWithState();
const {
courseId, learnerUsername, category, topicId, page, enableInContextSidebar,
courseId, learnerUsername, category, topicId, page, enableInContextSidebar, postId,
} = useContext(DiscussionContext);
const commentsCount = useCommentsCount(postId);
const { closed, id: threadId, type } = usePost(postId);
useEffect(() => {
if (!thread) { submitDispatch(fetchThread(postId, courseId, true)); }
setAddingResponse(false);
}, [postId]);
if (!postId) {
submitDispatch(fetchThread(postId, courseId, true));
}
if (!thread) {
return () => {
setAddingResponse(false);
};
}, [postId, courseId]);
const handleAddResponseButton = useCallback(() => {
setAddingResponse(true);
}, []);
const handleCloseEditor = useCallback(() => {
setAddingResponse(false);
}, []);
if (!threadId) {
if (!isLoading) {
return (
<EmptyPage title={intl.formatMessage(messages.noThreadFound)} />
@@ -59,7 +74,12 @@ function PostCommentsView({ intl }) {
}
return (
<>
<PostCommentsContext.Provider value={{
isClosed: closed,
postType: type,
postId,
}}
>
{!isOnDesktop && (
enableInContextSidebar ? (
<>
@@ -95,49 +115,28 @@ function PostCommentsView({ intl }) {
<div
className="discussion-comments d-flex flex-column card border-0 post-card-margin post-card-padding on-focus"
>
<Post post={thread} handleAddResponseButton={() => setAddingResponse(true)} />
{!thread.closed && (
<Post handleAddResponseButton={handleAddResponseButton} />
{!closed && (
<ResponseEditor
postId={postId}
handleCloseEditor={() => setAddingResponse(false)}
handleCloseEditor={handleCloseEditor}
addingResponse={addingResponse}
/>
)}
</div>
{!!commentsCount && <CommentsSort />}
{thread.type === ThreadType.DISCUSSION && (
<CommentsView
postId={postId}
intl={intl}
postType={thread.type}
endorsed={EndorsementStatus.DISCUSSION}
isClosed={thread.closed}
/>
)}
{thread.type === ThreadType.QUESTION && (
<>
<CommentsView
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}
/>
</>
)}
</>
<Suspense fallback={(<Spinner />)}>
{!!commentsCount && <CommentsSort />}
{type === ThreadType.DISCUSSION && (
<CommentsView endorsed={EndorsementStatus.DISCUSSION} />
)}
{type === ThreadType.QUESTION && (
<>
<CommentsView endorsed={EndorsementStatus.ENDORSED} />
<CommentsView endorsed={EndorsementStatus.UNENDORSED} />
</>
)}
</Suspense>
</PostCommentsContext.Provider>
);
}
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 { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
Button, Dropdown, ModalPopup, useToggle,
} from '@edx/paragon';
@@ -13,15 +13,17 @@ import { selectCommentSortOrder } from '../data/selectors';
import { setCommentSortOrder } from '../data/slices';
import messages from '../messages';
function CommentSortDropdown({ intl }) {
const CommentSortDropdown = () => {
const intl = useIntl();
const dispatch = useDispatch();
const sortedOrder = useSelector(selectCommentSortOrder);
const [isOpen, open, close] = useToggle(false);
const [target, setTarget] = useState(null);
const handleActions = (reverseOrder) => {
const handleActions = useCallback((reverseOrder) => {
close();
dispatch(setCommentSortOrder(reverseOrder));
};
}, []);
const enableCommentsSortTour = useCallback((enabled) => {
const data = {
@@ -94,11 +96,6 @@ function CommentSortDropdown({ intl }) {
</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 { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Button, Spinner } from '@edx/paragon';
import { EndorsementStatus } from '../../../data/constants';
import { useUserCanAddThreadInBlackoutDate } from '../../data/hooks';
import { filterPosts, isLastElementOfList } from '../../utils';
import { isLastElementOfList } from '../../utils';
import { usePostComments } from '../data/hooks';
import messages from '../messages';
import { PostCommentsContext } from '../postCommentsContext';
import { Comment, ResponseEditor } from './comment';
function CommentsView({
postType,
postId,
intl,
endorsed,
isClosed,
}) {
const CommentsView = ({ endorsed }) => {
const intl = useIntl();
const [addingResponse, setAddingResponse] = useState(false);
const { isClosed } = useContext(PostCommentsContext);
const userCanAddThreadInBlackoutDate = useUserCanAddThreadInBlackoutDate();
const {
comments,
endorsedCommentsIds,
unEndorsedCommentsIds,
hasMorePages,
isLoading,
handleLoadMoreResponses,
} = usePostComments(postId, endorsed);
} = usePostComments(endorsed);
const endorsedComments = useMemo(() => [...filterPosts(comments, 'endorsed')], [comments]);
const unEndorsedComments = useMemo(() => [...filterPosts(comments, 'unendorsed')], [comments]);
const userCanAddThreadInBlackoutDate = useUserCanAddThreadInBlackoutDate();
const [addingResponse, setAddingResponse] = useState(false);
const handleAddResponse = useCallback(() => {
setAddingResponse(true);
}, []);
const handleDefinition = (message, commentsLength) => (
const handleCloseResponseEditor = useCallback(() => {
setAddingResponse(false);
}, []);
const handleDefinition = useCallback((message, commentsLength) => (
<div
className="mx-4 my-14px text-gray-700 font-style"
role="heading"
@@ -38,17 +41,15 @@ function CommentsView({
>
{intl.formatMessage(message, { num: commentsLength })}
</div>
);
), []);
const handleComments = (postComments, showLoadMoreResponses = false) => (
const handleComments = useCallback((postCommentsIds, showLoadMoreResponses = false) => (
<div className="mx-4" role="list">
{postComments.map((comment) => (
{postCommentsIds.map((commentId) => (
<Comment
comment={comment}
key={comment.id}
postType={postType}
isClosedPost={isClosed}
marginBottom={isLastElementOfList(postComments, comment)}
commentId={commentId}
key={commentId}
marginBottom={isLastElementOfList(postCommentsIds, commentId)}
/>
))}
{hasMorePages && !isLoading && !showLoadMoreResponses && (
@@ -68,26 +69,26 @@ function CommentsView({
</div>
)}
</div>
);
), [hasMorePages, isLoading, handleLoadMoreResponses]);
return (
<>
{((hasMorePages && isLoading) || !isLoading) && (
<>
{endorsedComments.length > 0 && (
{endorsedCommentsIds.length > 0 && (
<>
{handleDefinition(messages.endorsedResponseCount, endorsedComments.length)}
{handleDefinition(messages.endorsedResponseCount, endorsedCommentsIds.length)}
{endorsed === EndorsementStatus.DISCUSSION
? handleComments(endorsedComments, true)
: handleComments(endorsedComments, false)}
? handleComments(endorsedCommentsIds, true)
: handleComments(endorsedCommentsIds, false)}
</>
)}
{endorsed !== EndorsementStatus.ENDORSED && (
<>
{handleDefinition(messages.responseCount, unEndorsedComments.length)}
{unEndorsedComments.length === 0 && <br />}
{handleComments(unEndorsedComments, false)}
{(userCanAddThreadInBlackoutDate && !!unEndorsedComments.length && !isClosed) && (
{handleDefinition(messages.responseCount, unEndorsedCommentsIds.length)}
{unEndorsedCommentsIds.length === 0 && <br />}
{handleComments(unEndorsedCommentsIds, false)}
{(userCanAddThreadInBlackoutDate && !!unEndorsedCommentsIds.length && !isClosed) && (
<div className="mx-4">
{!addingResponse && (
<Button
@@ -95,17 +96,16 @@ function CommentsView({
block="true"
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"
onClick={() => setAddingResponse(true)}
onClick={handleAddResponse}
data-testid="add-response"
>
{intl.formatMessage(messages.addResponse)}
</Button>
)}
<ResponseEditor
postId={postId}
handleCloseEditor={() => setAddingResponse(false)}
addWrappingDiv
addingResponse={addingResponse}
handleCloseEditor={handleCloseResponseEditor}
/>
</div>
)}
@@ -115,16 +115,12 @@ function CommentsView({
)}
</>
);
}
};
CommentsView.propTypes = {
postId: PropTypes.string.isRequired,
postType: PropTypes.string.isRequired,
isClosed: PropTypes.bool.isRequired,
intl: intlShape.isRequired,
endorsed: PropTypes.oneOf([
EndorsementStatus.ENDORSED, EndorsementStatus.UNENDORSED, EndorsementStatus.DISCUSSION,
]).isRequired,
};
export default injectIntl(CommentsView);
export default React.memo(CommentsView);

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

@@ -1,9 +1,8 @@
import React, {
useContext, useEffect, useRef,
useCallback, useContext, useEffect, useRef,
} from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { Formik } from 'formik';
import { isEmpty } from 'lodash';
import { useDispatch, useSelector } from 'react-redux';
@@ -13,7 +12,7 @@ import * as Yup from 'yup';
import { useIntl } from '@edx/frontend-platform/i18n';
import { AppContext } from '@edx/frontend-platform/react';
import {
Button, Card, Form, Spinner, StatefulButton,
Button, Form, Spinner, StatefulButton,
} from '@edx/paragon';
import { Help, Post } from '@edx/paragon/icons';
@@ -49,58 +48,22 @@ import { hidePostEditor } from '../data';
import { selectThread } from '../data/selectors';
import { createNewThread, fetchThread, updateExistingThread } from '../data/thunks';
import messages from './messages';
import PostTypeCard from './PostTypeCard';
function DiscussionPostType({
value,
type,
selected,
icon,
}) {
const { enableInContextSidebar } = useContext(DiscussionContext);
// Need to use regular label since Form.Label doesn't support overriding htmlFor
return (
<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({
const PostEditor = ({
editExisting,
}) {
}) => {
const intl = useIntl();
const { authenticatedUser } = useContext(AppContext);
const dispatch = useDispatch();
const editorRef = useRef(null);
const [submitting, dispatchSubmit] = useDispatchWithState();
const history = useHistory();
const location = useLocation();
const commentsPagePath = useCommentsPagePath();
const {
courseId,
postId,
} = useParams();
const dispatch = useDispatch();
const editorRef = useRef(null);
const { courseId, postId } = useParams();
const { authenticatedUser } = useContext(AppContext);
const { category, enableInContextSidebar } = useContext(DiscussionContext);
const topicId = useCurrentDiscussionTopic();
const commentsPagePath = useCommentsPagePath();
const [submitting, dispatchSubmit] = useDispatchWithState();
const enableInContext = useSelector(selectEnableInContext);
const nonCoursewareTopics = useSelector(enableInContext ? inContextNonCourseware : selectNonCoursewareTopics);
const nonCoursewareIds = useSelector(enableInContext ? inContextCoursewareIds : selectNonCoursewareIds);
@@ -114,6 +77,7 @@ function PostEditor({
const { reasonCodesEnabled, editReasons } = useSelector(selectModerationSettings);
const userIsStaff = useSelector(selectUserIsStaff);
const archivedTopics = useSelector(selectArchivedTopics);
const postEditorId = `post-editor-${editExisting ? postId : 'new'}`;
const canDisplayEditReason = (reasonCodesEnabled && editExisting
&& (userHasModerationPrivileges || userIsGroupTa || userIsStaff)
@@ -124,7 +88,7 @@ function PostEditor({
editReasonCode: Yup.string().required(intl.formatMessage(messages.editReasonCodeError)),
};
const canSelectCohort = (tId) => {
const canSelectCohort = useCallback((tId) => {
// If the user isn't privileged, they can't edit the cohort.
// If the topic is being edited the cohort can't be changed.
if (!userHasModerationPrivileges) {
@@ -135,7 +99,7 @@ function PostEditor({
}
const isCohorting = settings.alwaysDivideInlineDiscussions || settings.dividedInlineDiscussions.includes(tId);
return isCohorting;
};
}, [nonCoursewareIds, settings, userHasModerationPrivileges]);
const initialValues = {
postType: post?.type || 'discussion',
@@ -145,11 +109,13 @@ function PostEditor({
follow: isEmpty(post?.following) ? true : post?.following,
anonymous: allowAnonymous ? false : undefined,
anonymousToPeers: allowAnonymousToPeers ? false : undefined,
editReasonCode: post?.lastEdit?.reasonCode || (userIsStaff && canDisplayEditReason ? 'violates-guidelines' : undefined),
cohort: post?.cohort || 'default',
editReasonCode: post?.lastEdit?.reasonCode || (
userIsStaff && canDisplayEditReason ? 'violates-guidelines' : undefined
),
};
const hideEditor = (resetForm) => {
const hideEditor = useCallback((resetForm) => {
resetForm({ values: initialValues });
if (editExisting) {
const newLocation = discussionsPath(commentsPagePath, {
@@ -162,10 +128,14 @@ function PostEditor({
history.push(newLocation);
}
dispatch(hidePostEditor());
};
}, [postId, topicId, post?.author, category, editExisting, commentsPagePath, location]);
// null stands for no cohort restriction ("All learners" option)
const selectedCohort = (cohort) => (cohort === 'default' ? null : cohort);
const submitForm = async (values, { resetForm }) => {
const selectedCohort = useCallback((cohort) => (
cohort === 'default' ? null : cohort),
[]);
const submitForm = useCallback(async (values, { resetForm }) => {
if (editExisting) {
await dispatchSubmit(updateExistingThread(postId, {
topicId: values.topic,
@@ -195,7 +165,10 @@ function PostEditor({
editorRef.current.plugins.autosave.removeDraft();
}
hideEditor(resetForm);
};
}, [
allowAnonymous, allowAnonymousToPeers, canSelectCohort, editExisting,
enableInContextSidebar, hideEditor, postId, selectedCohort, topicId,
]);
useEffect(() => {
if (userHasModerationPrivileges && isEmpty(cohorts)) {
@@ -246,8 +219,6 @@ function PostEditor({
...editReasonCodeValidation,
});
const postEditorId = `post-editor-${editExisting ? postId : 'new'}`;
const handleInContextSelectLabel = (section, subsection) => (
`${section.displayName} / ${subsection.displayName}` || intl.formatMessage(messages.unnamedTopics)
);
@@ -258,66 +229,65 @@ function PostEditor({
initialValues={initialValues}
validationSchema={validationSchema}
onSubmit={submitForm}
>{
({
values,
errors,
touched,
handleSubmit,
handleBlur,
handleChange,
resetForm,
}) => (
<Form className="m-4 card p-4 post-form" onSubmit={handleSubmit}>
<h4 className="mb-4 font-style font-size-16" style={{ lineHeight: '16px' }}>
{editExisting
? intl.formatMessage(messages.editPostHeading)
: intl.formatMessage(messages.addPostHeading)}
</h4>
<Form.RadioSet
name="postType"
className="d-flex flex-row flex-wrap"
value={values.postType}
onChange={handleChange}
onBlur={handleBlur}
aria-label={intl.formatMessage(messages.postTitle)}
>
<DiscussionPostType
value="discussion"
selected={values.postType === 'discussion'}
type={intl.formatMessage(messages.discussionType)}
icon={<Post />}
/>
<DiscussionPostType
value="question"
selected={values.postType === 'question'}
type={intl.formatMessage(messages.questionType)}
icon={<Help />}
/>
</Form.RadioSet>
<div className="d-flex flex-row my-4.5 justify-content-between">
<Form.Group className="w-100 m-0">
<Form.Control
className="m-0"
name="topic"
as="select"
value={values.topic}
onChange={handleChange}
onBlur={handleBlur}
aria-describedby="topicAreaInput"
floatingLabel={intl.formatMessage(messages.topicArea)}
disabled={enableInContextSidebar}
>
{nonCoursewareTopics.map(topic => (
<option
key={topic.id}
value={topic.id}
>{topic.name || intl.formatMessage(messages.unnamedSubTopics)}
</option>
))}
{enableInContext ? (
<>
{coursewareTopics?.map(section => (
>{({
values,
errors,
touched,
handleSubmit,
handleBlur,
handleChange,
resetForm,
}) => (
<Form className="m-4 card p-4 post-form" onSubmit={handleSubmit}>
<h4 className="mb-4 font-style font-size-16" style={{ lineHeight: '16px' }}>
{editExisting
? intl.formatMessage(messages.editPostHeading)
: intl.formatMessage(messages.addPostHeading)}
</h4>
<Form.RadioSet
name="postType"
className="d-flex flex-row flex-wrap"
value={values.postType}
onChange={handleChange}
onBlur={handleBlur}
aria-label={intl.formatMessage(messages.postTitle)}
>
<PostTypeCard
value="discussion"
selected={values.postType === 'discussion'}
type={intl.formatMessage(messages.discussionType)}
icon={<Post />}
/>
<PostTypeCard
value="question"
selected={values.postType === 'question'}
type={intl.formatMessage(messages.questionType)}
icon={<Help />}
/>
</Form.RadioSet>
<div className="d-flex flex-row my-4.5 justify-content-between">
<Form.Group className="w-100 m-0">
<Form.Control
className="m-0"
name="topic"
as="select"
value={values.topic}
onChange={handleChange}
onBlur={handleBlur}
aria-describedby="topicAreaInput"
floatingLabel={intl.formatMessage(messages.topicArea)}
disabled={enableInContextSidebar}
>
{nonCoursewareTopics.map(topic => (
<option
key={topic.id}
value={topic.id}
>{topic.name || intl.formatMessage(messages.unnamedSubTopics)}
</option>
))}
{enableInContext ? (
<>
{coursewareTopics?.map(section => (
section?.children?.map(subsection => (
<optgroup
label={handleInContextSelectLabel(section, subsection)}
@@ -330,177 +300,172 @@ function PostEditor({
))}
</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 label={intl.formatMessage(messages.archivedTopics)}>
{archivedTopics.map(topic => (
<option key={topic.id} value={topic.id}>
{topic.name || intl.formatMessage(messages.unnamedSubTopics)}
</option>
))}
</optgroup>
)}
</>
) : (
coursewareTopics.map(categoryObj => (
<optgroup
label={categoryObj.name || intl.formatMessage(messages.unnamedTopics)}
key={categoryObj.id}
>
{categoryObj.topics.map(subtopic => (
<option key={subtopic.id} value={subtopic.id}>
{subtopic.name || intl.formatMessage(messages.unnamedSubTopics)}
</option>
))}
</optgroup>
))
)}
</Form.Control>
</Form.Group>
{canSelectCohort(values.topic) && (
<Form.Group className="w-100 ml-3 mb-0">
<Form.Control
className="m-0"
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,
})}
</optgroup>
)}
</>
) : (
coursewareTopics.map(categoryObj => (
<optgroup
label={categoryObj.name || intl.formatMessage(messages.unnamedTopics)}
key={categoryObj.id}
>
{categoryObj.topics.map(subtopic => (
<option key={subtopic.id} value={subtopic.id}>
{subtopic.name || intl.formatMessage(messages.unnamedSubTopics)}
</option>
))}
</optgroup>
))
)}
</Form.Control>
</Form.Group>
{canSelectCohort(values.topic) && (
<Form.Group className="w-100 ml-3 mb-0">
<Form.Control
className="m-0"
name="cohort"
as="select"
value={values.cohort}
onChange={handleChange}
onBlur={handleBlur}
aria-describedby="cohortAreaInput"
floatingLabel={intl.formatMessage(messages.cohortVisibility)}
>
<Form.Control
className="m-0"
name="title"
type="text"
onChange={handleChange}
onBlur={handleBlur}
aria-describedby="titleInput"
floatingLabel={intl.formatMessage(messages.postTitle)}
value={values.title}
/>
<FormikErrorFeedback name="title" />
</Form.Group>
{canDisplayEditReason && (
<Form.Group
className="w-100 ml-4 mb-0"
isInvalid={isFormikFieldInvalid('editReasonCode', {
errors,
touched,
})}
>
<Form.Control
name="editReasonCode"
className="m-0"
as="select"
value={values.editReasonCode}
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={
<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
className="m-0"
name="title"
type="text"
onChange={handleChange}
onBlur={handleBlur}
aria-describedby="titleInput"
floatingLabel={intl.formatMessage(messages.postTitle)}
value={values.title}
/>
<FormikErrorFeedback name="title" />
</Form.Group>
{canDisplayEditReason && (
<Form.Group
className="w-100 ml-4 mb-0"
isInvalid={isFormikFieldInvalid('editReasonCode', {
errors,
touched,
})}
>
<Form.Control
name="editReasonCode"
className="m-0"
as="select"
value={values.editReasonCode}
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 */
(_, editor) => {
editorRef.current = editor;
}
}
id={postEditorId}
value={values.comment}
onEditorChange={formikCompatibleHandler(handleChange, 'comment')}
onBlur={formikCompatibleHandler(handleBlur, 'comment')}
/>
<FormikErrorFeedback name="comment" />
</div>
<PostPreviewPanel htmlNode={values.comment} isPost editExisting={editExisting} />
<div className="d-flex flex-row mt-n4 w-75 text-primary font-style">
{!editExisting && (
<>
<Form.Group>
<Form.Checkbox
name="follow"
checked={values.follow}
onChange={handleChange}
onBlur={handleBlur}
className="mr-4.5"
>
<span className="font-size-14">
{intl.formatMessage(messages.followPost)}
</span>
</Form.Checkbox>
</Form.Group>
{allowAnonymousToPeers && (
<Form.Group>
<Form.Checkbox
name="anonymousToPeers"
checked={values.anonymousToPeers}
onChange={handleChange}
onBlur={handleBlur}
>
<span className="font-size-14">
{intl.formatMessage(messages.anonymousToPeersPost)}
</span>
</Form.Checkbox>
</Form.Group>
)}
</>
id={postEditorId}
value={values.comment}
onEditorChange={formikCompatibleHandler(handleChange, 'comment')}
onBlur={formikCompatibleHandler(handleBlur, 'comment')}
/>
<FormikErrorFeedback name="comment" />
</div>
<PostPreviewPanel htmlNode={values.comment} isPost editExisting={editExisting} />
<div className="d-flex flex-row mt-n4 w-75 text-primary font-style">
{!editExisting && (
<>
<Form.Group>
<Form.Checkbox
name="follow"
checked={values.follow}
onChange={handleChange}
onBlur={handleBlur}
className="mr-4.5"
>
<span className="font-size-14">
{intl.formatMessage(messages.followPost)}
</span>
</Form.Checkbox>
</Form.Group>
{allowAnonymousToPeers && (
<Form.Group>
<Form.Checkbox
name="anonymousToPeers"
checked={values.anonymousToPeers}
onChange={handleChange}
onBlur={handleBlur}
>
<span className="font-size-14">
{intl.formatMessage(messages.anonymousToPeersPost)}
</span>
</Form.Checkbox>
</Form.Group>
)}
</div>
<div className="d-flex justify-content-end">
<Button
variant="outline-primary"
onClick={() => hideEditor(resetForm)}
>
{intl.formatMessage(messages.cancel)}
</Button>
<StatefulButton
labels={{
default: intl.formatMessage(messages.submit),
pending: intl.formatMessage(messages.submitting),
}}
state={submitting ? 'pending' : 'default'}
className="ml-2"
variant="primary"
onClick={handleSubmit}
/>
</div>
</Form>
)
}
</>
)}
</div>
<div className="d-flex justify-content-end">
<Button
variant="outline-primary"
onClick={() => hideEditor(resetForm)}
>
{intl.formatMessage(messages.cancel)}
</Button>
<StatefulButton
labels={{
default: intl.formatMessage(messages.submit),
pending: intl.formatMessage(messages.submitting),
}}
state={submitting ? 'pending' : 'default'}
className="ml-2"
variant="primary"
onClick={handleSubmit}
/>
</div>
</Form>
)}
</Formik>
);
}
};
PostEditor.propTypes = {
editExisting: PropTypes.bool,
@@ -510,4 +475,4 @@ PostEditor.defaultProps = {
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, {
useContext, useEffect, useMemo, useState,
useCallback, useContext, useEffect, useMemo, useState,
} from 'react';
import PropTypes from 'prop-types';
@@ -9,7 +9,7 @@ import { useDispatch, useSelector } from 'react-redux';
import { useParams } from 'react-router-dom';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
Collapsible, Form, Icon, Spinner,
} from '@edx/paragon';
@@ -29,7 +29,7 @@ import {
import { selectThreadFilters, selectThreadSorting } from '../data/selectors';
import messages from './messages';
export const ActionItem = ({
export const ActionItem = React.memo(({
id,
label,
value,
@@ -52,7 +52,7 @@ export const ActionItem = ({
{label}
</span>
</label>
);
));
ActionItem.propTypes = {
id: PropTypes.string.isRequired,
@@ -61,9 +61,8 @@ ActionItem.propTypes = {
selected: PropTypes.string.isRequired,
};
function PostFilterBar({
intl,
}) {
const PostFilterBar = () => {
const intl = useIntl();
const dispatch = useDispatch();
const { courseId } = useParams();
const { page } = useContext(DiscussionContext);
@@ -75,11 +74,13 @@ function PostFilterBar({
const cohorts = useSelector(selectCourseCohorts);
const [isOpen, setOpen] = useState(false);
const selectedCohort = useMemo(() => cohorts.find(cohort => (
toString(cohort.id) === currentFilters.cohort)),
[currentFilters.cohort]);
const selectedCohort = useMemo(() => (
cohorts.find(cohort => (
toString(cohort.id) === currentFilters.cohort
))
), [cohorts, currentFilters.cohort]);
const handleSortFilterChange = (event) => {
const handleSortFilterChange = useCallback((event) => {
const currentType = currentFilters.postType;
const currentStatus = currentFilters.status;
const {
@@ -93,6 +94,7 @@ function PostFilterBar({
cohortFilter: selectedCohort,
triggeredBy: name,
};
if (name === 'type') {
dispatch(setPostsTypeFilter(value));
if (
@@ -103,6 +105,7 @@ function PostFilterBar({
}
filterContentEventProperties.threadTypeFilter = value;
}
if (name === 'status') {
dispatch(setStatusFilter(value));
if (value === PostsStatusFilter.UNANSWERED && currentType !== ThreadType.QUESTION) {
@@ -115,16 +118,23 @@ function PostFilterBar({
}
filterContentEventProperties.statusFilter = value;
}
if (name === 'sort') {
dispatch(setSortedBy(value));
filterContentEventProperties.sortFilter = value;
}
if (name === 'cohort') {
dispatch(setCohortFilter(value));
filterContentEventProperties.cohortFilter = value;
}
sendTrackEvent('edx.forum.filter.content', filterContentEventProperties);
};
}, [currentFilters, currentSorting, dispatch, selectedCohort]);
const handleToggle = useCallback(() => {
setOpen(!isOpen);
}, [isOpen]);
useEffect(() => {
if (userHasModerationPrivileges && isEmpty(cohorts)) {
@@ -132,10 +142,48 @@ function PostFilterBar({
}
}, [courseId, userHasModerationPrivileges]);
const renderCohortFilter = useMemo(() => (
userHasModerationPrivileges && (
<>
<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 (
<Collapsible.Advanced
open={isOpen}
onToggle={() => setOpen(!isOpen)}
onToggle={handleToggle}
className="filter-bar collapsible-card-lg border-0"
>
<Collapsible.Trigger className="collapsible-trigger border-0">
@@ -157,7 +205,6 @@ function PostFilterBar({
<Icon src={Tune} />
</Collapsible.Visible>
</span>
</Collapsible.Trigger>
<Collapsible.Body className="collapsible-body px-4 pb-3 pt-0">
<Form>
@@ -260,49 +307,11 @@ function PostFilterBar({
/>
</Form.RadioSet>
</div>
{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={cohort.id}
label={capitalize(cohort.name)}
value={toString(cohort.id)}
selected={currentFilters.cohort}
/>
))}
</Form.RadioSet>
</div>
)}
</>
)}
{renderCohortFilter}
</Form>
</Collapsible.Body>
</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 { useSelector } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
ActionRow,
Button,
@@ -14,24 +16,23 @@ import {
import { selectModerationSettings } from '../../data/selectors';
import messages from './messages';
function ClosePostReasonModal({
intl,
const ClosePostReasonModal = ({
isOpen,
onCancel,
onConfirm,
}) {
}) => {
const intl = useIntl();
const scrollTo = useRef(null);
const [reasonCode, setReasonCode] = useState(null);
const { postCloseReasons } = useSelector(selectModerationSettings);
const onChange = event => {
const onChange = useCallback(event => {
if (event.target.value) {
setReasonCode(event.target.value);
} else {
setReasonCode(null);
}
};
}, []);
useEffect(() => {
/* istanbul ignore if: This API is not available in the test environment. */
@@ -87,13 +88,12 @@ function ClosePostReasonModal({
</ModalDialog.Footer>
</ModalDialog>
);
}
};
ClosePostReasonModal.propTypes = {
intl: intlShape.isRequired,
isOpen: PropTypes.bool.isRequired,
onCancel: PropTypes.func.isRequired,
onConfirm: PropTypes.func.isRequired,
};
export default injectIntl(ClosePostReasonModal);
export default React.memo(ClosePostReasonModal);

View File

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

View File

@@ -2,11 +2,12 @@ import React, { useCallback, useContext, useMemo } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { toString } from 'lodash';
import { useDispatch, useSelector } from 'react-redux';
import { useHistory, useLocation } from 'react-router-dom';
import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Hyperlink, useToggle } from '@edx/paragon';
import HTMLLoader from '../../../components/HTMLLoader';
@@ -15,102 +16,119 @@ import { selectorForUnitSubsection, selectTopicContext } from '../../../data/sel
import { AlertBanner, Confirmation } from '../../common';
import { DiscussionContext } from '../../common/context';
import HoverCard from '../../common/HoverCard';
import { ContentTypes } from '../../data/constants';
import { selectModerationSettings, selectUserHasModerationPrivileges } from '../../data/selectors';
import { selectTopic } from '../../topics/data/selectors';
import { selectThread } from '../data/selectors';
import { removeThread, updateExistingThread } from '../data/thunks';
import ClosePostReasonModal from './ClosePostReasonModal';
import messages from './messages';
import PostFooter from './PostFooter';
import PostHeader from './PostHeader';
import { postShape } from './proptypes';
function Post({
post,
intl,
handleAddResponseButton,
}) {
const Post = ({ handleAddResponseButton }) => {
const { enableInContextSidebar, postId } = useContext(DiscussionContext);
const {
topicId, abuseFlagged, closed, pinned, voted, hasEndorsed, following, closedBy, voteCount, groupId, groupName,
closeReason, authorLabel, type: postType, author, title, createdAt, renderedBody, lastEdit, editByLabel,
closedByLabel,
} = useSelector(selectThread(postId));
const intl = useIntl();
const location = useLocation();
const history = useHistory();
const dispatch = useDispatch();
const { enableInContextSidebar } = useContext(DiscussionContext);
const courseId = useSelector((state) => state.config.id);
const topic = useSelector(selectTopic(post.topicId));
const topic = useSelector(selectTopic(topicId));
const getTopicSubsection = useSelector(selectorForUnitSubsection);
const topicContext = useSelector(selectTopicContext(post.topicId));
const topicContext = useSelector(selectTopicContext(topicId));
const { reasonCodesEnabled } = useSelector(selectModerationSettings);
const [isDeleting, showDeleteConfirmation, hideDeleteConfirmation] = useToggle(false);
const [isReporting, showReportConfirmation, hideReportConfirmation] = useToggle(false);
const [isClosing, showClosePostModal, hideClosePostModal] = useToggle(false);
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
const displayPostFooter = post.following || post.voteCount || post.closed
|| (post.groupId && userHasModerationPrivileges);
const displayPostFooter = following || voteCount || closed || (groupId && userHasModerationPrivileges);
const handleAbusedFlag = useCallback(() => {
if (post.abuseFlagged) {
dispatch(updateExistingThread(post.id, { flagged: !post.abuseFlagged }));
} else {
showReportConfirmation();
}
}, [dispatch, post.abuseFlagged, post.id, showReportConfirmation]);
const handleDeleteConfirmation = async () => {
await dispatch(removeThread(post.id));
const handleDeleteConfirmation = useCallback(async () => {
await dispatch(removeThread(postId));
history.push({
pathname: '.',
search: enableInContextSidebar && '?inContextSidebar',
});
hideDeleteConfirmation();
};
}, [enableInContextSidebar, postId, hideDeleteConfirmation]);
const handleReportConfirmation = () => {
dispatch(updateExistingThread(post.id, { flagged: !post.abuseFlagged }));
const handleReportConfirmation = useCallback(() => {
dispatch(updateExistingThread(postId, { flagged: !abuseFlagged }));
hideReportConfirmation();
};
}, [abuseFlagged, postId, hideReportConfirmation]);
const handlePostContentEdit = useCallback(() => history.push({
...location,
pathname: `${location.pathname}/edit`,
}), [location.pathname]);
const handlePostClose = useCallback(() => {
if (closed) {
dispatch(updateExistingThread(postId, { closed: false }));
} else if (reasonCodesEnabled) {
showClosePostModal();
} else {
dispatch(updateExistingThread(postId, { closed: true }));
}
}, [closed, postId, reasonCodesEnabled, showClosePostModal]);
const handlePostCopyLink = useCallback(() => navigator.clipboard.writeText(
`${window.location.origin}/${courseId}/posts/${postId}`,
), [window.location.origin, postId, courseId]);
const handlePostPin = useCallback(() => dispatch(updateExistingThread(
postId, { pinned: !pinned },
)), [postId, pinned]);
const handlePostReport = useCallback(() => {
if (abuseFlagged) {
dispatch(updateExistingThread(postId, { flagged: !abuseFlagged }));
} else {
showReportConfirmation();
}
}, [abuseFlagged, postId, showReportConfirmation]);
const actionHandlers = useMemo(() => ({
[ContentActions.EDIT_CONTENT]: () => history.push({
...location,
pathname: `${location.pathname}/edit`,
}),
[ContentActions.EDIT_CONTENT]: handlePostContentEdit,
[ContentActions.DELETE]: showDeleteConfirmation,
[ContentActions.CLOSE]: () => {
if (post.closed) {
dispatch(updateExistingThread(post.id, { closed: false }));
} else if (reasonCodesEnabled) {
showClosePostModal();
} else {
dispatch(updateExistingThread(post.id, { closed: true }));
}
},
[ContentActions.COPY_LINK]: () => { navigator.clipboard.writeText(`${window.location.origin}/${courseId}/posts/${post.id}`); },
[ContentActions.PIN]: () => dispatch(updateExistingThread(post.id, { pinned: !post.pinned })),
[ContentActions.REPORT]: () => handleAbusedFlag(),
[ContentActions.CLOSE]: handlePostClose,
[ContentActions.COPY_LINK]: handlePostCopyLink,
[ContentActions.PIN]: handlePostPin,
[ContentActions.REPORT]: handlePostReport,
}), [
showDeleteConfirmation,
history,
location,
post.closed,
post.id,
post.pinned,
reasonCodesEnabled,
dispatch,
showClosePostModal,
courseId,
handleAbusedFlag,
handlePostClose, handlePostContentEdit, handlePostCopyLink, handlePostPin, handlePostReport, showDeleteConfirmation,
]);
const getTopicCategoryName = topicData => (
topicData.usageKey ? getTopicSubsection(topicData.usageKey)?.displayName : topicData.categoryId
);
const handleClosePostConfirmation = useCallback((closeReasonCode) => {
dispatch(updateExistingThread(postId, { closed: true, closeReasonCode }));
hideClosePostModal();
}, [postId, hideClosePostModal]);
const getTopicInfo = topicData => (
const handlePostLike = useCallback(() => {
dispatch(updateExistingThread(postId, { voted: !voted }));
}, [postId, voted]);
const handlePostFollow = useCallback(() => {
dispatch(updateExistingThread(postId, { following: !following }));
}, [postId, following]);
const getTopicCategoryName = useCallback(topicData => (
topicData.usageKey ? getTopicSubsection(topicData.usageKey)?.displayName : topicData.categoryId
), [getTopicSubsection]);
const getTopicInfo = useCallback(topicData => (
getTopicCategoryName(topicData) ? `${getTopicCategoryName(topicData)} / ${topicData.name}` : `${topicData.name}`
);
), [getTopicCategoryName]);
return (
<div
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
tabIndex="0"
>
@@ -123,7 +141,7 @@ function Post({
closeButtonVaraint="tertiary"
confirmButtonText={intl.formatMessage(messages.deleteConfirmationDelete)}
/>
{!post.abuseFlagged && (
{!abuseFlagged && (
<Confirmation
isOpen={isReporting}
title={intl.formatMessage(messages.reportPostTitle)}
@@ -134,18 +152,39 @@ function Post({
/>
)}
<HoverCard
commentOrPost={post}
id={postId}
contentType={ContentTypes.POST}
actionHandlers={actionHandlers}
handleResponseCommentButton={handleAddResponseButton}
addResponseCommentButtonMessage={intl.formatMessage(messages.addResponse)}
onLike={() => dispatch(updateExistingThread(post.id, { voted: !post.voted }))}
onFollow={() => dispatch(updateExistingThread(post.id, { following: !post.following }))}
isClosedPost={post.closed}
onLike={handlePostLike}
onFollow={handlePostFollow}
voted={voted}
following={following}
/>
<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">
<HTMLLoader htmlNode={post.renderedBody} componentId="post" cssClassName="html-loader" testId={post.id} />
<HTMLLoader htmlNode={renderedBody} componentId="post" cssClassName="html-loader" testId={postId} />
</div>
{(topicContext || topic) && (
<div
@@ -153,42 +192,54 @@ function Post({
{ 'w-100': enableInContextSidebar, 'mb-1': !displayPostFooter })}
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
destination={topicContext ? topicContext.unitLink : `${getConfig().BASE_URL}/${courseId}/topics/${post.topicId}`}
target="_top"
destination={topicContext ? (
topicContext.unitLink
) : (
`${getConfig().BASE_URL}/${courseId}/topics/${topicId}`
)}
>
{(topicContext && !topic)
? (
<>
<span className="w-auto">{topicContext.chapterName}</span>
<span className="mx-1">/</span>
<span className="w-auto">{topicContext.verticalName}</span>
<span className="mx-1">/</span>
<span className="w-auto">{topicContext.unitName}</span>
</>
)
: getTopicInfo(topic)}
{(topicContext && !topic) ? (
<>
<span className="w-auto">{topicContext.chapterName}</span>
<span className="mx-1">/</span>
<span className="w-auto">{topicContext.verticalName}</span>
<span className="mx-1">/</span>
<span className="w-auto">{topicContext.unitName}</span>
</>
) : (
getTopicInfo(topic)
)}
</Hyperlink>
</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
isOpen={isClosing}
onCancel={hideClosePostModal}
onConfirm={closeReasonCode => {
dispatch(updateExistingThread(post.id, { closed: true, closeReasonCode }));
hideClosePostModal();
}}
onConfirm={handleClosePostConfirmation}
/>
</div>
);
}
};
Post.propTypes = {
intl: intlShape.isRequired,
post: postShape.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 { useDispatch } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
Icon, IconButton, OverlayTrigger, Tooltip,
} from '@edx/paragon';
@@ -13,36 +13,46 @@ import { StarFilled, StarOutline } from '../../../components/icons';
import { updateExistingThread } from '../data/thunks';
import LikeButton from './LikeButton';
import messages from './messages';
import { postShape } from './proptypes';
function PostFooter({
intl,
post,
const PostFooter = ({
closed,
following,
groupId,
groupName,
id,
userHasModerationPrivileges,
}) {
voted,
voteCount,
}) => {
const dispatch = useDispatch();
const intl = useIntl();
const handlePostLike = useCallback(() => {
dispatch(updateExistingThread(id, { voted: !voted }));
}, [id, voted]);
return (
<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
count={post.voteCount}
onClick={() => dispatch(updateExistingThread(post.id, { voted: !post.voted }))}
voted={post.voted}
count={voteCount}
onClick={handlePostLike}
voted={voted}
/>
)}
{post.following && (
{following && (
<OverlayTrigger
overlay={(
<Tooltip id={`follow-${post.id}-tooltip`}>
{intl.formatMessage(post.following ? messages.unFollow : messages.follow)}
<Tooltip id={`follow-${id}-tooltip`}>
{intl.formatMessage(following ? messages.unFollow : messages.follow)}
</Tooltip>
)}
>
<IconButton
src={post.following ? StarFilled : StarOutline}
src={following ? StarFilled : StarOutline}
onClick={(e) => {
e.preventDefault();
dispatch(updateExistingThread(post.id, { following: !post.following }));
dispatch(updateExistingThread(id, { following: !following }));
return true;
}}
iconAs={Icon}
@@ -53,10 +63,10 @@ function PostFooter({
</OverlayTrigger>
)}
<div className="d-flex flex-fill justify-content-end align-items-center">
{post.groupId && userHasModerationPrivileges && (
{groupId && userHasModerationPrivileges && (
<OverlayTrigger
overlay={(
<Tooltip id={`visibility-${post.id}-tooltip`}>{post.groupName}</Tooltip>
<Tooltip id={`visibility-${id}-tooltip`}>{groupName}</Tooltip>
)}
>
<span data-testid="cohort-icon">
@@ -71,36 +81,43 @@ function PostFooter({
</span>
</OverlayTrigger>
)}
{post.closed
&& (
<OverlayTrigger
overlay={(
<Tooltip id={`closed-${post.id}-tooltip`}>
{intl.formatMessage(messages.postClosed)}
</Tooltip>
)}
>
<Icon
src={Locked}
className="text-primary-500"
style={{
width: '1rem',
height: '1rem',
marginLeft: '19.5px',
}}
/>
</OverlayTrigger>
)}
{closed && (
<OverlayTrigger
overlay={(
<Tooltip id={`closed-${id}-tooltip`}>
{intl.formatMessage(messages.postClosed)}
</Tooltip>
)}
>
<Icon
src={Locked}
style={{
width: '1rem',
height: '1rem',
marginLeft: '19.5px',
}}
/>
</OverlayTrigger>
)}
</div>
</div>
);
}
};
PostFooter.propTypes = {
intl: intlShape.isRequired,
post: postShape.isRequired,
voteCount: PropTypes.number.isRequired,
voted: PropTypes.bool.isRequired,
following: PropTypes.bool.isRequired,
id: PropTypes.string.isRequired,
groupId: PropTypes.string,
groupName: PropTypes.string,
closed: PropTypes.bool.isRequired,
userHasModerationPrivileges: PropTypes.bool.isRequired,
};
export default injectIntl(PostFooter);
PostFooter.defaultProps = {
groupId: null,
groupName: null,
};
export default React.memo(PostFooter);

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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