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:
22
package-lock.json
generated
22
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
11
src/components/Spinner.jsx
Normal file
11
src/components/Spinner.jsx
Normal 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);
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
12
src/discussions/data/constants.js
Normal file
12
src/discussions/data/constants.js
Normal 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',
|
||||
};
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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] || [],
|
||||
|
||||
@@ -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)),
|
||||
|
||||
8
src/discussions/post-comments/postCommentsContext.js
Normal file
8
src/discussions/post-comments/postCommentsContext.js
Normal 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: '',
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
26
src/discussions/posts/data/hooks.js
Normal file
26
src/discussions/posts/data/hooks.js
Normal 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;
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
44
src/discussions/posts/post-editor/PostTypeCard.jsx
Normal file
44
src/discussions/posts/post-editor/PostTypeCard.jsx
Normal 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);
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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%);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user