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",
|
"raw-loader": "4.0.2",
|
||||||
"react": "16.14.0",
|
"react": "16.14.0",
|
||||||
"react-dom": "16.14.0",
|
"react-dom": "16.14.0",
|
||||||
"react-mathjax-preview": "2.2.6",
|
|
||||||
"react-redux": "7.2.6",
|
"react-redux": "7.2.6",
|
||||||
"react-router": "5.2.1",
|
"react-router": "5.2.1",
|
||||||
"react-router-dom": "5.3.0",
|
"react-router-dom": "5.3.0",
|
||||||
@@ -22327,19 +22326,6 @@
|
|||||||
"react": ">=16.8.0"
|
"react": ">=16.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-mathjax-preview": {
|
|
||||||
"version": "2.2.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/react-mathjax-preview/-/react-mathjax-preview-2.2.6.tgz",
|
|
||||||
"integrity": "sha512-lS+wQ49jd/XyXu8tTWWZ38qfNJrAlxsDjkUrQoldfM1MdS6s18DxFV4dwP9xoAsq86kuSb/raXU8qJrH5dtV5w==",
|
|
||||||
"dependencies": {
|
|
||||||
"dompurify": "^2.0.8"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"prop-types": "^15",
|
|
||||||
"react": "^16 || ^17",
|
|
||||||
"react-dom": "^16 || ^17"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/react-overlays": {
|
"node_modules/react-overlays": {
|
||||||
"version": "5.2.0",
|
"version": "5.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-overlays/-/react-overlays-5.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-overlays/-/react-overlays-5.2.0.tgz",
|
||||||
@@ -43849,14 +43835,6 @@
|
|||||||
"integrity": "sha512-j1U1CWWs68nBPOg7tkQqnlFcAMFF6oEK6MgqAo15f8A5p7mjH6xyKn2gHbkcimpwfO0VQXqxAswnSYVr8lWzjw==",
|
"integrity": "sha512-j1U1CWWs68nBPOg7tkQqnlFcAMFF6oEK6MgqAo15f8A5p7mjH6xyKn2gHbkcimpwfO0VQXqxAswnSYVr8lWzjw==",
|
||||||
"requires": {}
|
"requires": {}
|
||||||
},
|
},
|
||||||
"react-mathjax-preview": {
|
|
||||||
"version": "2.2.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/react-mathjax-preview/-/react-mathjax-preview-2.2.6.tgz",
|
|
||||||
"integrity": "sha512-lS+wQ49jd/XyXu8tTWWZ38qfNJrAlxsDjkUrQoldfM1MdS6s18DxFV4dwP9xoAsq86kuSb/raXU8qJrH5dtV5w==",
|
|
||||||
"requires": {
|
|
||||||
"dompurify": "^2.0.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"react-overlays": {
|
"react-overlays": {
|
||||||
"version": "5.2.0",
|
"version": "5.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-overlays/-/react-overlays-5.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-overlays/-/react-overlays-5.2.0.tgz",
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
href="<%=htmlWebpackPlugin.options.FAVICON_URL%>"
|
href="<%=htmlWebpackPlugin.options.FAVICON_URL%>"
|
||||||
type="image/x-icon"
|
type="image/x-icon"
|
||||||
/>
|
/>
|
||||||
<script>
|
<script defer>
|
||||||
window.MathJax = {
|
window.MathJax = {
|
||||||
tex: {
|
tex: {
|
||||||
inlineMath: [
|
inlineMath: [
|
||||||
@@ -180,7 +180,6 @@
|
|||||||
var r = (window.lightningjs = t(e));
|
var r = (window.lightningjs = t(e));
|
||||||
(r.require = t), (r.modules = n);
|
(r.require = t), (r.modules = n);
|
||||||
})({});
|
})({});
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
<!-- end usabilla live embed code -->
|
<!-- end usabilla live embed code -->
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -5,31 +5,26 @@ import { getIn, useFormikContext } from 'formik';
|
|||||||
|
|
||||||
import { Form, TransitionReplace } from '@edx/paragon';
|
import { Form, TransitionReplace } from '@edx/paragon';
|
||||||
|
|
||||||
function FormikErrorFeedback({ name }) {
|
const FormikErrorFeedback = ({ name }) => {
|
||||||
const {
|
const { touched, errors } = useFormikContext();
|
||||||
touched,
|
|
||||||
errors,
|
|
||||||
} = useFormikContext();
|
|
||||||
const fieldTouched = getIn(touched, name);
|
const fieldTouched = getIn(touched, name);
|
||||||
const fieldError = getIn(errors, name);
|
const fieldError = getIn(errors, name);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TransitionReplace>
|
<TransitionReplace>
|
||||||
{fieldTouched && fieldError
|
{fieldTouched && fieldError ? (
|
||||||
? (
|
<Form.Control.Feedback type="invalid" hasIcon={false} key={`${name}-error-feedback`}>
|
||||||
<Form.Control.Feedback type="invalid" hasIcon={false} key={`${name}-error-feedback`}>
|
{fieldError}
|
||||||
{fieldError}
|
</Form.Control.Feedback>
|
||||||
</Form.Control.Feedback>
|
) : (
|
||||||
)
|
<React.Fragment key={`${name}-no-error-feedback`} />
|
||||||
: (
|
)}
|
||||||
<React.Fragment key={`${name}-no-error-feedback`} />
|
|
||||||
)}
|
|
||||||
</TransitionReplace>
|
</TransitionReplace>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
FormikErrorFeedback.propTypes = {
|
FormikErrorFeedback.propTypes = {
|
||||||
name: PropTypes.string.isRequired,
|
name: PropTypes.string.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default FormikErrorFeedback;
|
export default React.memo(FormikErrorFeedback);
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ const defaultSanitizeOptions = {
|
|||||||
ADD_ATTR: ['columnalign'],
|
ADD_ATTR: ['columnalign'],
|
||||||
};
|
};
|
||||||
|
|
||||||
function HTMLLoader({
|
const HTMLLoader = ({
|
||||||
htmlNode, componentId, cssClassName, testId, delay,
|
htmlNode, componentId, cssClassName, testId, delay,
|
||||||
}) {
|
}) => {
|
||||||
const sanitizedMath = DOMPurify.sanitize(htmlNode, { ...defaultSanitizeOptions });
|
const sanitizedMath = DOMPurify.sanitize(htmlNode, { ...defaultSanitizeOptions });
|
||||||
const previewRef = useRef(null);
|
const previewRef = useRef(null);
|
||||||
const debouncedPostContent = useDebounce(htmlNode, delay);
|
const debouncedPostContent = useDebounce(htmlNode, delay);
|
||||||
@@ -45,7 +45,7 @@ function HTMLLoader({
|
|||||||
return (
|
return (
|
||||||
<div ref={previewRef} className={cssClassName} id={componentId} data-testid={testId} />
|
<div ref={previewRef} className={cssClassName} id={componentId} data-testid={testId} />
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
HTMLLoader.propTypes = {
|
HTMLLoader.propTypes = {
|
||||||
htmlNode: PropTypes.node,
|
htmlNode: PropTypes.node,
|
||||||
@@ -63,4 +63,4 @@ HTMLLoader.defaultProps = {
|
|||||||
delay: 0,
|
delay: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default HTMLLoader;
|
export default React.memo(HTMLLoader);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
|
|||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
import { fetchTab } from './data/thunks';
|
import { fetchTab } from './data/thunks';
|
||||||
import Tabs from './tabs/Tabs';
|
import Tabs from './tabs/Tabs';
|
||||||
@@ -12,12 +12,13 @@ import messages from './messages';
|
|||||||
|
|
||||||
import './navBar.scss';
|
import './navBar.scss';
|
||||||
|
|
||||||
function CourseTabsNavigation({
|
const CourseTabsNavigation = ({
|
||||||
activeTab, className, intl, courseId, rootSlug,
|
activeTab, className, courseId, rootSlug,
|
||||||
}) {
|
}) => {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
|
const intl = useIntl();
|
||||||
const tabs = useSelector(state => state.courseTabs.tabs);
|
const tabs = useSelector(state => state.courseTabs.tabs);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch(fetchTab(courseId, rootSlug));
|
dispatch(fetchTab(courseId, rootSlug));
|
||||||
}, [courseId]);
|
}, [courseId]);
|
||||||
@@ -25,8 +26,7 @@ function CourseTabsNavigation({
|
|||||||
return (
|
return (
|
||||||
<div id="courseTabsNavigation" className={classNames('course-tabs-navigation', className)}>
|
<div id="courseTabsNavigation" className={classNames('course-tabs-navigation', className)}>
|
||||||
<div className="container-xl">
|
<div className="container-xl">
|
||||||
{!!tabs.length
|
{!!tabs.length && (
|
||||||
&& (
|
|
||||||
<Tabs
|
<Tabs
|
||||||
className="nav-underline-tabs"
|
className="nav-underline-tabs"
|
||||||
aria-label={intl.formatMessage(messages.courseMaterial)}
|
aria-label={intl.formatMessage(messages.courseMaterial)}
|
||||||
@@ -41,18 +41,17 @@ function CourseTabsNavigation({
|
|||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
CourseTabsNavigation.propTypes = {
|
CourseTabsNavigation.propTypes = {
|
||||||
activeTab: PropTypes.string,
|
activeTab: PropTypes.string,
|
||||||
className: PropTypes.string,
|
className: PropTypes.string,
|
||||||
rootSlug: PropTypes.string,
|
rootSlug: PropTypes.string,
|
||||||
courseId: PropTypes.string.isRequired,
|
courseId: PropTypes.string.isRequired,
|
||||||
intl: intlShape.isRequired,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
CourseTabsNavigation.defaultProps = {
|
CourseTabsNavigation.defaultProps = {
|
||||||
@@ -61,4 +60,4 @@ CourseTabsNavigation.defaultProps = {
|
|||||||
rootSlug: 'outline',
|
rootSlug: 'outline',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default injectIntl(CourseTabsNavigation);
|
export default React.memo(CourseTabsNavigation);
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import { Button, Icon, IconButton } from '@edx/paragon';
|
import { Button, Icon, IconButton } from '@edx/paragon';
|
||||||
import { Close } from '@edx/paragon/icons';
|
import { Close } from '@edx/paragon/icons';
|
||||||
|
|
||||||
import messages from '../discussions/posts/post-editor/messages';
|
import messages from '../discussions/posts/post-editor/messages';
|
||||||
import HTMLLoader from './HTMLLoader';
|
import HTMLLoader from './HTMLLoader';
|
||||||
|
|
||||||
function PostPreviewPanel({
|
const PostPreviewPanel = ({
|
||||||
htmlNode, intl, isPost, editExisting,
|
htmlNode, isPost, editExisting,
|
||||||
}) {
|
}) => {
|
||||||
|
const intl = useIntl();
|
||||||
const [showPreviewPane, setShowPreviewPane] = useState(false);
|
const [showPreviewPane, setShowPreviewPane] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -30,13 +31,15 @@ function PostPreviewPanel({
|
|||||||
iconClassNames="icon-size"
|
iconClassNames="icon-size"
|
||||||
data-testid="hide-preview-button"
|
data-testid="hide-preview-button"
|
||||||
/>
|
/>
|
||||||
<HTMLLoader
|
{htmlNode && (
|
||||||
htmlNode={htmlNode}
|
<HTMLLoader
|
||||||
cssClassName="text-primary"
|
htmlNode={htmlNode}
|
||||||
componentId="post-preview"
|
cssClassName="text-primary"
|
||||||
testId="post-preview"
|
componentId="post-preview"
|
||||||
delay={500}
|
testId="post-preview"
|
||||||
/>
|
delay={500}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="d-flex justify-content-end">
|
<div className="d-flex justify-content-end">
|
||||||
@@ -55,18 +58,18 @@ function PostPreviewPanel({
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
PostPreviewPanel.propTypes = {
|
PostPreviewPanel.propTypes = {
|
||||||
intl: intlShape.isRequired,
|
htmlNode: PropTypes.node,
|
||||||
htmlNode: PropTypes.node.isRequired,
|
|
||||||
isPost: PropTypes.bool,
|
isPost: PropTypes.bool,
|
||||||
editExisting: PropTypes.bool,
|
editExisting: PropTypes.bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
PostPreviewPanel.defaultProps = {
|
PostPreviewPanel.defaultProps = {
|
||||||
|
htmlNode: '',
|
||||||
isPost: false,
|
isPost: false,
|
||||||
editExisting: false,
|
editExisting: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default injectIntl(PostPreviewPanel);
|
export default React.memo(PostPreviewPanel);
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import React, { useContext, useEffect } from 'react';
|
import React, {
|
||||||
|
useCallback, useContext, useEffect, useState,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
import camelCase from 'lodash/camelCase';
|
import camelCase from 'lodash/camelCase';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import { Icon, SearchField } from '@edx/paragon';
|
import { Icon, SearchField } from '@edx/paragon';
|
||||||
import { Search as SearchIcon } from '@edx/paragon/icons';
|
import { Search as SearchIcon } from '@edx/paragon/icons';
|
||||||
|
|
||||||
@@ -13,7 +15,8 @@ import { setSearchQuery } from '../discussions/posts/data';
|
|||||||
import postsMessages from '../discussions/posts/post-actions-bar/messages';
|
import postsMessages from '../discussions/posts/post-actions-bar/messages';
|
||||||
import { setFilter as setTopicFilter } from '../discussions/topics/data/slices';
|
import { setFilter as setTopicFilter } from '../discussions/topics/data/slices';
|
||||||
|
|
||||||
function Search({ intl }) {
|
const Search = () => {
|
||||||
|
const intl = useIntl();
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const { page } = useContext(DiscussionContext);
|
const { page } = useContext(DiscussionContext);
|
||||||
const postSearch = useSelector(({ threads }) => threads.filters.search);
|
const postSearch = useSelector(({ threads }) => threads.filters.search);
|
||||||
@@ -21,8 +24,9 @@ function Search({ intl }) {
|
|||||||
const learnerSearch = useSelector(({ learners }) => learners.usernameSearch);
|
const learnerSearch = useSelector(({ learners }) => learners.usernameSearch);
|
||||||
const isPostSearch = ['posts', 'my-posts'].includes(page);
|
const isPostSearch = ['posts', 'my-posts'].includes(page);
|
||||||
const isTopicSearch = 'topics'.includes(page);
|
const isTopicSearch = 'topics'.includes(page);
|
||||||
let searchValue = '';
|
const [searchValue, setSearchValue] = useState('');
|
||||||
let currentValue = '';
|
let currentValue = '';
|
||||||
|
|
||||||
if (isPostSearch) {
|
if (isPostSearch) {
|
||||||
currentValue = postSearch;
|
currentValue = postSearch;
|
||||||
} else if (isTopicSearch) {
|
} else if (isTopicSearch) {
|
||||||
@@ -31,20 +35,21 @@ function Search({ intl }) {
|
|||||||
currentValue = learnerSearch;
|
currentValue = learnerSearch;
|
||||||
}
|
}
|
||||||
|
|
||||||
const onClear = () => {
|
const onClear = useCallback(() => {
|
||||||
dispatch(setSearchQuery(''));
|
dispatch(setSearchQuery(''));
|
||||||
dispatch(setTopicFilter(''));
|
dispatch(setTopicFilter(''));
|
||||||
dispatch(setUsernameSearch(''));
|
dispatch(setUsernameSearch(''));
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const onChange = (query) => {
|
const onChange = useCallback((query) => {
|
||||||
searchValue = query;
|
setSearchValue(query);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const onSubmit = (query) => {
|
const onSubmit = useCallback((query) => {
|
||||||
if (query === '') {
|
if (query === '') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isPostSearch) {
|
if (isPostSearch) {
|
||||||
dispatch(setSearchQuery(query));
|
dispatch(setSearchQuery(query));
|
||||||
} else if (page === 'topics') {
|
} else if (page === 'topics') {
|
||||||
@@ -52,36 +57,36 @@ function Search({ intl }) {
|
|||||||
} else if (page === 'learners') {
|
} else if (page === 'learners') {
|
||||||
dispatch(setUsernameSearch(query));
|
dispatch(setUsernameSearch(query));
|
||||||
}
|
}
|
||||||
};
|
}, [page, searchValue]);
|
||||||
|
|
||||||
|
const handleIconClick = useCallback((e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onSubmit(searchValue);
|
||||||
|
}, [searchValue]);
|
||||||
|
|
||||||
useEffect(() => onClear(), [page]);
|
useEffect(() => onClear(), [page]);
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<SearchField.Advanced
|
|
||||||
onClear={onClear}
|
|
||||||
onChange={onChange}
|
|
||||||
onSubmit={onSubmit}
|
|
||||||
value={currentValue}
|
|
||||||
>
|
|
||||||
<SearchField.Label />
|
|
||||||
<SearchField.Input
|
|
||||||
style={{ paddingRight: '1rem' }}
|
|
||||||
placeholder={intl.formatMessage(postsMessages.search, { page: camelCase(page) })}
|
|
||||||
/>
|
|
||||||
<span className="mt-auto mb-auto mr-2.5 pointer-cursor-hover">
|
|
||||||
<Icon
|
|
||||||
src={SearchIcon}
|
|
||||||
onClick={() => onSubmit(searchValue)}
|
|
||||||
data-testid="search-icon"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</SearchField.Advanced>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Search.propTypes = {
|
return (
|
||||||
intl: intlShape.isRequired,
|
<SearchField.Advanced
|
||||||
|
onClear={onClear}
|
||||||
|
onChange={onChange}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
value={currentValue}
|
||||||
|
>
|
||||||
|
<SearchField.Label />
|
||||||
|
<SearchField.Input
|
||||||
|
style={{ paddingRight: '1rem' }}
|
||||||
|
placeholder={intl.formatMessage(postsMessages.search, { page: camelCase(page) })}
|
||||||
|
/>
|
||||||
|
<span className="py-auto px-2.5 pointer-cursor-hover">
|
||||||
|
<Icon
|
||||||
|
src={SearchIcon}
|
||||||
|
onClick={handleIconClick}
|
||||||
|
data-testid="search-icon"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</SearchField.Advanced>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default injectIntl(Search);
|
export default React.memo(Search);
|
||||||
|
|||||||
@@ -1,32 +1,36 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import { Button, Icon } from '@edx/paragon';
|
import { Button, Icon } from '@edx/paragon';
|
||||||
import { Search } from '@edx/paragon/icons';
|
import { Search } from '@edx/paragon/icons';
|
||||||
|
|
||||||
import { RequestStatus } from '../data/constants';
|
import { RequestStatus } from '../data/constants';
|
||||||
import messages from '../discussions/posts/post-actions-bar/messages';
|
import messages from '../discussions/posts/post-actions-bar/messages';
|
||||||
|
|
||||||
function SearchInfo({
|
const SearchInfo = ({
|
||||||
intl,
|
|
||||||
count,
|
count,
|
||||||
text,
|
text,
|
||||||
loadingStatus,
|
loadingStatus,
|
||||||
onClear,
|
onClear,
|
||||||
textSearchRewrite,
|
textSearchRewrite,
|
||||||
}) {
|
}) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="d-flex flex-row border-bottom border-light-400">
|
<div className="d-flex flex-row border-bottom border-light-400">
|
||||||
<Icon src={Search} className="justify-content-start ml-3.5 mr-2 mb-2 mt-2.5" />
|
<Icon src={Search} className="justify-content-start ml-3.5 mr-2 mb-2 mt-2.5" />
|
||||||
<Button variant="" size="inline" className="text-justify p-2">
|
<Button variant="" size="inline" className="text-justify p-2">
|
||||||
{loadingStatus === RequestStatus.SUCCESSFUL && (
|
{loadingStatus === RequestStatus.SUCCESSFUL && (
|
||||||
textSearchRewrite ? intl.formatMessage(messages.searchRewriteInfo, {
|
textSearchRewrite ? (
|
||||||
searchString: text,
|
intl.formatMessage(messages.searchRewriteInfo, {
|
||||||
count,
|
searchString: text,
|
||||||
textSearchRewrite,
|
count,
|
||||||
})
|
textSearchRewrite,
|
||||||
: intl.formatMessage(messages.searchInfo, { count, text })
|
})
|
||||||
|
) : (
|
||||||
|
intl.formatMessage(messages.searchInfo, { count, text })
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
{loadingStatus !== RequestStatus.SUCCESSFUL && intl.formatMessage(messages.searchInfoSearching)}
|
{loadingStatus !== RequestStatus.SUCCESSFUL && intl.formatMessage(messages.searchInfoSearching)}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -35,10 +39,9 @@ function SearchInfo({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
SearchInfo.propTypes = {
|
SearchInfo.propTypes = {
|
||||||
intl: intlShape.isRequired,
|
|
||||||
count: PropTypes.number.isRequired,
|
count: PropTypes.number.isRequired,
|
||||||
text: PropTypes.string.isRequired,
|
text: PropTypes.string.isRequired,
|
||||||
loadingStatus: PropTypes.string.isRequired,
|
loadingStatus: PropTypes.string.isRequired,
|
||||||
@@ -51,4 +54,4 @@ SearchInfo.defaultProps = {
|
|||||||
textSearchRewrite: null,
|
textSearchRewrite: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default injectIntl(SearchInfo);
|
export default React.memo(SearchInfo);
|
||||||
|
|||||||
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 { Editor } from '@tinymce/tinymce-react';
|
||||||
import { useParams } from 'react-router';
|
import { useParams } from 'react-router';
|
||||||
@@ -42,30 +42,31 @@ import contentCss from '!!raw-loader!tinymce/skins/content/default/content.min.c
|
|||||||
import contentUiCss from '!!raw-loader!tinymce/skins/ui/oxide/content.min.css';
|
import contentUiCss from '!!raw-loader!tinymce/skins/ui/oxide/content.min.css';
|
||||||
|
|
||||||
/* istanbul ignore next */
|
/* istanbul ignore next */
|
||||||
const setup = (editor) => {
|
function TinyMCEEditor(props) {
|
||||||
editor.ui.registry.addButton('openedx_code', {
|
|
||||||
icon: 'sourcecode',
|
|
||||||
onAction: () => {
|
|
||||||
editor.execCommand('CodeSample');
|
|
||||||
},
|
|
||||||
});
|
|
||||||
editor.ui.registry.addButton('openedx_html', {
|
|
||||||
text: 'HTML',
|
|
||||||
onAction: () => {
|
|
||||||
editor.execCommand('mceCodeEditor');
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/* istanbul ignore next */
|
|
||||||
export default function TinyMCEEditor(props) {
|
|
||||||
// note that skin and content_css is disabled to avoid the normal
|
// note that skin and content_css is disabled to avoid the normal
|
||||||
// loading process and is instead loaded as a string via content_style
|
// loading process and is instead loaded as a string via content_style
|
||||||
|
|
||||||
const { courseId, postId } = useParams();
|
const { courseId, postId } = useParams();
|
||||||
const [showImageWarning, setShowImageWarning] = useState(false);
|
const [showImageWarning, setShowImageWarning] = useState(false);
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const uploadHandler = async (blobInfo, success, failure) => {
|
|
||||||
|
/* istanbul ignore next */
|
||||||
|
const setup = useCallback((editor) => {
|
||||||
|
editor.ui.registry.addButton('openedx_code', {
|
||||||
|
icon: 'sourcecode',
|
||||||
|
onAction: () => {
|
||||||
|
editor.execCommand('CodeSample');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
editor.ui.registry.addButton('openedx_html', {
|
||||||
|
text: 'HTML',
|
||||||
|
onAction: () => {
|
||||||
|
editor.execCommand('mceCodeEditor');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const uploadHandler = useCallback(async (blobInfo, success, failure) => {
|
||||||
try {
|
try {
|
||||||
const blob = blobInfo.blob();
|
const blob = blobInfo.blob();
|
||||||
const imageSize = blobInfo.blob().size / 1024;
|
const imageSize = blobInfo.blob().size / 1024;
|
||||||
@@ -76,7 +77,7 @@ export default function TinyMCEEditor(props) {
|
|||||||
const filename = blobInfo.filename();
|
const filename = blobInfo.filename();
|
||||||
const { location } = await uploadFile(blob, filename, courseId, postId || 'root');
|
const { location } = await uploadFile(blob, filename, courseId, postId || 'root');
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
img.onload = function () {
|
img.onload = () => {
|
||||||
if (img.height > 999 || img.width > 999) { setShowImageWarning(true); }
|
if (img.height > 999 || img.width > 999) { setShowImageWarning(true); }
|
||||||
};
|
};
|
||||||
img.src = location;
|
img.src = location;
|
||||||
@@ -84,7 +85,11 @@ export default function TinyMCEEditor(props) {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
failure(e.toString(), { remove: true });
|
failure(e.toString(), { remove: true });
|
||||||
}
|
}
|
||||||
};
|
}, [courseId, postId]);
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
setShowImageWarning(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
let contentStyle;
|
let contentStyle;
|
||||||
// In the test environment this causes an error so set styles to empty since they aren't needed for testing.
|
// In the test environment this causes an error so set styles to empty since they aren't needed for testing.
|
||||||
@@ -131,21 +136,22 @@ export default function TinyMCEEditor(props) {
|
|||||||
<AlertModal
|
<AlertModal
|
||||||
title={intl.formatMessage(messages.imageWarningModalTitle)}
|
title={intl.formatMessage(messages.imageWarningModalTitle)}
|
||||||
isOpen={showImageWarning}
|
isOpen={showImageWarning}
|
||||||
onClose={() => setShowImageWarning(false)}
|
onClose={handleClose}
|
||||||
isBlocking
|
isBlocking
|
||||||
footerNode={(
|
footerNode={(
|
||||||
<ActionRow>
|
<ActionRow>
|
||||||
<Button variant="danger" onClick={() => setShowImageWarning(false)}>
|
<Button variant="danger" onClick={handleClose}>
|
||||||
{intl.formatMessage(messages.imageWarningDismissButton)}
|
{intl.formatMessage(messages.imageWarningDismissButton)}
|
||||||
</Button>
|
</Button>
|
||||||
</ActionRow>
|
</ActionRow>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<p>
|
<p>
|
||||||
{intl.formatMessage(messages.imageWarningMessage)}
|
{intl.formatMessage(messages.imageWarningMessage)}
|
||||||
</p>
|
</p>
|
||||||
</AlertModal>
|
</AlertModal>
|
||||||
</>
|
</>
|
||||||
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default React.memo(TinyMCEEditor);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
|
|||||||
|
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
|
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import { Icon, OverlayTrigger, Tooltip } from '@edx/paragon';
|
import { Icon, OverlayTrigger, Tooltip } from '@edx/paragon';
|
||||||
import { HelpOutline, PostOutline, Report } from '@edx/paragon/icons';
|
import { HelpOutline, PostOutline, Report } from '@edx/paragon/icons';
|
||||||
|
|
||||||
@@ -14,15 +14,16 @@ import {
|
|||||||
} from '../discussions/data/selectors';
|
} from '../discussions/data/selectors';
|
||||||
import messages from '../discussions/in-context-topics/messages';
|
import messages from '../discussions/in-context-topics/messages';
|
||||||
|
|
||||||
function TopicStats({
|
const TopicStats = ({
|
||||||
threadCounts,
|
threadCounts,
|
||||||
activeFlags,
|
activeFlags,
|
||||||
inactiveFlags,
|
inactiveFlags,
|
||||||
intl,
|
}) => {
|
||||||
}) {
|
const intl = useIntl();
|
||||||
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
|
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
|
||||||
const userIsGroupTa = useSelector(selectUserIsGroupTa);
|
const userIsGroupTa = useSelector(selectUserIsGroupTa);
|
||||||
const canSeeReportedStats = (activeFlags || inactiveFlags) && (userHasModerationPrivileges || userIsGroupTa);
|
const canSeeReportedStats = (activeFlags || inactiveFlags) && (userHasModerationPrivileges || userIsGroupTa);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="d-flex align-items-center mt-2.5" style={{ marginBottom: '2px' }}>
|
<div className="d-flex align-items-center mt-2.5" style={{ marginBottom: '2px' }}>
|
||||||
<OverlayTrigger
|
<OverlayTrigger
|
||||||
@@ -87,7 +88,7 @@ function TopicStats({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
TopicStats.propTypes = {
|
TopicStats.propTypes = {
|
||||||
threadCounts: PropTypes.shape({
|
threadCounts: PropTypes.shape({
|
||||||
@@ -96,7 +97,6 @@ TopicStats.propTypes = {
|
|||||||
}),
|
}),
|
||||||
activeFlags: PropTypes.number,
|
activeFlags: PropTypes.number,
|
||||||
inactiveFlags: PropTypes.number,
|
inactiveFlags: PropTypes.number,
|
||||||
intl: intlShape.isRequired,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
TopicStats.defaultProps = {
|
TopicStats.defaultProps = {
|
||||||
@@ -108,4 +108,4 @@ TopicStats.defaultProps = {
|
|||||||
inactiveFlags: null,
|
inactiveFlags: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default injectIntl(TopicStats);
|
export default React.memo(TopicStats);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export { default as PostActionsBar } from '../discussions/posts/post-actions-bar/PostActionsBar';
|
export { default as PostActionsBar } from '../discussions/posts/post-actions-bar/PostActionsBar';
|
||||||
export { default as Search } from './Search';
|
export { default as Search } from './Search';
|
||||||
|
export { default as Spinner } from './Spinner';
|
||||||
export { default as TinyMCEEditor } from './TinyMCEEditor';
|
export { default as TinyMCEEditor } from './TinyMCEEditor';
|
||||||
export { default as TopicStats } from './TopicStats';
|
export { default as TopicStats } from './TopicStats';
|
||||||
|
|||||||
@@ -18,11 +18,13 @@ import { useDispatch } from 'react-redux';
|
|||||||
export function useDispatchWithState() {
|
export function useDispatchWithState() {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const [isDispatching, setDispatching] = useState(false);
|
const [isDispatching, setDispatching] = useState(false);
|
||||||
|
|
||||||
const dispatchWithState = async (thunk) => {
|
const dispatchWithState = async (thunk) => {
|
||||||
setDispatching(true);
|
setDispatching(true);
|
||||||
await dispatch(thunk);
|
await dispatch(thunk);
|
||||||
setDispatching(false);
|
setDispatching(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
return [
|
return [
|
||||||
isDispatching,
|
isDispatching,
|
||||||
dispatchWithState,
|
dispatchWithState,
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import React, { useCallback, useRef, useState } from 'react';
|
import React, {
|
||||||
|
useCallback, useMemo, useRef, useState,
|
||||||
|
} from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
|
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import { logError } from '@edx/frontend-platform/logging';
|
import { logError } from '@edx/frontend-platform/logging';
|
||||||
import {
|
import {
|
||||||
Button, Dropdown, Icon, IconButton, ModalPopup, useToggle,
|
Button, Dropdown, Icon, IconButton, ModalPopup, useToggle,
|
||||||
@@ -13,22 +15,22 @@ import { MoreHoriz } from '@edx/paragon/icons';
|
|||||||
import { ContentActions } from '../../data/constants';
|
import { ContentActions } from '../../data/constants';
|
||||||
import { selectBlackoutDate } from '../data/selectors';
|
import { selectBlackoutDate } from '../data/selectors';
|
||||||
import messages from '../messages';
|
import messages from '../messages';
|
||||||
import { commentShape } from '../post-comments/comments/comment/proptypes';
|
|
||||||
import { postShape } from '../posts/post/proptypes';
|
|
||||||
import { inBlackoutDateRange, useActions } from '../utils';
|
import { inBlackoutDateRange, useActions } from '../utils';
|
||||||
|
|
||||||
function ActionsDropdown({
|
function ActionsDropdown({
|
||||||
intl,
|
|
||||||
commentOrPost,
|
|
||||||
disabled,
|
|
||||||
actionHandlers,
|
actionHandlers,
|
||||||
iconSize,
|
contentType,
|
||||||
|
disabled,
|
||||||
dropDownIconSize,
|
dropDownIconSize,
|
||||||
|
iconSize,
|
||||||
|
id,
|
||||||
}) {
|
}) {
|
||||||
const buttonRef = useRef();
|
const buttonRef = useRef();
|
||||||
|
const intl = useIntl();
|
||||||
const [isOpen, open, close] = useToggle(false);
|
const [isOpen, open, close] = useToggle(false);
|
||||||
const [target, setTarget] = useState(null);
|
const [target, setTarget] = useState(null);
|
||||||
const actions = useActions(commentOrPost);
|
const blackoutDateRange = useSelector(selectBlackoutDate);
|
||||||
|
const actions = useActions(contentType, id);
|
||||||
|
|
||||||
const handleActions = useCallback((action) => {
|
const handleActions = useCallback((action) => {
|
||||||
const actionFunction = actionHandlers[action];
|
const actionFunction = actionHandlers[action];
|
||||||
@@ -39,11 +41,12 @@ function ActionsDropdown({
|
|||||||
}
|
}
|
||||||
}, [actionHandlers]);
|
}, [actionHandlers]);
|
||||||
|
|
||||||
const blackoutDateRange = useSelector(selectBlackoutDate);
|
|
||||||
// Find and remove edit action if in blackout date range.
|
// Find and remove edit action if in blackout date range.
|
||||||
if (inBlackoutDateRange(blackoutDateRange)) {
|
useMemo(() => {
|
||||||
actions.splice(actions.findIndex(action => action.id === 'edit'), 1);
|
if (inBlackoutDateRange(blackoutDateRange)) {
|
||||||
}
|
actions.splice(actions.findIndex(action => action.id === 'edit'), 1);
|
||||||
|
}
|
||||||
|
}, [actions, blackoutDateRange]);
|
||||||
|
|
||||||
const onClickButton = useCallback(() => {
|
const onClickButton = useCallback(() => {
|
||||||
setTarget(buttonRef.current);
|
setTarget(buttonRef.current);
|
||||||
@@ -80,9 +83,7 @@ function ActionsDropdown({
|
|||||||
>
|
>
|
||||||
{actions.map(action => (
|
{actions.map(action => (
|
||||||
<React.Fragment key={action.id}>
|
<React.Fragment key={action.id}>
|
||||||
{(action.action === ContentActions.DELETE)
|
{(action.action === ContentActions.DELETE) && <Dropdown.Divider />}
|
||||||
&& <Dropdown.Divider />}
|
|
||||||
|
|
||||||
<Dropdown.Item
|
<Dropdown.Item
|
||||||
as={Button}
|
as={Button}
|
||||||
variant="tertiary"
|
variant="tertiary"
|
||||||
@@ -111,12 +112,12 @@ function ActionsDropdown({
|
|||||||
}
|
}
|
||||||
|
|
||||||
ActionsDropdown.propTypes = {
|
ActionsDropdown.propTypes = {
|
||||||
intl: intlShape.isRequired,
|
id: PropTypes.string.isRequired,
|
||||||
commentOrPost: PropTypes.oneOfType([commentShape, postShape]).isRequired,
|
|
||||||
disabled: PropTypes.bool,
|
disabled: PropTypes.bool,
|
||||||
actionHandlers: PropTypes.objectOf(PropTypes.func).isRequired,
|
actionHandlers: PropTypes.objectOf(PropTypes.func).isRequired,
|
||||||
iconSize: PropTypes.string,
|
iconSize: PropTypes.string,
|
||||||
dropDownIconSize: PropTypes.bool,
|
dropDownIconSize: PropTypes.bool,
|
||||||
|
contentType: PropTypes.oneOf(['POST', 'COMMENT']).isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
ActionsDropdown.defaultProps = {
|
ActionsDropdown.defaultProps = {
|
||||||
@@ -125,4 +126,4 @@ ActionsDropdown.defaultProps = {
|
|||||||
dropDownIconSize: false,
|
dropDownIconSize: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default injectIntl(ActionsDropdown);
|
export default ActionsDropdown;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
|
|||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
|
|
||||||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import { Alert } from '@edx/paragon';
|
import { Alert } from '@edx/paragon';
|
||||||
import { Report } from '@edx/paragon/icons';
|
import { Report } from '@edx/paragon/icons';
|
||||||
|
|
||||||
@@ -12,26 +12,31 @@ import { AvatarOutlineAndLabelColors } from '../../data/constants';
|
|||||||
import {
|
import {
|
||||||
selectModerationSettings, selectUserHasModerationPrivileges, selectUserIsGroupTa, selectUserIsStaff,
|
selectModerationSettings, selectUserHasModerationPrivileges, selectUserIsGroupTa, selectUserIsStaff,
|
||||||
} from '../data/selectors';
|
} from '../data/selectors';
|
||||||
import { commentShape } from '../post-comments/comments/comment/proptypes';
|
|
||||||
import messages from '../post-comments/messages';
|
import messages from '../post-comments/messages';
|
||||||
import { postShape } from '../posts/post/proptypes';
|
|
||||||
import AlertBar from './AlertBar';
|
import AlertBar from './AlertBar';
|
||||||
|
|
||||||
function AlertBanner({
|
const AlertBanner = ({
|
||||||
intl,
|
author,
|
||||||
content,
|
abuseFlagged,
|
||||||
}) {
|
lastEdit,
|
||||||
|
closed,
|
||||||
|
closedBy,
|
||||||
|
closeReason,
|
||||||
|
editByLabel,
|
||||||
|
closedByLabel,
|
||||||
|
}) => {
|
||||||
|
const intl = useIntl();
|
||||||
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
|
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
|
||||||
const userIsGroupTa = useSelector(selectUserIsGroupTa);
|
const userIsGroupTa = useSelector(selectUserIsGroupTa);
|
||||||
const userIsGlobalStaff = useSelector(selectUserIsStaff);
|
const userIsGlobalStaff = useSelector(selectUserIsStaff);
|
||||||
const { reasonCodesEnabled } = useSelector(selectModerationSettings);
|
const { reasonCodesEnabled } = useSelector(selectModerationSettings);
|
||||||
const userIsContentAuthor = getAuthenticatedUser().username === content.author;
|
const userIsContentAuthor = getAuthenticatedUser().username === author;
|
||||||
const canSeeReportedBanner = content?.abuseFlagged;
|
const canSeeReportedBanner = abuseFlagged;
|
||||||
const canSeeLastEditOrClosedAlert = (userHasModerationPrivileges || userIsGroupTa
|
const canSeeLastEditOrClosedAlert = (userHasModerationPrivileges || userIsGroupTa
|
||||||
|| userIsGlobalStaff || userIsContentAuthor
|
|| userIsGlobalStaff || userIsContentAuthor
|
||||||
);
|
);
|
||||||
const editByLabelColor = AvatarOutlineAndLabelColors[content.editByLabel];
|
const editByLabelColor = AvatarOutlineAndLabelColors[editByLabel];
|
||||||
const closedByLabelColor = AvatarOutlineAndLabelColors[content.closedByLabel];
|
const closedByLabelColor = AvatarOutlineAndLabelColors[closedByLabel];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -42,33 +47,52 @@ function AlertBanner({
|
|||||||
)}
|
)}
|
||||||
{reasonCodesEnabled && canSeeLastEditOrClosedAlert && (
|
{reasonCodesEnabled && canSeeLastEditOrClosedAlert && (
|
||||||
<>
|
<>
|
||||||
{content.lastEdit?.reason && (
|
{lastEdit?.reason && (
|
||||||
<AlertBar
|
<AlertBar
|
||||||
message={messages.editedBy}
|
message={intl.formatMessage(messages.editedBy)}
|
||||||
author={content.lastEdit.editorUsername}
|
author={lastEdit.editorUsername}
|
||||||
authorLabel={content.editByLabel}
|
authorLabel={editByLabel}
|
||||||
labelColor={editByLabelColor && `text-${editByLabelColor}`}
|
labelColor={editByLabelColor && `text-${editByLabelColor}`}
|
||||||
reason={content.lastEdit.reason}
|
reason={lastEdit.reason}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{content.closed && (
|
{closed && (
|
||||||
<AlertBar
|
<AlertBar
|
||||||
message={messages.closedBy}
|
message={intl.formatMessage(messages.closedBy)}
|
||||||
author={content.closedBy}
|
author={closedBy}
|
||||||
authorLabel={content.closedByLabel}
|
authorLabel={closedByLabel}
|
||||||
labelColor={closedByLabelColor && `text-${closedByLabelColor}`}
|
labelColor={closedByLabelColor && `text-${closedByLabelColor}`}
|
||||||
reason={content.closeReason}
|
reason={closeReason}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
AlertBanner.propTypes = {
|
|
||||||
intl: intlShape.isRequired,
|
|
||||||
content: PropTypes.oneOfType([commentShape.isRequired, postShape.isRequired]).isRequired,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default injectIntl(AlertBanner);
|
AlertBanner.propTypes = {
|
||||||
|
author: PropTypes.string.isRequired,
|
||||||
|
abuseFlagged: PropTypes.bool,
|
||||||
|
closed: PropTypes.bool,
|
||||||
|
closedBy: PropTypes.string,
|
||||||
|
closedByLabel: PropTypes.string,
|
||||||
|
closeReason: PropTypes.string,
|
||||||
|
editByLabel: PropTypes.string,
|
||||||
|
lastEdit: PropTypes.shape({
|
||||||
|
editorUsername: PropTypes.string,
|
||||||
|
reason: PropTypes.string,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
AlertBanner.defaultProps = {
|
||||||
|
abuseFlagged: false,
|
||||||
|
closed: undefined,
|
||||||
|
closedBy: undefined,
|
||||||
|
closedByLabel: undefined,
|
||||||
|
closeReason: undefined,
|
||||||
|
editByLabel: undefined,
|
||||||
|
lastEdit: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default React.memo(AlertBanner);
|
||||||
|
|||||||
@@ -1,24 +1,25 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import { Alert } from '@edx/paragon';
|
import { Alert } from '@edx/paragon';
|
||||||
|
|
||||||
import messages from '../post-comments/messages';
|
import messages from '../post-comments/messages';
|
||||||
import AuthorLabel from './AuthorLabel';
|
import AuthorLabel from './AuthorLabel';
|
||||||
|
|
||||||
function AlertBar({
|
const AlertBar = ({
|
||||||
intl,
|
|
||||||
message,
|
message,
|
||||||
author,
|
author,
|
||||||
authorLabel,
|
authorLabel,
|
||||||
labelColor,
|
labelColor,
|
||||||
reason,
|
reason,
|
||||||
}) {
|
}) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Alert variant="info" className="px-3 shadow-none mb-1 py-10px bg-light-200">
|
<Alert variant="info" className="px-3 shadow-none mb-1 py-10px bg-light-200">
|
||||||
<div className="d-flex align-items-center flex-wrap text-gray-700 font-style">
|
<div className="d-flex align-items-center flex-wrap text-gray-700 font-style">
|
||||||
{intl.formatMessage(message)}
|
{message}
|
||||||
<span className="ml-1">
|
<span className="ml-1">
|
||||||
<AuthorLabel
|
<AuthorLabel
|
||||||
author={author}
|
author={author}
|
||||||
@@ -38,10 +39,9 @@ function AlertBar({
|
|||||||
</div>
|
</div>
|
||||||
</Alert>
|
</Alert>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
AlertBar.propTypes = {
|
AlertBar.propTypes = {
|
||||||
intl: intlShape.isRequired,
|
|
||||||
message: PropTypes.string,
|
message: PropTypes.string,
|
||||||
author: PropTypes.string,
|
author: PropTypes.string,
|
||||||
authorLabel: PropTypes.string,
|
authorLabel: PropTypes.string,
|
||||||
@@ -57,4 +57,4 @@ AlertBar.defaultProps = {
|
|||||||
reason: '',
|
reason: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default injectIntl(AlertBar);
|
export default React.memo(AlertBar);
|
||||||
|
|||||||
@@ -1,23 +1,22 @@
|
|||||||
import React, { useContext } from 'react';
|
import React, { useContext, useMemo } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
import { generatePath } from 'react-router';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
import * as timeago from 'timeago.js';
|
import * as timeago from 'timeago.js';
|
||||||
|
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import { Icon, OverlayTrigger, Tooltip } from '@edx/paragon';
|
import { Icon, OverlayTrigger, Tooltip } from '@edx/paragon';
|
||||||
import { Institution, School } from '@edx/paragon/icons';
|
import { Institution, School } from '@edx/paragon/icons';
|
||||||
|
|
||||||
import { Routes } from '../../data/constants';
|
import { Routes } from '../../data/constants';
|
||||||
import { useShowLearnersTab } from '../data/hooks';
|
import { useShowLearnersTab } from '../data/hooks';
|
||||||
import messages from '../messages';
|
import messages from '../messages';
|
||||||
import { discussionsPath } from '../utils';
|
|
||||||
import { DiscussionContext } from './context';
|
import { DiscussionContext } from './context';
|
||||||
import timeLocale from './time-locale';
|
import timeLocale from './time-locale';
|
||||||
|
|
||||||
function AuthorLabel({
|
const AuthorLabel = ({
|
||||||
intl,
|
|
||||||
author,
|
author,
|
||||||
authorLabel,
|
authorLabel,
|
||||||
linkToProfile,
|
linkToProfile,
|
||||||
@@ -26,17 +25,18 @@ function AuthorLabel({
|
|||||||
postCreatedAt,
|
postCreatedAt,
|
||||||
authorToolTip,
|
authorToolTip,
|
||||||
postOrComment,
|
postOrComment,
|
||||||
}) {
|
}) => {
|
||||||
const location = useLocation();
|
timeago.register('time-locale', timeLocale);
|
||||||
|
const intl = useIntl();
|
||||||
const { courseId } = useContext(DiscussionContext);
|
const { courseId } = useContext(DiscussionContext);
|
||||||
let icon = null;
|
let icon = null;
|
||||||
let authorLabelMessage = null;
|
let authorLabelMessage = null;
|
||||||
timeago.register('time-locale', timeLocale);
|
|
||||||
|
|
||||||
if (authorLabel === 'Staff') {
|
if (authorLabel === 'Staff') {
|
||||||
icon = Institution;
|
icon = Institution;
|
||||||
authorLabelMessage = intl.formatMessage(messages.authorLabelStaff);
|
authorLabelMessage = intl.formatMessage(messages.authorLabelStaff);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (authorLabel === 'Community TA') {
|
if (authorLabel === 'Community TA') {
|
||||||
icon = School;
|
icon = School;
|
||||||
authorLabelMessage = intl.formatMessage(messages.authorLabelTA);
|
authorLabelMessage = intl.formatMessage(messages.authorLabelTA);
|
||||||
@@ -49,7 +49,7 @@ function AuthorLabel({
|
|||||||
const showUserNameAsLink = useShowLearnersTab()
|
const showUserNameAsLink = useShowLearnersTab()
|
||||||
&& linkToProfile && author && author !== intl.formatMessage(messages.anonymous);
|
&& linkToProfile && author && author !== intl.formatMessage(messages.anonymous);
|
||||||
|
|
||||||
const authorName = (
|
const authorName = useMemo(() => (
|
||||||
<span
|
<span
|
||||||
className={classNames('mr-1.5 font-size-14 font-style font-weight-500', {
|
className={classNames('mr-1.5 font-size-14 font-style font-weight-500', {
|
||||||
'text-gray-700': isRetiredUser,
|
'text-gray-700': isRetiredUser,
|
||||||
@@ -60,8 +60,9 @@ function AuthorLabel({
|
|||||||
>
|
>
|
||||||
{isRetiredUser ? '[Deactivated]' : author}
|
{isRetiredUser ? '[Deactivated]' : author}
|
||||||
</span>
|
</span>
|
||||||
);
|
), [author, authorLabelMessage, isRetiredUser]);
|
||||||
const labelContents = (
|
|
||||||
|
const labelContents = useMemo(() => (
|
||||||
<>
|
<>
|
||||||
<OverlayTrigger
|
<OverlayTrigger
|
||||||
overlay={(
|
overlay={(
|
||||||
@@ -109,7 +110,7 @@ function AuthorLabel({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
), [author, authorLabelMessage, authorToolTip, icon, isRetiredUser, postCreatedAt, showTextPrimary, alert]);
|
||||||
|
|
||||||
return showUserNameAsLink
|
return showUserNameAsLink
|
||||||
? (
|
? (
|
||||||
@@ -117,7 +118,7 @@ function AuthorLabel({
|
|||||||
<Link
|
<Link
|
||||||
data-testid="learner-posts-link"
|
data-testid="learner-posts-link"
|
||||||
id="learner-posts-link"
|
id="learner-posts-link"
|
||||||
to={discussionsPath(Routes.LEARNERS.POSTS, { learnerUsername: author, courseId })(location)}
|
to={generatePath(Routes.LEARNERS.POSTS, { learnerUsername: author, courseId })}
|
||||||
className="text-decoration-none"
|
className="text-decoration-none"
|
||||||
style={{ width: 'fit-content' }}
|
style={{ width: 'fit-content' }}
|
||||||
>
|
>
|
||||||
@@ -127,10 +128,9 @@ function AuthorLabel({
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
: <div className={className}>{authorName}{labelContents}</div>;
|
: <div className={className}>{authorName}{labelContents}</div>;
|
||||||
}
|
};
|
||||||
|
|
||||||
AuthorLabel.propTypes = {
|
AuthorLabel.propTypes = {
|
||||||
intl: intlShape.isRequired,
|
|
||||||
author: PropTypes.string.isRequired,
|
author: PropTypes.string.isRequired,
|
||||||
authorLabel: PropTypes.string,
|
authorLabel: PropTypes.string,
|
||||||
linkToProfile: PropTypes.bool,
|
linkToProfile: PropTypes.bool,
|
||||||
@@ -151,4 +151,4 @@ AuthorLabel.defaultProps = {
|
|||||||
postOrComment: false,
|
postOrComment: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default injectIntl(AuthorLabel);
|
export default React.memo(AuthorLabel);
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import { ActionRow, Button, ModalDialog } from '@edx/paragon';
|
import { ActionRow, Button, ModalDialog } from '@edx/paragon';
|
||||||
|
|
||||||
import messages from '../messages';
|
import messages from '../messages';
|
||||||
|
|
||||||
function Confirmation({
|
function Confirmation({
|
||||||
intl,
|
|
||||||
isOpen,
|
isOpen,
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
@@ -17,6 +16,8 @@ function Confirmation({
|
|||||||
confirmButtonVariant,
|
confirmButtonVariant,
|
||||||
confirmButtonText,
|
confirmButtonText,
|
||||||
}) {
|
}) {
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ModalDialog title={title} isOpen={isOpen} hasCloseButton={false} onClose={onClose} zIndex={5000}>
|
<ModalDialog title={title} isOpen={isOpen} hasCloseButton={false} onClose={onClose} zIndex={5000}>
|
||||||
<ModalDialog.Header>
|
<ModalDialog.Header>
|
||||||
@@ -42,7 +43,6 @@ function Confirmation({
|
|||||||
}
|
}
|
||||||
|
|
||||||
Confirmation.propTypes = {
|
Confirmation.propTypes = {
|
||||||
intl: intlShape.isRequired,
|
|
||||||
isOpen: PropTypes.bool.isRequired,
|
isOpen: PropTypes.bool.isRequired,
|
||||||
onClose: PropTypes.func.isRequired,
|
onClose: PropTypes.func.isRequired,
|
||||||
comfirmAction: PropTypes.func.isRequired,
|
comfirmAction: PropTypes.func.isRequired,
|
||||||
@@ -59,4 +59,4 @@ Confirmation.defaultProps = {
|
|||||||
confirmButtonText: '',
|
confirmButtonText: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default injectIntl(Confirmation);
|
export default React.memo(Confirmation);
|
||||||
|
|||||||
@@ -1,30 +1,34 @@
|
|||||||
import React from 'react';
|
import React, { useContext } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
import * as timeago from 'timeago.js';
|
import * as timeago from 'timeago.js';
|
||||||
|
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import { Alert, Icon } from '@edx/paragon';
|
import { Alert, Icon } from '@edx/paragon';
|
||||||
import { CheckCircle, Verified } from '@edx/paragon/icons';
|
import { CheckCircle, Verified } from '@edx/paragon/icons';
|
||||||
|
|
||||||
import { ThreadType } from '../../data/constants';
|
import { ThreadType } from '../../data/constants';
|
||||||
import { commentShape } from '../post-comments/comments/comment/proptypes';
|
|
||||||
import messages from '../post-comments/messages';
|
import messages from '../post-comments/messages';
|
||||||
|
import { PostCommentsContext } from '../post-comments/postCommentsContext';
|
||||||
import AuthorLabel from './AuthorLabel';
|
import AuthorLabel from './AuthorLabel';
|
||||||
import timeLocale from './time-locale';
|
import timeLocale from './time-locale';
|
||||||
|
|
||||||
function EndorsedAlertBanner({
|
function EndorsedAlertBanner({
|
||||||
intl,
|
endorsed,
|
||||||
content,
|
endorsedAt,
|
||||||
postType,
|
endorsedBy,
|
||||||
|
endorsedByLabel,
|
||||||
}) {
|
}) {
|
||||||
timeago.register('time-locale', timeLocale);
|
timeago.register('time-locale', timeLocale);
|
||||||
|
|
||||||
|
const intl = useIntl();
|
||||||
|
const { postType } = useContext(PostCommentsContext);
|
||||||
const isQuestion = postType === ThreadType.QUESTION;
|
const isQuestion = postType === ThreadType.QUESTION;
|
||||||
const classes = isQuestion ? 'bg-success-500 text-white' : 'bg-dark-500 text-white';
|
const classes = isQuestion ? 'bg-success-500 text-white' : 'bg-dark-500 text-white';
|
||||||
const iconClass = isQuestion ? CheckCircle : Verified;
|
const iconClass = isQuestion ? CheckCircle : Verified;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
content.endorsed && (
|
endorsed && (
|
||||||
<Alert
|
<Alert
|
||||||
variant="plain"
|
variant="plain"
|
||||||
className={`px-2.5 mb-0 py-8px align-items-center shadow-none ${classes}`}
|
className={`px-2.5 mb-0 py-8px align-items-center shadow-none ${classes}`}
|
||||||
@@ -45,11 +49,11 @@ function EndorsedAlertBanner({
|
|||||||
</div>
|
</div>
|
||||||
<span className="d-flex align-items-center align-items-center flex-wrap" style={{ marginRight: '-1px' }}>
|
<span className="d-flex align-items-center align-items-center flex-wrap" style={{ marginRight: '-1px' }}>
|
||||||
<AuthorLabel
|
<AuthorLabel
|
||||||
author={content.endorsedBy}
|
author={endorsedBy}
|
||||||
authorLabel={content.endorsedByLabel}
|
authorLabel={endorsedByLabel}
|
||||||
linkToProfile
|
linkToProfile
|
||||||
alert={content.endorsed}
|
alert={endorsed}
|
||||||
postCreatedAt={content.endorsedAt}
|
postCreatedAt={endorsedAt}
|
||||||
authorToolTip
|
authorToolTip
|
||||||
postOrComment
|
postOrComment
|
||||||
/>
|
/>
|
||||||
@@ -61,13 +65,16 @@ function EndorsedAlertBanner({
|
|||||||
}
|
}
|
||||||
|
|
||||||
EndorsedAlertBanner.propTypes = {
|
EndorsedAlertBanner.propTypes = {
|
||||||
intl: intlShape.isRequired,
|
endorsed: PropTypes.bool.isRequired,
|
||||||
content: PropTypes.oneOfType([commentShape.isRequired]).isRequired,
|
endorsedAt: PropTypes.string,
|
||||||
postType: PropTypes.string,
|
endorsedBy: PropTypes.string,
|
||||||
|
endorsedByLabel: PropTypes.string,
|
||||||
};
|
};
|
||||||
|
|
||||||
EndorsedAlertBanner.defaultProps = {
|
EndorsedAlertBanner.defaultProps = {
|
||||||
postType: null,
|
endorsedAt: null,
|
||||||
|
endorsedBy: null,
|
||||||
|
endorsedByLabel: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default injectIntl(EndorsedAlertBanner);
|
export default React.memo(EndorsedAlertBanner);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
|
|||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import {
|
import {
|
||||||
Button, Icon, IconButton, OverlayTrigger, Tooltip,
|
Button, Icon, IconButton, OverlayTrigger, Tooltip,
|
||||||
} from '@edx/paragon';
|
} from '@edx/paragon';
|
||||||
@@ -12,29 +12,32 @@ import {
|
|||||||
StarFilled, StarOutline, ThumbUpFilled, ThumbUpOutline,
|
StarFilled, StarOutline, ThumbUpFilled, ThumbUpOutline,
|
||||||
} from '../../components/icons';
|
} from '../../components/icons';
|
||||||
import { useUserCanAddThreadInBlackoutDate } from '../data/hooks';
|
import { useUserCanAddThreadInBlackoutDate } from '../data/hooks';
|
||||||
import { commentShape } from '../post-comments/comments/comment/proptypes';
|
import { PostCommentsContext } from '../post-comments/postCommentsContext';
|
||||||
import { postShape } from '../posts/post/proptypes';
|
|
||||||
import ActionsDropdown from './ActionsDropdown';
|
import ActionsDropdown from './ActionsDropdown';
|
||||||
import { DiscussionContext } from './context';
|
import { DiscussionContext } from './context';
|
||||||
|
|
||||||
function HoverCard({
|
const HoverCard = ({
|
||||||
intl,
|
id,
|
||||||
commentOrPost,
|
contentType,
|
||||||
actionHandlers,
|
actionHandlers,
|
||||||
handleResponseCommentButton,
|
handleResponseCommentButton,
|
||||||
addResponseCommentButtonMessage,
|
addResponseCommentButtonMessage,
|
||||||
onLike,
|
onLike,
|
||||||
onFollow,
|
onFollow,
|
||||||
isClosedPost,
|
voted,
|
||||||
|
following,
|
||||||
endorseIcons,
|
endorseIcons,
|
||||||
}) {
|
}) => {
|
||||||
|
const intl = useIntl();
|
||||||
const { enableInContextSidebar } = useContext(DiscussionContext);
|
const { enableInContextSidebar } = useContext(DiscussionContext);
|
||||||
|
const { isClosed } = useContext(PostCommentsContext);
|
||||||
const userCanAddThreadInBlackoutDate = useUserCanAddThreadInBlackoutDate();
|
const userCanAddThreadInBlackoutDate = useUserCanAddThreadInBlackoutDate();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex-fill justify-content-end align-items-center hover-card mr-n4 position-absolute"
|
className="flex-fill justify-content-end align-items-center hover-card mr-n4 position-absolute"
|
||||||
data-testid={`hover-card-${commentOrPost.id}`}
|
data-testid={`hover-card-${id}`}
|
||||||
id={`hover-card-${commentOrPost.id}`}
|
id={`hover-card-${id}`}
|
||||||
>
|
>
|
||||||
{userCanAddThreadInBlackoutDate && (
|
{userCanAddThreadInBlackoutDate && (
|
||||||
<div className="d-flex">
|
<div className="d-flex">
|
||||||
@@ -43,7 +46,7 @@ function HoverCard({
|
|||||||
className={classNames('px-2.5 py-2 border-0 font-style text-gray-700 font-size-12',
|
className={classNames('px-2.5 py-2 border-0 font-style text-gray-700 font-size-12',
|
||||||
{ 'w-100': enableInContextSidebar })}
|
{ 'w-100': enableInContextSidebar })}
|
||||||
onClick={() => handleResponseCommentButton()}
|
onClick={() => handleResponseCommentButton()}
|
||||||
disabled={isClosedPost}
|
disabled={isClosed}
|
||||||
style={{ lineHeight: '20px' }}
|
style={{ lineHeight: '20px' }}
|
||||||
>
|
>
|
||||||
{addResponseCommentButtonMessage}
|
{addResponseCommentButtonMessage}
|
||||||
@@ -76,7 +79,7 @@ function HoverCard({
|
|||||||
)}
|
)}
|
||||||
<div className="hover-button">
|
<div className="hover-button">
|
||||||
<IconButton
|
<IconButton
|
||||||
src={commentOrPost.voted ? ThumbUpFilled : ThumbUpOutline}
|
src={voted ? ThumbUpFilled : ThumbUpOutline}
|
||||||
iconAs={Icon}
|
iconAs={Icon}
|
||||||
size="sm"
|
size="sm"
|
||||||
alt="Like"
|
alt="Like"
|
||||||
@@ -87,10 +90,10 @@ function HoverCard({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{commentOrPost.following !== undefined && (
|
{following !== undefined && (
|
||||||
<div className="hover-button">
|
<div className="hover-button">
|
||||||
<IconButton
|
<IconButton
|
||||||
src={commentOrPost.following ? StarFilled : StarOutline}
|
src={following ? StarFilled : StarOutline}
|
||||||
iconAs={Icon}
|
iconAs={Icon}
|
||||||
size="sm"
|
size="sm"
|
||||||
alt="Follow"
|
alt="Follow"
|
||||||
@@ -103,27 +106,34 @@ function HoverCard({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="hover-button ml-auto">
|
<div className="hover-button ml-auto">
|
||||||
<ActionsDropdown commentOrPost={commentOrPost} actionHandlers={actionHandlers} dropDownIconSize />
|
<ActionsDropdown
|
||||||
|
id={id}
|
||||||
|
contentType={contentType}
|
||||||
|
actionHandlers={actionHandlers}
|
||||||
|
dropDownIconSize
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
HoverCard.propTypes = {
|
HoverCard.propTypes = {
|
||||||
intl: intlShape.isRequired,
|
id: PropTypes.string.isRequired,
|
||||||
commentOrPost: PropTypes.oneOfType([commentShape, postShape]).isRequired,
|
contentType: PropTypes.string.isRequired,
|
||||||
actionHandlers: PropTypes.objectOf(PropTypes.func).isRequired,
|
actionHandlers: PropTypes.objectOf(PropTypes.func).isRequired,
|
||||||
handleResponseCommentButton: PropTypes.func.isRequired,
|
handleResponseCommentButton: PropTypes.func.isRequired,
|
||||||
onLike: PropTypes.func.isRequired,
|
|
||||||
onFollow: PropTypes.func,
|
|
||||||
addResponseCommentButtonMessage: PropTypes.string.isRequired,
|
addResponseCommentButtonMessage: PropTypes.string.isRequired,
|
||||||
isClosedPost: PropTypes.bool.isRequired,
|
onLike: PropTypes.func.isRequired,
|
||||||
|
voted: PropTypes.bool.isRequired,
|
||||||
endorseIcons: PropTypes.objectOf(PropTypes.any),
|
endorseIcons: PropTypes.objectOf(PropTypes.any),
|
||||||
|
onFollow: PropTypes.func,
|
||||||
|
following: PropTypes.bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
HoverCard.defaultProps = {
|
HoverCard.defaultProps = {
|
||||||
onFollow: () => null,
|
onFollow: () => null,
|
||||||
endorseIcons: null,
|
endorseIcons: null,
|
||||||
|
following: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default injectIntl(HoverCard);
|
export default React.memo(HoverCard);
|
||||||
|
|||||||
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 {
|
import {
|
||||||
useContext,
|
useCallback,
|
||||||
useEffect,
|
useContext, useEffect, useMemo, useRef, useState,
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from 'react';
|
} from 'react';
|
||||||
|
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
import { useHistory, useLocation, useRouteMatch } from 'react-router';
|
import { useHistory, useLocation, useRouteMatch } from 'react-router';
|
||||||
|
|
||||||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||||
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import { AppContext } from '@edx/frontend-platform/react';
|
import { AppContext } from '@edx/frontend-platform/react';
|
||||||
import { breakpoints, useWindowSize } from '@edx/paragon';
|
import { breakpoints, useWindowSize } from '@edx/paragon';
|
||||||
|
|
||||||
@@ -42,16 +40,14 @@ import { fetchCourseConfig } from './thunks';
|
|||||||
|
|
||||||
export function useTotalTopicThreadCount() {
|
export function useTotalTopicThreadCount() {
|
||||||
const topics = useSelector(selectTopics);
|
const topics = useSelector(selectTopics);
|
||||||
|
const count = useMemo(() => (
|
||||||
if (!topics) {
|
Object.keys(topics)?.reduce((total, topicId) => {
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Object.keys(topics)
|
|
||||||
.reduce((total, topicId) => {
|
|
||||||
const topic = topics[topicId];
|
const topic = topics[topicId];
|
||||||
return total + topic.threadCounts.discussion + topic.threadCounts.question;
|
return total + topic.threadCounts.discussion + topic.threadCounts.question;
|
||||||
}, 0);
|
}, 0)),
|
||||||
|
[]);
|
||||||
|
|
||||||
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useSidebarVisible = () => {
|
export const useSidebarVisible = () => {
|
||||||
@@ -87,13 +83,14 @@ export function useCourseDiscussionData(courseId) {
|
|||||||
|
|
||||||
export function useRedirectToThread(courseId, enableInContextSidebar) {
|
export function useRedirectToThread(courseId, enableInContextSidebar) {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const redirectToThread = useSelector(
|
|
||||||
(state) => state.threads.redirectToThread,
|
|
||||||
);
|
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
return useEffect(() => {
|
const redirectToThread = useSelector(
|
||||||
|
(state) => state.threads.redirectToThread,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
// After posting a new thread we'd like to redirect users to it, the topic and post id are temporarily
|
// After posting a new thread we'd like to redirect users to it, the topic and post id are temporarily
|
||||||
// stored in redirectToThread
|
// stored in redirectToThread
|
||||||
if (redirectToThread) {
|
if (redirectToThread) {
|
||||||
@@ -153,17 +150,20 @@ export function useContainerSize(refContainer) {
|
|||||||
return height;
|
return height;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAlertBannerVisible = (content) => {
|
export const useAlertBannerVisible = (
|
||||||
|
{
|
||||||
|
author, abuseFlagged, lastEdit, closed,
|
||||||
|
} = {},
|
||||||
|
) => {
|
||||||
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
|
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
|
||||||
const userIsGroupTa = useSelector(selectUserIsGroupTa);
|
const userIsGroupTa = useSelector(selectUserIsGroupTa);
|
||||||
const { reasonCodesEnabled } = useSelector(selectModerationSettings);
|
const { reasonCodesEnabled } = useSelector(selectModerationSettings);
|
||||||
const userIsContentAuthor = getAuthenticatedUser().username === content.author;
|
const userIsContentAuthor = getAuthenticatedUser().username === author;
|
||||||
const canSeeLastEditOrClosedAlert = (userHasModerationPrivileges || userIsContentAuthor || userIsGroupTa);
|
const canSeeLastEditOrClosedAlert = (userHasModerationPrivileges || userIsContentAuthor || userIsGroupTa);
|
||||||
const canSeeReportedBanner = content.abuseFlagged;
|
const canSeeReportedBanner = abuseFlagged;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
(reasonCodesEnabled && canSeeLastEditOrClosedAlert && (content.lastEdit?.reason || content.closed))
|
(reasonCodesEnabled && canSeeLastEditOrClosedAlert && (lastEdit?.reason || closed)) || (canSeeReportedBanner)
|
||||||
|| (content.abuseFlagged && canSeeReportedBanner)
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -193,38 +193,50 @@ export const useCurrentDiscussionTopic = () => {
|
|||||||
export const useUserCanAddThreadInBlackoutDate = () => {
|
export const useUserCanAddThreadInBlackoutDate = () => {
|
||||||
const blackoutDateRange = useSelector(selectBlackoutDate);
|
const blackoutDateRange = useSelector(selectBlackoutDate);
|
||||||
const isUserAdmin = useSelector(selectUserIsStaff);
|
const isUserAdmin = useSelector(selectUserIsStaff);
|
||||||
const userHasModerationPrivilages = useSelector(selectUserHasModerationPrivileges);
|
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
|
||||||
const isUserGroupTA = useSelector(selectUserIsGroupTa);
|
const isUserGroupTA = useSelector(selectUserIsGroupTa);
|
||||||
const isCourseAdmin = useSelector(selectIsCourseAdmin);
|
const isCourseAdmin = useSelector(selectIsCourseAdmin);
|
||||||
const isCourseStaff = useSelector(selectIsCourseStaff);
|
const isCourseStaff = useSelector(selectIsCourseStaff);
|
||||||
const isInBlackoutDateRange = inBlackoutDateRange(blackoutDateRange);
|
const isPrivileged = isUserAdmin || userHasModerationPrivileges || isUserGroupTA || isCourseAdmin || isCourseStaff;
|
||||||
|
const isInBlackoutDateRange = useMemo(() => inBlackoutDateRange(blackoutDateRange), [blackoutDateRange]);
|
||||||
|
|
||||||
return (!(isInBlackoutDateRange)
|
return (!(isInBlackoutDateRange) || (isPrivileged));
|
||||||
|| (isUserAdmin || userHasModerationPrivilages || isUserGroupTA || isCourseAdmin || isCourseStaff));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function camelToConstant(string) {
|
function camelToConstant(string) {
|
||||||
return string.replace(/[A-Z]/g, (match) => `_${match}`).toUpperCase();
|
return string.replace(/[A-Z]/g, (match) => `_${match}`).toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useTourConfiguration = (intl) => {
|
export const useTourConfiguration = () => {
|
||||||
|
const intl = useIntl();
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
const { enableInContextSidebar } = useContext(DiscussionContext);
|
const { enableInContextSidebar } = useContext(DiscussionContext);
|
||||||
const tours = useSelector(selectTours);
|
const tours = useSelector(selectTours);
|
||||||
|
|
||||||
return tours.map((tour) => (
|
const handleOnDismiss = useCallback((id) => (
|
||||||
{
|
dispatch(updateTourShowStatus(id))
|
||||||
tourId: tour.tourName,
|
), []);
|
||||||
advanceButtonText: intl.formatMessage(messages.advanceButtonText),
|
|
||||||
dismissButtonText: intl.formatMessage(messages.dismissButtonText),
|
const handleOnEnd = useCallback((id) => (
|
||||||
endButtonText: intl.formatMessage(messages.endButtonText),
|
dispatch(updateTourShowStatus(id))
|
||||||
enabled: tour && Boolean(tour.enabled && tour.showTour && !enableInContextSidebar),
|
), []);
|
||||||
onDismiss: () => dispatch(updateTourShowStatus(tour.id)),
|
|
||||||
onEnd: () => dispatch(updateTourShowStatus(tour.id)),
|
const toursConfig = useMemo(() => (
|
||||||
checkpoints: tourCheckpoints(intl)[camelToConstant(tour.tourName)],
|
tours?.map((tour) => (
|
||||||
}
|
{
|
||||||
));
|
tourId: tour.tourName,
|
||||||
|
advanceButtonText: intl.formatMessage(messages.advanceButtonText),
|
||||||
|
dismissButtonText: intl.formatMessage(messages.dismissButtonText),
|
||||||
|
endButtonText: intl.formatMessage(messages.endButtonText),
|
||||||
|
enabled: tour && Boolean(tour.enabled && tour.showTour && !enableInContextSidebar),
|
||||||
|
onDismiss: () => handleOnDismiss(tour.id),
|
||||||
|
onEnd: () => handleOnEnd(tour.id),
|
||||||
|
checkpoints: tourCheckpoints(intl)[camelToConstant(tour.tourName)],
|
||||||
|
}
|
||||||
|
))
|
||||||
|
), [tours, enableInContextSidebar]);
|
||||||
|
|
||||||
|
return toursConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useDebounce = (value, delay) => {
|
export const useDebounce = (value, delay) => {
|
||||||
|
|||||||
@@ -1,36 +1,39 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useCallback, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
|
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import { PageBanner } from '@edx/paragon';
|
import { PageBanner } from '@edx/paragon';
|
||||||
|
|
||||||
import { selectBlackoutDate } from '../data/selectors';
|
import { selectBlackoutDate } from '../data/selectors';
|
||||||
import messages from '../messages';
|
import messages from '../messages';
|
||||||
import { inBlackoutDateRange } from '../utils';
|
import { inBlackoutDateRange } from '../utils';
|
||||||
|
|
||||||
function BlackoutInformationBanner({
|
const BlackoutInformationBanner = () => {
|
||||||
intl,
|
const intl = useIntl();
|
||||||
}) {
|
const blackoutDate = useSelector(selectBlackoutDate);
|
||||||
const isDiscussionsBlackout = inBlackoutDateRange(useSelector(selectBlackoutDate));
|
|
||||||
const [showBanner, setShowBanner] = useState(true);
|
const [showBanner, setShowBanner] = useState(true);
|
||||||
|
|
||||||
|
const isDiscussionsBlackout = useMemo(() => (
|
||||||
|
inBlackoutDateRange(blackoutDate)
|
||||||
|
), [blackoutDate]);
|
||||||
|
|
||||||
|
const handleDismiss = useCallback(() => {
|
||||||
|
setShowBanner(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageBanner
|
<PageBanner
|
||||||
variant="accentB"
|
variant="accentB"
|
||||||
show={isDiscussionsBlackout && showBanner}
|
show={isDiscussionsBlackout && showBanner}
|
||||||
dismissible
|
dismissible
|
||||||
onDismiss={() => setShowBanner(false)}
|
onDismiss={handleDismiss}
|
||||||
>
|
>
|
||||||
<div className="font-weight-500">
|
<div className="font-weight-500">
|
||||||
{intl.formatMessage(messages.blackoutDiscussionInformation)}
|
{intl.formatMessage(messages.blackoutDiscussionInformation)}
|
||||||
</div>
|
</div>
|
||||||
</PageBanner>
|
</PageBanner>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
BlackoutInformationBanner.propTypes = {
|
|
||||||
intl: intlShape.isRequired,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default injectIntl(BlackoutInformationBanner);
|
export default BlackoutInformationBanner;
|
||||||
|
|||||||
@@ -1,37 +1,39 @@
|
|||||||
import React from 'react';
|
import React, { lazy, Suspense } from 'react';
|
||||||
|
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
import { Route, Switch } from 'react-router';
|
import { Route, Switch } from 'react-router';
|
||||||
|
|
||||||
import { injectIntl } from '@edx/frontend-platform/i18n';
|
import Spinner from '../../components/Spinner';
|
||||||
|
|
||||||
import { Routes } from '../../data/constants';
|
import { Routes } from '../../data/constants';
|
||||||
import { PostCommentsView } from '../post-comments';
|
|
||||||
import { PostEditor } from '../posts';
|
|
||||||
|
|
||||||
function DiscussionContent() {
|
const PostEditor = lazy(() => import('../posts/post-editor/PostEditor'));
|
||||||
|
const PostCommentsView = lazy(() => import('../post-comments/PostCommentsView'));
|
||||||
|
|
||||||
|
const DiscussionContent = () => {
|
||||||
const postEditorVisible = useSelector((state) => state.threads.postEditorVisible);
|
const postEditorVisible = useSelector((state) => state.threads.postEditorVisible);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="d-flex bg-light-400 flex-column w-75 w-xs-100 w-xl-75 align-items-center">
|
<div className="d-flex bg-light-400 flex-column w-75 w-xs-100 w-xl-75 align-items-center">
|
||||||
<div className="d-flex flex-column w-100">
|
<div className="d-flex flex-column w-100">
|
||||||
{postEditorVisible ? (
|
<Suspense fallback={(<Spinner />)}>
|
||||||
<Route path={Routes.POSTS.NEW_POST}>
|
{postEditorVisible ? (
|
||||||
<PostEditor />
|
<Route path={Routes.POSTS.NEW_POST}>
|
||||||
</Route>
|
<PostEditor />
|
||||||
) : (
|
|
||||||
<Switch>
|
|
||||||
<Route path={Routes.POSTS.EDIT_POST}>
|
|
||||||
<PostEditor editExisting />
|
|
||||||
</Route>
|
</Route>
|
||||||
<Route path={Routes.COMMENTS.PATH}>
|
) : (
|
||||||
<PostCommentsView />
|
<Switch>
|
||||||
</Route>
|
<Route path={Routes.POSTS.EDIT_POST}>
|
||||||
</Switch>
|
<PostEditor editExisting />
|
||||||
)}
|
</Route>
|
||||||
|
<Route path={Routes.COMMENTS.PATH}>
|
||||||
|
<PostCommentsView />
|
||||||
|
</Route>
|
||||||
|
</Switch>
|
||||||
|
)}
|
||||||
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default injectIntl(DiscussionContent);
|
export default DiscussionContent;
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import React, { useContext, useEffect, useRef } from 'react';
|
import React, {
|
||||||
|
lazy, Suspense, useContext, useEffect, useRef,
|
||||||
|
} from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
@@ -9,18 +11,22 @@ import {
|
|||||||
|
|
||||||
import { useWindowSize } from '@edx/paragon';
|
import { useWindowSize } from '@edx/paragon';
|
||||||
|
|
||||||
|
import Spinner from '../../components/Spinner';
|
||||||
import { RequestStatus, Routes } from '../../data/constants';
|
import { RequestStatus, Routes } from '../../data/constants';
|
||||||
import { DiscussionContext } from '../common/context';
|
import { DiscussionContext } from '../common/context';
|
||||||
import {
|
import {
|
||||||
useContainerSize, useIsOnDesktop, useIsOnXLDesktop, useShowLearnersTab,
|
useContainerSize, useIsOnDesktop, useIsOnXLDesktop, useShowLearnersTab,
|
||||||
} from '../data/hooks';
|
} from '../data/hooks';
|
||||||
import { selectconfigLoadingStatus, selectEnableInContext } from '../data/selectors';
|
import { selectconfigLoadingStatus, selectEnableInContext } from '../data/selectors';
|
||||||
import { TopicPostsView, TopicsView as InContextTopicsView } from '../in-context-topics';
|
|
||||||
import { LearnerPostsView, LearnersView } from '../learners';
|
|
||||||
import { PostsView } from '../posts';
|
|
||||||
import { TopicsView as LegacyTopicsView } from '../topics';
|
|
||||||
|
|
||||||
export default function DiscussionSidebar({ displaySidebar, postActionBarRef }) {
|
const TopicPostsView = lazy(() => import('../in-context-topics/TopicPostsView'));
|
||||||
|
const InContextTopicsView = lazy(() => import('../in-context-topics/TopicsView'));
|
||||||
|
const LearnerPostsView = lazy(() => import('../learners/LearnerPostsView'));
|
||||||
|
const LearnersView = lazy(() => import('../learners/LearnersView'));
|
||||||
|
const PostsView = lazy(() => import('../posts/PostsView'));
|
||||||
|
const LegacyTopicsView = lazy(() => import('../topics/TopicsView'));
|
||||||
|
|
||||||
|
const DiscussionSidebar = ({ displaySidebar, postActionBarRef }) => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const isOnDesktop = useIsOnDesktop();
|
const isOnDesktop = useIsOnDesktop();
|
||||||
const isOnXLDesktop = useIsOnXLDesktop();
|
const isOnXLDesktop = useIsOnXLDesktop();
|
||||||
@@ -55,15 +61,16 @@ export default function DiscussionSidebar({ displaySidebar, postActionBarRef })
|
|||||||
})}
|
})}
|
||||||
data-testid="sidebar"
|
data-testid="sidebar"
|
||||||
>
|
>
|
||||||
<Switch>
|
<Suspense fallback={(<Spinner />)}>
|
||||||
{enableInContext && !enableInContextSidebar && (
|
<Switch>
|
||||||
|
{enableInContext && !enableInContextSidebar && (
|
||||||
<Route
|
<Route
|
||||||
path={Routes.TOPICS.ALL}
|
path={Routes.TOPICS.ALL}
|
||||||
component={InContextTopicsView}
|
component={InContextTopicsView}
|
||||||
exact
|
exact
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{enableInContext && !enableInContextSidebar && (
|
{enableInContext && !enableInContextSidebar && (
|
||||||
<Route
|
<Route
|
||||||
path={[
|
path={[
|
||||||
Routes.TOPICS.TOPIC,
|
Routes.TOPICS.TOPIC,
|
||||||
@@ -74,19 +81,19 @@ export default function DiscussionSidebar({ displaySidebar, postActionBarRef })
|
|||||||
component={TopicPostsView}
|
component={TopicPostsView}
|
||||||
exact
|
exact
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Route
|
<Route
|
||||||
path={[Routes.POSTS.ALL_POSTS, Routes.POSTS.MY_POSTS, Routes.POSTS.PATH, Routes.TOPICS.CATEGORY]}
|
path={[Routes.POSTS.ALL_POSTS, Routes.POSTS.MY_POSTS, Routes.POSTS.PATH, Routes.TOPICS.CATEGORY]}
|
||||||
component={PostsView}
|
component={PostsView}
|
||||||
/>
|
/>
|
||||||
<Route path={Routes.TOPICS.PATH} component={LegacyTopicsView} />
|
<Route path={Routes.TOPICS.PATH} component={LegacyTopicsView} />
|
||||||
{redirectToLearnersTab && (
|
{redirectToLearnersTab && (
|
||||||
<Route path={Routes.LEARNERS.POSTS} component={LearnerPostsView} />
|
<Route path={Routes.LEARNERS.POSTS} component={LearnerPostsView} />
|
||||||
)}
|
)}
|
||||||
{redirectToLearnersTab && (
|
{redirectToLearnersTab && (
|
||||||
<Route path={Routes.LEARNERS.PATH} component={LearnersView} />
|
<Route path={Routes.LEARNERS.PATH} component={LearnersView} />
|
||||||
)}
|
)}
|
||||||
{configStatus === RequestStatus.SUCCESSFUL && (
|
{configStatus === RequestStatus.SUCCESSFUL && (
|
||||||
<Redirect
|
<Redirect
|
||||||
from={Routes.DISCUSSIONS.PATH}
|
from={Routes.DISCUSSIONS.PATH}
|
||||||
to={{
|
to={{
|
||||||
@@ -94,15 +101,11 @@ export default function DiscussionSidebar({ displaySidebar, postActionBarRef })
|
|||||||
pathname: Routes.POSTS.ALL_POSTS,
|
pathname: Routes.POSTS.ALL_POSTS,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Switch>
|
</Switch>
|
||||||
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
DiscussionSidebar.defaultProps = {
|
|
||||||
displaySidebar: false,
|
|
||||||
postActionBarRef: null,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
DiscussionSidebar.propTypes = {
|
DiscussionSidebar.propTypes = {
|
||||||
@@ -112,3 +115,10 @@ DiscussionSidebar.propTypes = {
|
|||||||
PropTypes.shape({ current: PropTypes.instanceOf(Element) }),
|
PropTypes.shape({ current: PropTypes.instanceOf(Element) }),
|
||||||
]),
|
]),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
DiscussionSidebar.defaultProps = {
|
||||||
|
displaySidebar: false,
|
||||||
|
postActionBarRef: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default React.memo(DiscussionSidebar);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useRef } from 'react';
|
import React, { lazy, Suspense, useRef } from 'react';
|
||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
@@ -6,12 +6,10 @@ import {
|
|||||||
Route, Switch, useLocation, useRouteMatch,
|
Route, Switch, useLocation, useRouteMatch,
|
||||||
} from 'react-router';
|
} from 'react-router';
|
||||||
|
|
||||||
import Footer from '@edx/frontend-component-footer';
|
|
||||||
import { LearningHeader as Header } from '@edx/frontend-component-header';
|
import { LearningHeader as Header } from '@edx/frontend-component-header';
|
||||||
import { getConfig } from '@edx/frontend-platform';
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
|
|
||||||
import { PostActionsBar } from '../../components';
|
import { Spinner } from '../../components';
|
||||||
import { CourseTabsNavigation } from '../../components/NavigationBar';
|
|
||||||
import { selectCourseTabs } from '../../components/NavigationBar/data/selectors';
|
import { selectCourseTabs } from '../../components/NavigationBar/data/selectors';
|
||||||
import { ALL_ROUTES, DiscussionProvider, Routes } from '../../data/constants';
|
import { ALL_ROUTES, DiscussionProvider, Routes } from '../../data/constants';
|
||||||
import { DiscussionContext } from '../common/context';
|
import { DiscussionContext } from '../common/context';
|
||||||
@@ -22,17 +20,21 @@ import { selectDiscussionProvider, selectEnableInContext } from '../data/selecto
|
|||||||
import { EmptyLearners, EmptyPosts, EmptyTopics } from '../empty-posts';
|
import { EmptyLearners, EmptyPosts, EmptyTopics } from '../empty-posts';
|
||||||
import { EmptyTopic as InContextEmptyTopics } from '../in-context-topics/components';
|
import { EmptyTopic as InContextEmptyTopics } from '../in-context-topics/components';
|
||||||
import messages from '../messages';
|
import messages from '../messages';
|
||||||
import { LegacyBreadcrumbMenu, NavigationBar } from '../navigation';
|
|
||||||
import { selectPostEditorVisible } from '../posts/data/selectors';
|
import { selectPostEditorVisible } from '../posts/data/selectors';
|
||||||
import DiscussionsProductTour from '../tours/DiscussionsProductTour';
|
|
||||||
import { postMessageToParent } from '../utils';
|
|
||||||
import BlackoutInformationBanner from './BlackoutInformationBanner';
|
|
||||||
import DiscussionContent from './DiscussionContent';
|
|
||||||
import DiscussionSidebar from './DiscussionSidebar';
|
|
||||||
import useFeedbackWrapper from './FeedbackWrapper';
|
import useFeedbackWrapper from './FeedbackWrapper';
|
||||||
import InformationBanner from './InformationBanner';
|
|
||||||
|
|
||||||
export default function DiscussionsHome() {
|
const Footer = lazy(() => import('@edx/frontend-component-footer'));
|
||||||
|
const PostActionsBar = lazy(() => import('../posts/post-actions-bar/PostActionsBar'));
|
||||||
|
const CourseTabsNavigation = lazy(() => import('../../components/NavigationBar/CourseTabsNavigation'));
|
||||||
|
const LegacyBreadcrumbMenu = lazy(() => import('../navigation/breadcrumb-menu/LegacyBreadcrumbMenu'));
|
||||||
|
const NavigationBar = lazy(() => import('../navigation/navigation-bar/NavigationBar'));
|
||||||
|
const DiscussionsProductTour = lazy(() => import('../tours/DiscussionsProductTour'));
|
||||||
|
const BlackoutInformationBanner = lazy(() => import('./BlackoutInformationBanner'));
|
||||||
|
const DiscussionContent = lazy(() => import('./DiscussionContent'));
|
||||||
|
const DiscussionSidebar = lazy(() => import('./DiscussionSidebar'));
|
||||||
|
const InformationBanner = lazy(() => import('./InformationBanner'));
|
||||||
|
|
||||||
|
const DiscussionsHome = () => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const postActionBarRef = useRef(null);
|
const postActionBarRef = useRef(null);
|
||||||
const postEditorVisible = useSelector(selectPostEditorVisible);
|
const postEditorVisible = useSelector(selectPostEditorVisible);
|
||||||
@@ -40,7 +42,6 @@ export default function DiscussionsHome() {
|
|||||||
const enableInContext = useSelector(selectEnableInContext);
|
const enableInContext = useSelector(selectEnableInContext);
|
||||||
const { courseNumber, courseTitle, org } = useSelector(selectCourseTabs);
|
const { courseNumber, courseTitle, org } = useSelector(selectCourseTabs);
|
||||||
const { params: { page } } = useRouteMatch(`${Routes.COMMENTS.PAGE}?`);
|
const { params: { page } } = useRouteMatch(`${Routes.COMMENTS.PAGE}?`);
|
||||||
const { params: { path } } = useRouteMatch(`${Routes.DISCUSSIONS.PATH}/:path*`);
|
|
||||||
const { params } = useRouteMatch(ALL_ROUTES);
|
const { params } = useRouteMatch(ALL_ROUTES);
|
||||||
const isRedirectToLearners = useShowLearnersTab();
|
const isRedirectToLearners = useShowLearnersTab();
|
||||||
const isOnDesktop = useIsOnDesktop();
|
const isOnDesktop = useIsOnDesktop();
|
||||||
@@ -60,54 +61,60 @@ export default function DiscussionsHome() {
|
|||||||
const displayContentArea = (postId || postEditorVisible || (learnerUsername && postId));
|
const displayContentArea = (postId || postEditorVisible || (learnerUsername && postId));
|
||||||
if (displayContentArea) { displaySidebar = isOnDesktop; }
|
if (displayContentArea) { displaySidebar = isOnDesktop; }
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (path && path !== 'undefined') {
|
|
||||||
postMessageToParent('discussions.navigate', { path });
|
|
||||||
}
|
|
||||||
}, [path]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DiscussionContext.Provider value={{
|
<Suspense fallback={(<Spinner />)}>
|
||||||
page,
|
<DiscussionContext.Provider value={{
|
||||||
courseId,
|
page,
|
||||||
postId,
|
courseId,
|
||||||
topicId,
|
postId,
|
||||||
enableInContextSidebar,
|
topicId,
|
||||||
category,
|
enableInContextSidebar,
|
||||||
learnerUsername,
|
category,
|
||||||
}}
|
learnerUsername,
|
||||||
>
|
}}
|
||||||
{!enableInContextSidebar && <Header courseOrg={org} courseNumber={courseNumber} courseTitle={courseTitle} />}
|
>
|
||||||
<main className="container-fluid d-flex flex-column p-0 w-100" id="main" tabIndex="-1">
|
{!enableInContextSidebar && (
|
||||||
{!enableInContextSidebar && <CourseTabsNavigation activeTab="discussion" courseId={courseId} />}
|
<Header courseOrg={org} courseNumber={courseNumber} courseTitle={courseTitle} />
|
||||||
<div
|
|
||||||
className={classNames('header-action-bar', {
|
|
||||||
'shadow-none border-light-300 border-bottom': enableInContextSidebar,
|
|
||||||
})}
|
|
||||||
ref={postActionBarRef}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={classNames('d-flex flex-row justify-content-between navbar fixed-top', {
|
|
||||||
'pl-4 pr-3 py-0': enableInContextSidebar,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{!enableInContextSidebar && <Route path={Routes.DISCUSSIONS.PATH} component={NavigationBar} />}
|
|
||||||
<PostActionsBar />
|
|
||||||
</div>
|
|
||||||
{isFeedbackBannerVisible && <InformationBanner />}
|
|
||||||
<BlackoutInformationBanner />
|
|
||||||
</div>
|
|
||||||
{provider === DiscussionProvider.LEGACY && (
|
|
||||||
<Route
|
|
||||||
path={[Routes.POSTS.PATH, Routes.TOPICS.CATEGORY]}
|
|
||||||
component={LegacyBreadcrumbMenu}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
<main className="container-fluid d-flex flex-column p-0 w-100" id="main" tabIndex="-1">
|
||||||
<div className="d-flex flex-row">
|
{!enableInContextSidebar && <CourseTabsNavigation activeTab="discussion" courseId={courseId} />}
|
||||||
<DiscussionSidebar displaySidebar={displaySidebar} postActionBarRef={postActionBarRef} />
|
<div
|
||||||
{displayContentArea && <DiscussionContent />}
|
className={classNames('header-action-bar', {
|
||||||
{!displayContentArea && (
|
'shadow-none border-light-300 border-bottom': enableInContextSidebar,
|
||||||
|
})}
|
||||||
|
ref={postActionBarRef}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={classNames('d-flex flex-row justify-content-between navbar fixed-top', {
|
||||||
|
'pl-4 pr-3 py-0': enableInContextSidebar,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{!enableInContextSidebar && (
|
||||||
|
<NavigationBar />
|
||||||
|
)}
|
||||||
|
<PostActionsBar />
|
||||||
|
</div>
|
||||||
|
{isFeedbackBannerVisible && <InformationBanner />}
|
||||||
|
<BlackoutInformationBanner />
|
||||||
|
</div>
|
||||||
|
{provider === DiscussionProvider.LEGACY && (
|
||||||
|
<Suspense fallback={(<Spinner />)}>
|
||||||
|
<Route
|
||||||
|
path={[Routes.POSTS.PATH, Routes.TOPICS.CATEGORY]}
|
||||||
|
component={LegacyBreadcrumbMenu}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
)}
|
||||||
|
<div className="d-flex flex-row position-relative">
|
||||||
|
<Suspense fallback={(<Spinner />)}>
|
||||||
|
<DiscussionSidebar displaySidebar={displaySidebar} postActionBarRef={postActionBarRef} />
|
||||||
|
</Suspense>
|
||||||
|
{displayContentArea && (
|
||||||
|
<Suspense fallback={(<Spinner />)}>
|
||||||
|
<DiscussionContent />
|
||||||
|
</Suspense>
|
||||||
|
)}
|
||||||
|
{!displayContentArea && (
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route
|
<Route
|
||||||
path={Routes.TOPICS.PATH}
|
path={Routes.TOPICS.PATH}
|
||||||
@@ -123,11 +130,16 @@ export default function DiscussionsHome() {
|
|||||||
/>
|
/>
|
||||||
{isRedirectToLearners && <Route path={Routes.LEARNERS.PATH} component={EmptyLearners} />}
|
{isRedirectToLearners && <Route path={Routes.LEARNERS.PATH} component={EmptyLearners} />}
|
||||||
</Switch>
|
</Switch>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!enableInContextSidebar && (
|
||||||
|
<DiscussionsProductTour />
|
||||||
)}
|
)}
|
||||||
</div>
|
</main>
|
||||||
{!enableInContextSidebar && <DiscussionsProductTour />}
|
{!enableInContextSidebar && <Footer />}
|
||||||
</main>
|
</DiscussionContext.Provider>
|
||||||
{!enableInContextSidebar && <Footer />}
|
</Suspense>
|
||||||
</DiscussionContext.Provider>
|
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
export default React.memo(DiscussionsHome);
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useCallback, useState } from 'react';
|
||||||
|
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
|
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import { Hyperlink, PageBanner } from '@edx/paragon';
|
import { Hyperlink, PageBanner } from '@edx/paragon';
|
||||||
|
|
||||||
import { selectUserIsStaff, selectUserRoles } from '../data/selectors';
|
import { selectUserIsStaff, selectUserRoles } from '../data/selectors';
|
||||||
import messages from '../messages';
|
import messages from '../messages';
|
||||||
|
|
||||||
function InformationBanner({
|
const InformationBanner = () => {
|
||||||
intl,
|
const intl = useIntl();
|
||||||
}) {
|
|
||||||
const [showBanner, setShowBanner] = useState(true);
|
const [showBanner, setShowBanner] = useState(true);
|
||||||
const userRoles = useSelector(selectUserRoles);
|
const userRoles = useSelector(selectUserRoles);
|
||||||
const isAdmin = useSelector(selectUserIsStaff);
|
const isAdmin = useSelector(selectUserIsStaff);
|
||||||
@@ -20,12 +19,16 @@ function InformationBanner({
|
|||||||
const hideLearnMoreButton = ((userRoles.includes('Student') && userRoles.length === 1) || !userRoles.length) && !isAdmin;
|
const hideLearnMoreButton = ((userRoles.includes('Student') && userRoles.length === 1) || !userRoles.length) && !isAdmin;
|
||||||
const showStaffLink = isAdmin || userRoles.includes('Moderator') || userRoles.includes('Administrator');
|
const showStaffLink = isAdmin || userRoles.includes('Moderator') || userRoles.includes('Administrator');
|
||||||
|
|
||||||
|
const handleDismiss = useCallback(() => {
|
||||||
|
setShowBanner(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageBanner
|
<PageBanner
|
||||||
variant="light"
|
variant="light"
|
||||||
show={showBanner}
|
show={showBanner}
|
||||||
dismissible
|
dismissible
|
||||||
onDismiss={() => setShowBanner(false)}
|
onDismiss={handleDismiss}
|
||||||
>
|
>
|
||||||
<div className="font-weight-500">
|
<div className="font-weight-500">
|
||||||
{intl.formatMessage(messages.bannerMessage)}
|
{intl.formatMessage(messages.bannerMessage)}
|
||||||
@@ -55,10 +58,6 @@ function InformationBanner({
|
|||||||
</div>
|
</div>
|
||||||
</PageBanner>
|
</PageBanner>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
InformationBanner.propTypes = {
|
|
||||||
intl: intlShape.isRequired,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default injectIntl(InformationBanner);
|
export default InformationBanner;
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
import { useIsOnDesktop } from '../data/hooks';
|
import { useIsOnDesktop } from '../data/hooks';
|
||||||
import messages from '../messages';
|
import messages from '../messages';
|
||||||
import EmptyPage from './EmptyPage';
|
import EmptyPage from './EmptyPage';
|
||||||
|
|
||||||
function EmptyLearners({ intl }) {
|
const EmptyLearners = () => {
|
||||||
|
const intl = useIntl();
|
||||||
const isOnDesktop = useIsOnDesktop();
|
const isOnDesktop = useIsOnDesktop();
|
||||||
|
|
||||||
if (!isOnDesktop) {
|
if (!isOnDesktop) {
|
||||||
@@ -16,10 +17,6 @@ function EmptyLearners({ intl }) {
|
|||||||
return (
|
return (
|
||||||
<EmptyPage title={intl.formatMessage(messages.emptyTitle)} />
|
<EmptyPage title={intl.formatMessage(messages.emptyTitle)} />
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
EmptyLearners.propTypes = {
|
|
||||||
intl: intlShape.isRequired,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default injectIntl(EmptyLearners);
|
export default EmptyLearners;
|
||||||
|
|||||||
@@ -7,13 +7,13 @@ import { Button } from '@edx/paragon';
|
|||||||
|
|
||||||
import { ReactComponent as EmptyIcon } from '../../assets/empty.svg';
|
import { ReactComponent as EmptyIcon } from '../../assets/empty.svg';
|
||||||
|
|
||||||
function EmptyPage({
|
const EmptyPage = ({
|
||||||
title,
|
title,
|
||||||
subTitle = null,
|
subTitle = null,
|
||||||
action = null,
|
action = null,
|
||||||
actionText = null,
|
actionText = null,
|
||||||
fullWidth = false,
|
fullWidth = false,
|
||||||
}) {
|
}) => {
|
||||||
const containerClasses = classNames(
|
const containerClasses = classNames(
|
||||||
'min-content-height justify-content-center align-items-center d-flex w-100 flex-column',
|
'min-content-height justify-content-center align-items-center d-flex w-100 flex-column',
|
||||||
{ 'bg-light-400': !fullWidth },
|
{ 'bg-light-400': !fullWidth },
|
||||||
@@ -33,7 +33,7 @@ function EmptyPage({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
EmptyPage.propTypes = {
|
EmptyPage.propTypes = {
|
||||||
title: propTypes.string.isRequired,
|
title: propTypes.string.isRequired,
|
||||||
@@ -50,4 +50,4 @@ EmptyPage.defaultProps = {
|
|||||||
actionText: null,
|
actionText: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default EmptyPage;
|
export default React.memo(EmptyPage);
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import React from 'react';
|
import React, { useCallback } from 'react';
|
||||||
import propTypes from 'prop-types';
|
import propTypes from 'prop-types';
|
||||||
|
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
import { useIsOnDesktop } from '../data/hooks';
|
import { useIsOnDesktop } from '../data/hooks';
|
||||||
import { selectAreThreadsFiltered, selectPostThreadCount } from '../data/selectors';
|
import { selectAreThreadsFiltered, selectPostThreadCount } from '../data/selectors';
|
||||||
@@ -11,16 +11,16 @@ import messages from '../messages';
|
|||||||
import { messages as postMessages, showPostEditor } from '../posts';
|
import { messages as postMessages, showPostEditor } from '../posts';
|
||||||
import EmptyPage from './EmptyPage';
|
import EmptyPage from './EmptyPage';
|
||||||
|
|
||||||
function EmptyPosts({ intl, subTitleMessage }) {
|
const EmptyPosts = ({ subTitleMessage }) => {
|
||||||
|
const intl = useIntl();
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
|
const isOnDesktop = useIsOnDesktop();
|
||||||
const isFiltered = useSelector(selectAreThreadsFiltered);
|
const isFiltered = useSelector(selectAreThreadsFiltered);
|
||||||
const totalThreads = useSelector(selectPostThreadCount);
|
const totalThreads = useSelector(selectPostThreadCount);
|
||||||
const isOnDesktop = useIsOnDesktop();
|
|
||||||
|
|
||||||
function addPost() {
|
const addPost = useCallback(() => (
|
||||||
return dispatch(showPostEditor());
|
dispatch(showPostEditor())
|
||||||
}
|
), []);
|
||||||
|
|
||||||
let title = messages.noPostSelected;
|
let title = messages.noPostSelected;
|
||||||
let subTitle = null;
|
let subTitle = null;
|
||||||
@@ -49,7 +49,7 @@ function EmptyPosts({ intl, subTitleMessage }) {
|
|||||||
fullWidth={fullWidth}
|
fullWidth={fullWidth}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
EmptyPosts.propTypes = {
|
EmptyPosts.propTypes = {
|
||||||
subTitleMessage: propTypes.shape({
|
subTitleMessage: propTypes.shape({
|
||||||
@@ -57,7 +57,6 @@ EmptyPosts.propTypes = {
|
|||||||
defaultMessage: propTypes.string,
|
defaultMessage: propTypes.string,
|
||||||
description: propTypes.string,
|
description: propTypes.string,
|
||||||
}).isRequired,
|
}).isRequired,
|
||||||
intl: intlShape.isRequired,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default injectIntl(EmptyPosts);
|
export default React.memo(EmptyPosts);
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import React from 'react';
|
import React, { useCallback } from 'react';
|
||||||
|
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
import { useRouteMatch } from 'react-router';
|
import { useRouteMatch } from 'react-router';
|
||||||
|
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
import { ALL_ROUTES } from '../../data/constants';
|
import { ALL_ROUTES } from '../../data/constants';
|
||||||
import { useIsOnDesktop, useTotalTopicThreadCount } from '../data/hooks';
|
import { useIsOnDesktop, useTotalTopicThreadCount } from '../data/hooks';
|
||||||
@@ -12,18 +12,17 @@ import messages from '../messages';
|
|||||||
import { messages as postMessages, showPostEditor } from '../posts';
|
import { messages as postMessages, showPostEditor } from '../posts';
|
||||||
import EmptyPage from './EmptyPage';
|
import EmptyPage from './EmptyPage';
|
||||||
|
|
||||||
function EmptyTopics({ intl }) {
|
const EmptyTopics = () => {
|
||||||
|
const intl = useIntl();
|
||||||
const match = useRouteMatch(ALL_ROUTES);
|
const match = useRouteMatch(ALL_ROUTES);
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
|
const isOnDesktop = useIsOnDesktop();
|
||||||
const hasGlobalThreads = useTotalTopicThreadCount() > 0;
|
const hasGlobalThreads = useTotalTopicThreadCount() > 0;
|
||||||
const topicThreadCount = useSelector(selectTopicThreadCount(match.params.topicId));
|
const topicThreadCount = useSelector(selectTopicThreadCount(match.params.topicId));
|
||||||
|
|
||||||
function addPost() {
|
const addPost = useCallback(() => (
|
||||||
return dispatch(showPostEditor());
|
dispatch(showPostEditor())
|
||||||
}
|
), []);
|
||||||
|
|
||||||
const isOnDesktop = useIsOnDesktop();
|
|
||||||
|
|
||||||
let title = messages.emptyTitle;
|
let title = messages.emptyTitle;
|
||||||
let fullWidth = false;
|
let fullWidth = false;
|
||||||
@@ -62,10 +61,6 @@ function EmptyTopics({ intl }) {
|
|||||||
fullWidth={fullWidth}
|
fullWidth={fullWidth}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
EmptyTopics.propTypes = {
|
|
||||||
intl: intlShape.isRequired,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default injectIntl(EmptyTopics);
|
export default EmptyTopics;
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
import React, { useContext, useEffect } from 'react';
|
import React, {
|
||||||
|
useCallback, useContext, useEffect, useMemo,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import { Spinner } from '@edx/paragon';
|
import { Spinner } from '@edx/paragon';
|
||||||
|
|
||||||
import { RequestStatus, Routes } from '../../data/constants';
|
import { RequestStatus, Routes } from '../../data/constants';
|
||||||
import { DiscussionContext } from '../common/context';
|
import { DiscussionContext } from '../common/context';
|
||||||
import { selectDiscussionProvider } from '../data/selectors';
|
import { selectDiscussionProvider } from '../data/selectors';
|
||||||
import { selectTopicThreads } from '../posts/data/selectors';
|
import { selectTopicThreadsIds } from '../posts/data/selectors';
|
||||||
import PostsList from '../posts/PostsList';
|
import PostsList from '../posts/PostsList';
|
||||||
import { discussionsPath, handleKeyDown } from '../utils';
|
import { discussionsPath, handleKeyDown } from '../utils';
|
||||||
import {
|
import {
|
||||||
@@ -21,19 +23,34 @@ import { BackButton, NoResults } from './components';
|
|||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
import { Topic } from './topic';
|
import { Topic } from './topic';
|
||||||
|
|
||||||
function TopicPostsView({ intl }) {
|
const TopicPostsView = () => {
|
||||||
|
const intl = useIntl();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const { courseId, topicId, category } = useContext(DiscussionContext);
|
const { courseId, topicId, category } = useContext(DiscussionContext);
|
||||||
const provider = useSelector(selectDiscussionProvider);
|
const provider = useSelector(selectDiscussionProvider);
|
||||||
const topicsStatus = useSelector(selectLoadingStatus);
|
const topicsStatus = useSelector(selectLoadingStatus);
|
||||||
const topicsInProgress = topicsStatus === RequestStatus.IN_PROGRESS;
|
const postsIds = useSelector(selectTopicThreadsIds([topicId]));
|
||||||
const posts = useSelector(selectTopicThreads([topicId]));
|
|
||||||
const selectedSubsectionUnits = useSelector(selectSubsectionUnits(category));
|
const selectedSubsectionUnits = useSelector(selectSubsectionUnits(category));
|
||||||
const selectedSubsection = useSelector(selectSubsection(category));
|
const selectedSubsection = useSelector(selectSubsection(category));
|
||||||
const selectedUnit = useSelector(selectUnits)?.find(unit => unit.id === topicId);
|
const units = useSelector(selectUnits);
|
||||||
const selectedNonCoursewareTopic = useSelector(selectNonCoursewareTopics)?.find(topic => topic.id === topicId);
|
const nonCoursewareTopics = useSelector(selectNonCoursewareTopics);
|
||||||
const selectedArchivedTopic = useSelector(selectArchivedTopic(topicId));
|
const selectedArchivedTopic = useSelector(selectArchivedTopic(topicId));
|
||||||
|
const topicsInProgress = topicsStatus === RequestStatus.IN_PROGRESS;
|
||||||
|
|
||||||
|
const selectedUnit = useMemo(() => (
|
||||||
|
units?.find(unit => unit.id === topicId)
|
||||||
|
), [units, topicId]);
|
||||||
|
|
||||||
|
const selectedNonCoursewareTopic = useMemo(() => (
|
||||||
|
nonCoursewareTopics?.find(topic => topic.id === topicId)
|
||||||
|
), [nonCoursewareTopics, topicId]);
|
||||||
|
|
||||||
|
const backButtonPath = useCallback(() => {
|
||||||
|
const path = selectedUnit ? Routes.TOPICS.CATEGORY : Routes.TOPICS.ALL;
|
||||||
|
const params = selectedUnit ? { courseId, category: selectedUnit?.parentId } : { courseId };
|
||||||
|
return discussionsPath(path, params)(location);
|
||||||
|
}, [selectedUnit]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (provider && topicsStatus === RequestStatus.IDLE) {
|
if (provider && topicsStatus === RequestStatus.IDLE) {
|
||||||
@@ -41,12 +58,6 @@ function TopicPostsView({ intl }) {
|
|||||||
}
|
}
|
||||||
}, [provider]);
|
}, [provider]);
|
||||||
|
|
||||||
const backButtonPath = () => {
|
|
||||||
const path = selectedUnit ? Routes.TOPICS.CATEGORY : Routes.TOPICS.ALL;
|
|
||||||
const params = selectedUnit ? { courseId, category: selectedUnit?.parentId } : { courseId };
|
|
||||||
return discussionsPath(path, params)(location);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="discussion-posts d-flex flex-column h-100">
|
<div className="discussion-posts d-flex flex-column h-100">
|
||||||
{topicId ? (
|
{topicId ? (
|
||||||
@@ -67,8 +78,8 @@ function TopicPostsView({ intl }) {
|
|||||||
<div className="list-group list-group-flush flex-fill" role="list" onKeyDown={e => handleKeyDown(e)}>
|
<div className="list-group list-group-flush flex-fill" role="list" onKeyDown={e => handleKeyDown(e)}>
|
||||||
{topicId ? (
|
{topicId ? (
|
||||||
<PostsList
|
<PostsList
|
||||||
posts={posts}
|
postsIds={postsIds}
|
||||||
topics={[topicId]}
|
topicsIds={[topicId]}
|
||||||
parentIsLoading={topicsInProgress}
|
parentIsLoading={topicsInProgress}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -90,10 +101,6 @@ function TopicPostsView({ intl }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
TopicPostsView.propTypes = {
|
|
||||||
intl: intlShape.isRequired,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default injectIntl(TopicPostsView);
|
export default React.memo(TopicPostsView);
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import React, { useContext, useEffect } from 'react';
|
import React, {
|
||||||
|
useCallback, useContext, useEffect, useMemo,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import isEmpty from 'lodash/isEmpty';
|
import isEmpty from 'lodash/isEmpty';
|
||||||
@@ -21,30 +23,38 @@ import { setFilter } from './data/slices';
|
|||||||
import { fetchCourseTopicsV3 } from './data/thunks';
|
import { fetchCourseTopicsV3 } from './data/thunks';
|
||||||
import { ArchivedBaseGroup, SectionBaseGroup, Topic } from './topic';
|
import { ArchivedBaseGroup, SectionBaseGroup, Topic } from './topic';
|
||||||
|
|
||||||
function TopicsList() {
|
const TopicsList = () => {
|
||||||
const loadingStatus = useSelector(selectLoadingStatus);
|
const loadingStatus = useSelector(selectLoadingStatus);
|
||||||
const coursewareTopics = useSelector(selectCoursewareTopics);
|
const coursewareTopics = useSelector(selectCoursewareTopics);
|
||||||
const nonCoursewareTopics = useSelector(selectNonCoursewareTopics);
|
const nonCoursewareTopics = useSelector(selectNonCoursewareTopics);
|
||||||
const archivedTopics = useSelector(selectArchivedTopics);
|
const archivedTopics = useSelector(selectArchivedTopics);
|
||||||
|
|
||||||
|
const renderNonCoursewareTopics = useMemo(() => (
|
||||||
|
nonCoursewareTopics?.map((topic, index) => (
|
||||||
|
<Topic
|
||||||
|
key={topic.id}
|
||||||
|
topic={topic}
|
||||||
|
showDivider={(nonCoursewareTopics.length - 1) !== index}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
), [nonCoursewareTopics]);
|
||||||
|
|
||||||
|
const renderCoursewareTopics = useMemo(() => (
|
||||||
|
coursewareTopics?.map((topic, index) => (
|
||||||
|
<SectionBaseGroup
|
||||||
|
key={topic.id}
|
||||||
|
section={topic?.children}
|
||||||
|
sectionId={topic.id}
|
||||||
|
sectionTitle={topic.displayName}
|
||||||
|
showDivider={(coursewareTopics.length - 1) !== index}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
), [coursewareTopics]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{nonCoursewareTopics?.map((topic, index) => (
|
{renderNonCoursewareTopics}
|
||||||
<Topic
|
{renderCoursewareTopics}
|
||||||
key={topic.id}
|
|
||||||
topic={topic}
|
|
||||||
showDivider={(nonCoursewareTopics.length - 1) !== index}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{coursewareTopics?.map((topic, index) => (
|
|
||||||
<SectionBaseGroup
|
|
||||||
key={topic.id}
|
|
||||||
section={topic?.children}
|
|
||||||
sectionId={topic.id}
|
|
||||||
sectionTitle={topic.displayName}
|
|
||||||
showDivider={(coursewareTopics.length - 1) !== index}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{!isEmpty(archivedTopics) && (
|
{!isEmpty(archivedTopics) && (
|
||||||
<ArchivedBaseGroup
|
<ArchivedBaseGroup
|
||||||
archivedTopics={archivedTopics}
|
archivedTopics={archivedTopics}
|
||||||
@@ -58,9 +68,9 @@ function TopicsList() {
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
function TopicsView() {
|
const TopicsView = () => {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const { courseId } = useContext(DiscussionContext);
|
const { courseId } = useContext(DiscussionContext);
|
||||||
const provider = useSelector(selectDiscussionProvider);
|
const provider = useSelector(selectDiscussionProvider);
|
||||||
@@ -83,6 +93,10 @@ function TopicsView() {
|
|||||||
}
|
}
|
||||||
}, [isPostsFiltered]);
|
}, [isPostsFiltered]);
|
||||||
|
|
||||||
|
const handleOnClear = useCallback(() => {
|
||||||
|
dispatch(setFilter(''));
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="d-flex flex-column h-100" data-testid="inContext-topics-view">
|
<div className="d-flex flex-column h-100" data-testid="inContext-topics-view">
|
||||||
{topicFilter && (
|
{topicFilter && (
|
||||||
@@ -91,7 +105,7 @@ function TopicsView() {
|
|||||||
text={topicFilter}
|
text={topicFilter}
|
||||||
count={filteredTopics.length}
|
count={filteredTopics.length}
|
||||||
loadingStatus={loadingStatus}
|
loadingStatus={loadingStatus}
|
||||||
onClear={() => dispatch(setFilter(''))}
|
onClear={handleOnClear}
|
||||||
/>
|
/>
|
||||||
{filteredTopics.length === 0 && loadingStatus === RequestStatus.SUCCESSFUL && <NoResults />}
|
{filteredTopics.length === 0 && loadingStatus === RequestStatus.SUCCESSFUL && <NoResults />}
|
||||||
</>
|
</>
|
||||||
@@ -116,6 +130,6 @@ function TopicsView() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default TopicsView;
|
export default TopicsView;
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import React, { useContext } from 'react';
|
import React, { useCallback, useContext } from 'react';
|
||||||
|
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
import { useRouteMatch } from 'react-router';
|
import { useRouteMatch } from 'react-router';
|
||||||
|
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
import { ALL_ROUTES } from '../../../data/constants';
|
import { ALL_ROUTES } from '../../../data/constants';
|
||||||
import { DiscussionContext } from '../../common/context';
|
import { DiscussionContext } from '../../common/context';
|
||||||
@@ -14,20 +14,20 @@ import messages from '../../messages';
|
|||||||
import { messages as postMessages, showPostEditor } from '../../posts';
|
import { messages as postMessages, showPostEditor } from '../../posts';
|
||||||
import { selectCourseWareThreadsCount, selectTotalTopicsThreadsCount } from '../data/selectors';
|
import { selectCourseWareThreadsCount, selectTotalTopicsThreadsCount } from '../data/selectors';
|
||||||
|
|
||||||
function EmptyTopics({ intl }) {
|
const EmptyTopics = () => {
|
||||||
|
const intl = useIntl();
|
||||||
const match = useRouteMatch(ALL_ROUTES);
|
const match = useRouteMatch(ALL_ROUTES);
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
|
const isOnDesktop = useIsOnDesktop();
|
||||||
const { enableInContextSidebar } = useContext(DiscussionContext);
|
const { enableInContextSidebar } = useContext(DiscussionContext);
|
||||||
const courseWareThreadsCount = useSelector(selectCourseWareThreadsCount(match.params.category));
|
const courseWareThreadsCount = useSelector(selectCourseWareThreadsCount(match.params.category));
|
||||||
const topicThreadsCount = useSelector(selectPostThreadCount);
|
const topicThreadsCount = useSelector(selectPostThreadCount);
|
||||||
// hasGlobalThreads is used to determine if there are any post available in courseware and non-courseware topics
|
// hasGlobalThreads is used to determine if there are any post available in courseware and non-courseware topics
|
||||||
const hasGlobalThreads = useSelector(selectTotalTopicsThreadsCount) > 0;
|
const hasGlobalThreads = useSelector(selectTotalTopicsThreadsCount) > 0;
|
||||||
|
|
||||||
function addPost() {
|
const addPost = useCallback(() => (
|
||||||
return dispatch(showPostEditor());
|
dispatch(showPostEditor())
|
||||||
}
|
), []);
|
||||||
|
|
||||||
const isOnDesktop = useIsOnDesktop();
|
|
||||||
|
|
||||||
let title = messages.emptyTitle;
|
let title = messages.emptyTitle;
|
||||||
let fullWidth = false;
|
let fullWidth = false;
|
||||||
@@ -74,10 +74,6 @@ function EmptyTopics({ intl }) {
|
|||||||
fullWidth={fullWidth}
|
fullWidth={fullWidth}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
EmptyTopics.propTypes = {
|
|
||||||
intl: intlShape.isRequired,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default injectIntl(EmptyTopics);
|
export default EmptyTopics;
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
|
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
import { selectTopics } from '../data/selectors';
|
import { selectTopics } from '../data/selectors';
|
||||||
import messages from '../messages';
|
import messages from '../messages';
|
||||||
|
|
||||||
function NoResults({ intl }) {
|
const NoResults = () => {
|
||||||
|
const intl = useIntl();
|
||||||
const topics = useSelector(selectTopics);
|
const topics = useSelector(selectTopics);
|
||||||
|
|
||||||
const title = messages.nothingHere;
|
const title = messages.nothingHere;
|
||||||
@@ -20,10 +23,6 @@ function NoResults({ intl }) {
|
|||||||
{ helpMessage && <small className="font-weight-normal text-gray-700">{intl.formatMessage(helpMessage)}</small>}
|
{ helpMessage && <small className="font-weight-normal text-gray-700">{intl.formatMessage(helpMessage)}</small>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
NoResults.propTypes = {
|
|
||||||
intl: intlShape.isRequired,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default injectIntl(NoResults);
|
export default NoResults;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import React, { useContext, useEffect } from 'react';
|
import React, { useCallback, useContext, useEffect } from 'react';
|
||||||
|
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import { Icon, SearchField } from '@edx/paragon';
|
import { Icon, SearchField } from '@edx/paragon';
|
||||||
import { Search as SearchIcon } from '@edx/paragon/icons';
|
import { Search as SearchIcon } from '@edx/paragon/icons';
|
||||||
|
|
||||||
@@ -10,56 +10,51 @@ import { DiscussionContext } from '../../common/context';
|
|||||||
import postsMessages from '../../posts/post-actions-bar/messages';
|
import postsMessages from '../../posts/post-actions-bar/messages';
|
||||||
import { setFilter as setTopicFilter } from '../data/slices';
|
import { setFilter as setTopicFilter } from '../data/slices';
|
||||||
|
|
||||||
function TopicSearchBar({ intl }) {
|
const TopicSearchBar = () => {
|
||||||
|
const intl = useIntl();
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const { page } = useContext(DiscussionContext);
|
const { page } = useContext(DiscussionContext);
|
||||||
const topicSearch = useSelector(({ inContextTopics }) => inContextTopics.filter);
|
const topicSearch = useSelector(({ inContextTopics }) => inContextTopics.filter);
|
||||||
let searchValue = '';
|
let searchValue = '';
|
||||||
|
|
||||||
const onClear = () => {
|
const onClear = useCallback(() => {
|
||||||
dispatch(setTopicFilter(''));
|
dispatch(setTopicFilter(''));
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const onChange = (query) => {
|
const onChange = useCallback((query) => {
|
||||||
searchValue = query;
|
searchValue = query;
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const onSubmit = (query) => {
|
const onSubmit = useCallback((query) => {
|
||||||
if (query === '') {
|
if (query === '') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
dispatch(setTopicFilter(query));
|
dispatch(setTopicFilter(query));
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
useEffect(() => onClear(), [page]);
|
useEffect(() => onClear(), [page]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<SearchField.Advanced
|
||||||
<SearchField.Advanced
|
onClear={onClear}
|
||||||
onClear={onClear}
|
onChange={onChange}
|
||||||
onChange={onChange}
|
onSubmit={onSubmit}
|
||||||
onSubmit={onSubmit}
|
value={topicSearch}
|
||||||
value={topicSearch}
|
>
|
||||||
>
|
<SearchField.Label />
|
||||||
<SearchField.Label />
|
<SearchField.Input
|
||||||
<SearchField.Input
|
style={{ paddingRight: '1rem' }}
|
||||||
style={{ paddingRight: '1rem' }}
|
placeholder={intl.formatMessage(postsMessages.search, { page: 'topics' })}
|
||||||
placeholder={intl.formatMessage(postsMessages.search, { page: 'topics' })}
|
/>
|
||||||
|
<span className="mt-auto mb-auto mr-2.5 pointer-cursor-hover">
|
||||||
|
<Icon
|
||||||
|
src={SearchIcon}
|
||||||
|
onClick={() => onSubmit(searchValue)}
|
||||||
|
data-testid="search-icon"
|
||||||
/>
|
/>
|
||||||
<span className="mt-auto mb-auto mr-2.5 pointer-cursor-hover">
|
</span>
|
||||||
<Icon
|
</SearchField.Advanced>
|
||||||
src={SearchIcon}
|
|
||||||
onClick={() => onSubmit(searchValue)}
|
|
||||||
data-testid="search-icon"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</SearchField.Advanced>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
TopicSearchBar.propTypes = {
|
|
||||||
intl: intlShape.isRequired,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default injectIntl(TopicSearchBar);
|
export default TopicSearchBar;
|
||||||
|
|||||||
@@ -1,16 +1,27 @@
|
|||||||
import React from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
import messages from '../messages';
|
import messages from '../messages';
|
||||||
import Topic, { topicShape } from './Topic';
|
import Topic, { topicShape } from './Topic';
|
||||||
|
|
||||||
function ArchivedBaseGroup({
|
const ArchivedBaseGroup = ({
|
||||||
archivedTopics,
|
archivedTopics,
|
||||||
showDivider,
|
showDivider,
|
||||||
intl,
|
}) => {
|
||||||
}) {
|
const intl = useIntl();
|
||||||
|
|
||||||
|
const renderArchivedTopics = useMemo(() => (
|
||||||
|
archivedTopics?.map((topic, index) => (
|
||||||
|
<Topic
|
||||||
|
key={topic.id}
|
||||||
|
topic={topic}
|
||||||
|
showDivider={(archivedTopics.length - 1) !== index}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
), [archivedTopics]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{showDivider && (
|
{showDivider && (
|
||||||
@@ -24,25 +35,18 @@ function ArchivedBaseGroup({
|
|||||||
data-testid="archived-group"
|
data-testid="archived-group"
|
||||||
>
|
>
|
||||||
<div className="pt-3 px-4 font-weight-bold">{intl.formatMessage(messages.archivedTopics)}</div>
|
<div className="pt-3 px-4 font-weight-bold">{intl.formatMessage(messages.archivedTopics)}</div>
|
||||||
{archivedTopics?.map((topic, index) => (
|
{renderArchivedTopics}
|
||||||
<Topic
|
|
||||||
key={topic.id}
|
|
||||||
topic={topic}
|
|
||||||
showDivider={(archivedTopics.length - 1) !== index}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
ArchivedBaseGroup.propTypes = {
|
ArchivedBaseGroup.propTypes = {
|
||||||
archivedTopics: PropTypes.arrayOf(topicShape).isRequired,
|
archivedTopics: PropTypes.arrayOf(topicShape).isRequired,
|
||||||
showDivider: PropTypes.bool,
|
showDivider: PropTypes.bool,
|
||||||
intl: intlShape.isRequired,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
ArchivedBaseGroup.defaultProps = {
|
ArchivedBaseGroup.defaultProps = {
|
||||||
showDivider: false,
|
showDivider: false,
|
||||||
};
|
};
|
||||||
export default injectIntl(ArchivedBaseGroup);
|
export default React.memo(ArchivedBaseGroup);
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import React from 'react';
|
import React, { useCallback, useMemo } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { useParams } from 'react-router';
|
import { useParams } from 'react-router';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
import TopicStats from '../../../components/TopicStats';
|
import TopicStats from '../../../components/TopicStats';
|
||||||
import { Routes } from '../../../data/constants';
|
import { Routes } from '../../../data/constants';
|
||||||
@@ -13,19 +13,52 @@ import { discussionsPath } from '../../utils';
|
|||||||
import messages from '../messages';
|
import messages from '../messages';
|
||||||
import { topicShape } from './Topic';
|
import { topicShape } from './Topic';
|
||||||
|
|
||||||
function SectionBaseGroup({
|
const SectionBaseGroup = ({
|
||||||
section,
|
section,
|
||||||
sectionTitle,
|
sectionTitle,
|
||||||
sectionId,
|
sectionId,
|
||||||
showDivider,
|
showDivider,
|
||||||
intl,
|
}) => {
|
||||||
}) {
|
const intl = useIntl();
|
||||||
const { courseId } = useParams();
|
const { courseId } = useParams();
|
||||||
const isSelected = (id) => window.location.pathname.includes(id);
|
|
||||||
const sectionUrl = (id) => discussionsPath(Routes.TOPICS.CATEGORY, {
|
const isSelected = useCallback((id) => (
|
||||||
|
window.location.pathname.includes(id)
|
||||||
|
), []);
|
||||||
|
|
||||||
|
const sectionUrl = useCallback((id) => discussionsPath(Routes.TOPICS.CATEGORY, {
|
||||||
courseId,
|
courseId,
|
||||||
category: id,
|
category: id,
|
||||||
});
|
}), [courseId]);
|
||||||
|
|
||||||
|
const renderSection = useMemo(() => (
|
||||||
|
section?.map((subsection, index) => (
|
||||||
|
<Link
|
||||||
|
className={classNames('subsection p-0 text-decoration-none text-primary-500', {
|
||||||
|
'border-bottom border-light-400': (section.length - 1 !== index),
|
||||||
|
})}
|
||||||
|
key={subsection.id}
|
||||||
|
role="option"
|
||||||
|
data-subsection-id={subsection.id}
|
||||||
|
data-testid="subsection-group"
|
||||||
|
to={sectionUrl(subsection.id)}
|
||||||
|
onClick={() => isSelected(subsection.id)}
|
||||||
|
aria-current={isSelected(section.id) ? 'page' : undefined}
|
||||||
|
tabIndex={(isSelected(subsection.id) || index === 0) ? 0 : -1}
|
||||||
|
>
|
||||||
|
<div className="d-flex flex-row pt-2.5 pb-2 px-4">
|
||||||
|
<div className="d-flex flex-column flex-fill" style={{ minWidth: 0 }}>
|
||||||
|
<div className="d-flex flex-column justify-content-start mw-100 flex-fill">
|
||||||
|
<div className="topic-name text-truncate">
|
||||||
|
{subsection?.displayName || intl.formatMessage(messages.unnamedSubsection)}
|
||||||
|
</div>
|
||||||
|
<TopicStats threadCounts={subsection?.threadCounts} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))
|
||||||
|
), [section, sectionUrl, isSelected]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -36,32 +69,7 @@ function SectionBaseGroup({
|
|||||||
<div className="pt-3 px-4 font-weight-bold">
|
<div className="pt-3 px-4 font-weight-bold">
|
||||||
{sectionTitle || intl.formatMessage(messages.unnamedSection)}
|
{sectionTitle || intl.formatMessage(messages.unnamedSection)}
|
||||||
</div>
|
</div>
|
||||||
{section.map((subsection, index) => (
|
{renderSection}
|
||||||
<Link
|
|
||||||
className={classNames('subsection p-0 text-decoration-none text-primary-500', {
|
|
||||||
'border-bottom border-light-400': (section.length - 1 !== index),
|
|
||||||
})}
|
|
||||||
key={subsection.id}
|
|
||||||
role="option"
|
|
||||||
data-subsection-id={subsection.id}
|
|
||||||
data-testid="subsection-group"
|
|
||||||
to={sectionUrl(subsection.id)}
|
|
||||||
onClick={() => isSelected(subsection.id)}
|
|
||||||
aria-current={isSelected(section.id) ? 'page' : undefined}
|
|
||||||
tabIndex={(isSelected(subsection.id) || index === 0) ? 0 : -1}
|
|
||||||
>
|
|
||||||
<div className="d-flex flex-row pt-2.5 pb-2 px-4">
|
|
||||||
<div className="d-flex flex-column flex-fill" style={{ minWidth: 0 }}>
|
|
||||||
<div className="d-flex flex-column justify-content-start mw-100 flex-fill">
|
|
||||||
<div className="topic-name text-truncate">
|
|
||||||
{subsection?.displayName || intl.formatMessage(messages.unnamedSubsection)}
|
|
||||||
</div>
|
|
||||||
<TopicStats threadCounts={subsection?.threadCounts} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
{showDivider && (
|
{showDivider && (
|
||||||
<>
|
<>
|
||||||
<div className="divider border-top border-light-500" />
|
<div className="divider border-top border-light-500" />
|
||||||
@@ -70,7 +78,7 @@ function SectionBaseGroup({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
SectionBaseGroup.propTypes = {
|
SectionBaseGroup.propTypes = {
|
||||||
section: PropTypes.arrayOf(PropTypes.shape({
|
section: PropTypes.arrayOf(PropTypes.shape({
|
||||||
@@ -86,7 +94,6 @@ SectionBaseGroup.propTypes = {
|
|||||||
sectionTitle: PropTypes.string.isRequired,
|
sectionTitle: PropTypes.string.isRequired,
|
||||||
sectionId: PropTypes.string.isRequired,
|
sectionId: PropTypes.string.isRequired,
|
||||||
showDivider: PropTypes.bool.isRequired,
|
showDivider: PropTypes.bool.isRequired,
|
||||||
intl: intlShape.isRequired,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default injectIntl(SectionBaseGroup);
|
export default React.memo(SectionBaseGroup);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { useSelector } from 'react-redux';
|
|||||||
import { useParams } from 'react-router';
|
import { useParams } from 'react-router';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import { Icon, OverlayTrigger, Tooltip } from '@edx/paragon';
|
import { Icon, OverlayTrigger, Tooltip } from '@edx/paragon';
|
||||||
import { HelpOutline, PostOutline, Report } from '@edx/paragon/icons';
|
import { HelpOutline, PostOutline, Report } from '@edx/paragon/icons';
|
||||||
|
|
||||||
@@ -17,12 +17,12 @@ import { selectUserHasModerationPrivileges, selectUserIsGroupTa } from '../../da
|
|||||||
import { discussionsPath } from '../../utils';
|
import { discussionsPath } from '../../utils';
|
||||||
import messages from '../messages';
|
import messages from '../messages';
|
||||||
|
|
||||||
function Topic({
|
const Topic = ({
|
||||||
topic,
|
topic,
|
||||||
showDivider,
|
showDivider,
|
||||||
index,
|
index,
|
||||||
intl,
|
}) => {
|
||||||
}) {
|
const intl = useIntl();
|
||||||
const { courseId } = useParams();
|
const { courseId } = useParams();
|
||||||
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
|
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
|
||||||
const userIsGroupTa = useSelector(selectUserIsGroupTa);
|
const userIsGroupTa = useSelector(selectUserIsGroupTa);
|
||||||
@@ -70,7 +70,7 @@ function Topic({
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export const topicShape = PropTypes.shape({
|
export const topicShape = PropTypes.shape({
|
||||||
id: PropTypes.string,
|
id: PropTypes.string,
|
||||||
@@ -85,7 +85,6 @@ export const topicShape = PropTypes.shape({
|
|||||||
});
|
});
|
||||||
|
|
||||||
Topic.propTypes = {
|
Topic.propTypes = {
|
||||||
intl: intlShape.isRequired,
|
|
||||||
topic: topicShape,
|
topic: topicShape,
|
||||||
showDivider: PropTypes.bool,
|
showDivider: PropTypes.bool,
|
||||||
index: PropTypes.number,
|
index: PropTypes.number,
|
||||||
@@ -99,4 +98,4 @@ Topic.defaultProps = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default injectIntl(Topic);
|
export default React.memo(Topic);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import capitalize from 'lodash/capitalize';
|
|||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
import { useHistory, useLocation } from 'react-router-dom';
|
import { useHistory, useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import {
|
import {
|
||||||
Button, Icon, IconButton, Spinner,
|
Button, Icon, IconButton, Spinner,
|
||||||
} from '@edx/paragon';
|
} from '@edx/paragon';
|
||||||
@@ -18,33 +18,36 @@ import {
|
|||||||
} from '../../data/constants';
|
} from '../../data/constants';
|
||||||
import { DiscussionContext } from '../common/context';
|
import { DiscussionContext } from '../common/context';
|
||||||
import { selectUserHasModerationPrivileges, selectUserIsStaff } from '../data/selectors';
|
import { selectUserHasModerationPrivileges, selectUserIsStaff } from '../data/selectors';
|
||||||
|
import { usePostList } from '../posts/data/hooks';
|
||||||
import {
|
import {
|
||||||
selectAllThreads,
|
selectAllThreadsIds,
|
||||||
selectThreadNextPage,
|
selectThreadNextPage,
|
||||||
threadsLoadingStatus,
|
threadsLoadingStatus,
|
||||||
} from '../posts/data/selectors';
|
} from '../posts/data/selectors';
|
||||||
import { clearPostsPages } from '../posts/data/slices';
|
import { clearPostsPages } from '../posts/data/slices';
|
||||||
import NoResults from '../posts/NoResults';
|
import NoResults from '../posts/NoResults';
|
||||||
import { PostLink } from '../posts/post';
|
import { PostLink } from '../posts/post';
|
||||||
import { discussionsPath, filterPosts } from '../utils';
|
import { discussionsPath } from '../utils';
|
||||||
import { fetchUserPosts } from './data/thunks';
|
import { fetchUserPosts } from './data/thunks';
|
||||||
import LearnerPostFilterBar from './learner-post-filter-bar/LearnerPostFilterBar';
|
import LearnerPostFilterBar from './learner-post-filter-bar/LearnerPostFilterBar';
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
|
|
||||||
function LearnerPostsView({ intl }) {
|
const LearnerPostsView = () => {
|
||||||
|
const intl = useIntl();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
const posts = useSelector(selectAllThreads);
|
const postsIds = useSelector(selectAllThreadsIds);
|
||||||
const loadingStatus = useSelector(threadsLoadingStatus());
|
const loadingStatus = useSelector(threadsLoadingStatus());
|
||||||
const postFilter = useSelector(state => state.learners.postFilter);
|
const postFilter = useSelector(state => state.learners.postFilter);
|
||||||
const { courseId, learnerUsername: username } = useContext(DiscussionContext);
|
const { courseId, learnerUsername: username } = useContext(DiscussionContext);
|
||||||
const nextPage = useSelector(selectThreadNextPage());
|
const nextPage = useSelector(selectThreadNextPage());
|
||||||
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
|
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
|
||||||
const userIsStaff = useSelector(selectUserIsStaff);
|
const userIsStaff = useSelector(selectUserIsStaff);
|
||||||
|
const sortedPostsIds = usePostList(postsIds);
|
||||||
|
|
||||||
const loadMorePosts = (pageNum = undefined) => {
|
const loadMorePosts = useCallback((pageNum = undefined) => {
|
||||||
const params = {
|
const params = {
|
||||||
author: username,
|
author: username,
|
||||||
page: pageNum,
|
page: pageNum,
|
||||||
@@ -54,29 +57,24 @@ function LearnerPostsView({ intl }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
dispatch(fetchUserPosts(courseId, params));
|
dispatch(fetchUserPosts(courseId, params));
|
||||||
};
|
}, [courseId, postFilter, username, userHasModerationPrivileges, userIsStaff]);
|
||||||
|
|
||||||
|
const postInstances = useMemo(() => (
|
||||||
|
sortedPostsIds?.map((postId, idx) => (
|
||||||
|
<PostLink
|
||||||
|
postId={postId}
|
||||||
|
idx={idx}
|
||||||
|
key={postId}
|
||||||
|
showDivider={(sortedPostsIds.length - 1) !== idx}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
), [sortedPostsIds]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch(clearPostsPages());
|
dispatch(clearPostsPages());
|
||||||
loadMorePosts();
|
loadMorePosts();
|
||||||
}, [courseId, postFilter, username]);
|
}, [courseId, postFilter, username]);
|
||||||
|
|
||||||
const checkIsSelected = (id) => window.location.pathname.includes(id);
|
|
||||||
const pinnedPosts = useMemo(() => filterPosts(posts, 'pinned'), [posts]);
|
|
||||||
const unpinnedPosts = useMemo(() => filterPosts(posts, 'unpinned'), [posts]);
|
|
||||||
|
|
||||||
const postInstances = useCallback((sortedPosts) => (
|
|
||||||
sortedPosts.map((post, idx) => (
|
|
||||||
<PostLink
|
|
||||||
post={post}
|
|
||||||
key={post.id}
|
|
||||||
isSelected={checkIsSelected}
|
|
||||||
idx={idx}
|
|
||||||
showDivider={(sortedPosts.length - 1) !== idx}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
), []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="discussion-posts d-flex flex-column">
|
<div className="discussion-posts d-flex flex-column">
|
||||||
<div className="d-flex align-items-center justify-content-between px-2.5">
|
<div className="d-flex align-items-center justify-content-between px-2.5">
|
||||||
@@ -97,9 +95,8 @@ function LearnerPostsView({ intl }) {
|
|||||||
<LearnerPostFilterBar />
|
<LearnerPostFilterBar />
|
||||||
<div className="border-bottom border-light-400" />
|
<div className="border-bottom border-light-400" />
|
||||||
<div className="list-group list-group-flush">
|
<div className="list-group list-group-flush">
|
||||||
{postInstances(pinnedPosts)}
|
{postInstances}
|
||||||
{postInstances(unpinnedPosts)}
|
{loadingStatus !== RequestStatus.IN_PROGRESS && sortedPostsIds?.length === 0 && <NoResults />}
|
||||||
{loadingStatus !== RequestStatus.IN_PROGRESS && posts?.length === 0 && <NoResults />}
|
|
||||||
{loadingStatus === RequestStatus.IN_PROGRESS ? (
|
{loadingStatus === RequestStatus.IN_PROGRESS ? (
|
||||||
<div className="d-flex justify-content-center p-4">
|
<div className="d-flex justify-content-center p-4">
|
||||||
<Spinner animation="border" variant="primary" size="lg" />
|
<Spinner animation="border" variant="primary" size="lg" />
|
||||||
@@ -114,10 +111,6 @@ function LearnerPostsView({ intl }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
LearnerPostsView.propTypes = {
|
|
||||||
intl: intlShape.isRequired,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default injectIntl(LearnerPostsView);
|
export default LearnerPostsView;
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import React, { useEffect } from 'react';
|
import React, { useCallback, useEffect, useMemo } from 'react';
|
||||||
|
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
import {
|
import {
|
||||||
Redirect, useLocation, useParams,
|
Redirect, useLocation, useParams,
|
||||||
} from 'react-router';
|
} from 'react-router';
|
||||||
|
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import { Button, Spinner } from '@edx/paragon';
|
import { Button, Spinner } from '@edx/paragon';
|
||||||
|
|
||||||
import SearchInfo from '../../components/SearchInfo';
|
import SearchInfo from '../../components/SearchInfo';
|
||||||
@@ -24,7 +24,8 @@ import { fetchLearners } from './data/thunks';
|
|||||||
import { LearnerCard, LearnerFilterBar } from './learner';
|
import { LearnerCard, LearnerFilterBar } from './learner';
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
|
|
||||||
function LearnersView({ intl }) {
|
const LearnersView = () => {
|
||||||
|
const intl = useIntl();
|
||||||
const { courseId } = useParams();
|
const { courseId } = useParams();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
@@ -46,7 +47,7 @@ function LearnersView({ intl }) {
|
|||||||
}
|
}
|
||||||
}, [courseId, orderBy, learnersTabEnabled, usernameSearch]);
|
}, [courseId, orderBy, learnersTabEnabled, usernameSearch]);
|
||||||
|
|
||||||
const loadPage = async () => {
|
const loadPage = useCallback(async () => {
|
||||||
if (nextPage) {
|
if (nextPage) {
|
||||||
dispatch(fetchLearners(courseId, {
|
dispatch(fetchLearners(courseId, {
|
||||||
orderBy,
|
orderBy,
|
||||||
@@ -54,7 +55,19 @@ function LearnersView({ intl }) {
|
|||||||
usernameSearch,
|
usernameSearch,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
};
|
}, [courseId, orderBy, nextPage, usernameSearch]);
|
||||||
|
|
||||||
|
const handleOnClear = useCallback(() => {
|
||||||
|
dispatch(setUsernameSearch(''));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const renderLearnersList = useMemo(() => (
|
||||||
|
(
|
||||||
|
courseConfigLoadingStatus === RequestStatus.SUCCESSFUL && learnersTabEnabled && learners.map((learner) => (
|
||||||
|
<LearnerCard learner={learner} key={learner.username} />
|
||||||
|
))
|
||||||
|
) || <></>
|
||||||
|
), [courseConfigLoadingStatus, learnersTabEnabled, learners]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="d-flex flex-column border-right border-light-400">
|
<div className="d-flex flex-column border-right border-light-400">
|
||||||
@@ -65,7 +78,7 @@ function LearnersView({ intl }) {
|
|||||||
text={usernameSearch}
|
text={usernameSearch}
|
||||||
count={learners.length}
|
count={learners.length}
|
||||||
loadingStatus={loadingStatus}
|
loadingStatus={loadingStatus}
|
||||||
onClear={() => dispatch(setUsernameSearch(''))}
|
onClear={handleOnClear}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="list-group list-group-flush learner" role="list">
|
<div className="list-group list-group-flush learner" role="list">
|
||||||
@@ -77,12 +90,7 @@ function LearnersView({ intl }) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{courseConfigLoadingStatus === RequestStatus.SUCCESSFUL
|
{renderLearnersList}
|
||||||
&& learnersTabEnabled
|
|
||||||
&& learners.map((learner, index) => (
|
|
||||||
// eslint-disable-next-line react/no-array-index-key
|
|
||||||
<LearnerCard learner={learner} key={index} courseId={courseId} />
|
|
||||||
))}
|
|
||||||
{loadingStatus === RequestStatus.IN_PROGRESS ? (
|
{loadingStatus === RequestStatus.IN_PROGRESS ? (
|
||||||
<div className="d-flex justify-content-center p-4">
|
<div className="d-flex justify-content-center p-4">
|
||||||
<Spinner animation="border" variant="primary" size="lg" />
|
<Spinner animation="border" variant="primary" size="lg" />
|
||||||
@@ -98,10 +106,6 @@ function LearnersView({ intl }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
LearnersView.propTypes = {
|
|
||||||
intl: intlShape.isRequired,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default injectIntl(LearnersView);
|
export default LearnersView;
|
||||||
|
|||||||
@@ -1,26 +1,23 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
import { Avatar } from '@edx/paragon';
|
import { Avatar } from '@edx/paragon';
|
||||||
|
|
||||||
import { learnerShape } from './proptypes';
|
const LearnerAvatar = ({ username }) => (
|
||||||
|
<div className="mr-3 mt-1">
|
||||||
function LearnerAvatar({ learner }) {
|
<Avatar
|
||||||
return (
|
size="sm"
|
||||||
<div className="mr-3 mt-1">
|
alt={username}
|
||||||
<Avatar
|
style={{
|
||||||
size="sm"
|
height: '2rem',
|
||||||
alt={learner.username}
|
width: '2rem',
|
||||||
style={{
|
}}
|
||||||
height: '2rem',
|
/>
|
||||||
width: '2rem',
|
</div>
|
||||||
}}
|
);
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
LearnerAvatar.propTypes = {
|
LearnerAvatar.propTypes = {
|
||||||
learner: learnerShape.isRequired,
|
username: PropTypes.string.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default LearnerAvatar;
|
export default React.memo(LearnerAvatar);
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import React, { useContext } from 'react';
|
import React, { useContext } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
|
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
import { injectIntl } from '@edx/frontend-platform/i18n';
|
|
||||||
|
|
||||||
import { Routes } from '../../../data/constants';
|
import { Routes } from '../../../data/constants';
|
||||||
import { DiscussionContext } from '../../common/context';
|
import { DiscussionContext } from '../../common/context';
|
||||||
import { discussionsPath } from '../../utils';
|
import { discussionsPath } from '../../utils';
|
||||||
@@ -12,11 +9,11 @@ import LearnerAvatar from './LearnerAvatar';
|
|||||||
import LearnerFooter from './LearnerFooter';
|
import LearnerFooter from './LearnerFooter';
|
||||||
import { learnerShape } from './proptypes';
|
import { learnerShape } from './proptypes';
|
||||||
|
|
||||||
function LearnerCard({
|
const LearnerCard = ({ learner }) => {
|
||||||
learner,
|
const {
|
||||||
courseId,
|
username, threads, inactiveFlags, activeFlags, responses, replies,
|
||||||
}) {
|
} = learner;
|
||||||
const { enableInContextSidebar, learnerUsername } = useContext(DiscussionContext);
|
const { enableInContextSidebar, learnerUsername, courseId } = useContext(DiscussionContext);
|
||||||
const linkUrl = discussionsPath(Routes.LEARNERS.POSTS, {
|
const linkUrl = discussionsPath(Routes.LEARNERS.POSTS, {
|
||||||
0: enableInContextSidebar ? 'in-context' : undefined,
|
0: enableInContextSidebar ? 'in-context' : undefined,
|
||||||
learnerUsername: learner.username,
|
learnerUsername: learner.username,
|
||||||
@@ -30,32 +27,40 @@ function LearnerCard({
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="d-flex flex-row flex-fill mw-100 py-3 px-4 border-primary-500"
|
className="d-flex flex-row flex-fill mw-100 py-3 px-4 border-primary-500"
|
||||||
style={learner.username === learnerUsername ? {
|
style={username === learnerUsername ? {
|
||||||
borderRightWidth: '4px',
|
borderRightWidth: '4px',
|
||||||
borderRightStyle: 'solid',
|
borderRightStyle: 'solid',
|
||||||
} : null}
|
} : null}
|
||||||
>
|
>
|
||||||
<LearnerAvatar learner={learner} />
|
<LearnerAvatar username={username} />
|
||||||
<div className="d-flex flex-column flex-fill" style={{ minWidth: 0 }}>
|
<div className="d-flex flex-column flex-fill" style={{ minWidth: 0 }}>
|
||||||
<div className="d-flex flex-column justify-content-start mw-100 flex-fill">
|
<div className="d-flex flex-column justify-content-start mw-100 flex-fill">
|
||||||
<div className="d-flex align-items-center flex-fill">
|
<div className="d-flex align-items-center flex-fill">
|
||||||
<div
|
<div
|
||||||
className="text-truncate font-weight-500 font-size-14 text-primary-500 font-style"
|
className="text-truncate font-weight-500 font-size-14 text-primary-500 font-style"
|
||||||
>
|
>
|
||||||
{learner.username}
|
{username}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{learner.threads === null ? null : <LearnerFooter learner={learner} /> }
|
{threads !== null && (
|
||||||
|
<LearnerFooter
|
||||||
|
inactiveFlags={inactiveFlags}
|
||||||
|
activeFlags={activeFlags}
|
||||||
|
threads={threads}
|
||||||
|
responses={responses}
|
||||||
|
replies={replies}
|
||||||
|
username={username}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
LearnerCard.propTypes = {
|
LearnerCard.propTypes = {
|
||||||
learner: learnerShape.isRequired,
|
learner: learnerShape.isRequired,
|
||||||
courseId: PropTypes.string.isRequired,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default injectIntl(LearnerCard);
|
export default React.memo(LearnerCard);
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useCallback, useState } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
|
||||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import { Collapsible, Form, Icon } from '@edx/paragon';
|
import { Collapsible, Form, Icon } from '@edx/paragon';
|
||||||
import { Check, Tune } from '@edx/paragon/icons';
|
import { Check, Tune } from '@edx/paragon/icons';
|
||||||
|
|
||||||
@@ -15,7 +15,7 @@ import { setSortedBy } from '../data';
|
|||||||
import { selectLearnerSorting } from '../data/selectors';
|
import { selectLearnerSorting } from '../data/selectors';
|
||||||
import messages from '../messages';
|
import messages from '../messages';
|
||||||
|
|
||||||
const ActionItem = ({
|
const ActionItem = React.memo(({
|
||||||
id,
|
id,
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
@@ -38,7 +38,7 @@ const ActionItem = ({
|
|||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
);
|
));
|
||||||
|
|
||||||
ActionItem.propTypes = {
|
ActionItem.propTypes = {
|
||||||
id: PropTypes.string.isRequired,
|
id: PropTypes.string.isRequired,
|
||||||
@@ -47,16 +47,15 @@ ActionItem.propTypes = {
|
|||||||
selected: PropTypes.string.isRequired,
|
selected: PropTypes.string.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
function LearnerFilterBar({
|
const LearnerFilterBar = () => {
|
||||||
intl,
|
const intl = useIntl();
|
||||||
}) {
|
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
|
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
|
||||||
const userIsGroupTa = useSelector(selectUserIsGroupTa);
|
const userIsGroupTa = useSelector(selectUserIsGroupTa);
|
||||||
const currentSorting = useSelector(selectLearnerSorting());
|
const currentSorting = useSelector(selectLearnerSorting());
|
||||||
const [isOpen, setOpen] = useState(false);
|
const [isOpen, setOpen] = useState(false);
|
||||||
|
|
||||||
const handleSortFilterChange = (event) => {
|
const handleSortFilterChange = useCallback((event) => {
|
||||||
const { name, value } = event.currentTarget;
|
const { name, value } = event.currentTarget;
|
||||||
|
|
||||||
if (name === 'sort') {
|
if (name === 'sort') {
|
||||||
@@ -68,12 +67,16 @@ function LearnerFilterBar({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
|
const handleOnToggle = useCallback(() => {
|
||||||
|
setOpen(!isOpen);
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Collapsible.Advanced
|
<Collapsible.Advanced
|
||||||
open={isOpen}
|
open={isOpen}
|
||||||
onToggle={() => setOpen(!isOpen)}
|
onToggle={handleOnToggle}
|
||||||
className="filter-bar collapsible-card-lg border-0"
|
className="filter-bar collapsible-card-lg border-0"
|
||||||
>
|
>
|
||||||
<Collapsible.Trigger className="collapsible-trigger border-0">
|
<Collapsible.Trigger className="collapsible-trigger border-0">
|
||||||
@@ -124,10 +127,6 @@ function LearnerFilterBar({
|
|||||||
</Collapsible.Body>
|
</Collapsible.Body>
|
||||||
</Collapsible.Advanced>
|
</Collapsible.Advanced>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
LearnerFilterBar.propTypes = {
|
|
||||||
intl: intlShape.isRequired,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default injectIntl(LearnerFilterBar);
|
export default LearnerFilterBar;
|
||||||
|
|||||||
@@ -1,24 +1,22 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
|
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import { Icon, OverlayTrigger, Tooltip } from '@edx/paragon';
|
import { Icon, OverlayTrigger, Tooltip } from '@edx/paragon';
|
||||||
import { Edit, Report, ReportGmailerrorred } from '@edx/paragon/icons';
|
import { Edit, Report, ReportGmailerrorred } from '@edx/paragon/icons';
|
||||||
|
|
||||||
import { QuestionAnswerOutline } from '../../../components/icons';
|
import { QuestionAnswerOutline } from '../../../components/icons';
|
||||||
import { selectUserHasModerationPrivileges, selectUserIsGroupTa } from '../../data/selectors';
|
import { selectUserHasModerationPrivileges, selectUserIsGroupTa } from '../../data/selectors';
|
||||||
import messages from '../messages';
|
import messages from '../messages';
|
||||||
import { learnerShape } from './proptypes';
|
|
||||||
|
|
||||||
function LearnerFooter({
|
const LearnerFooter = ({
|
||||||
learner,
|
inactiveFlags, activeFlags, threads, responses, replies, username,
|
||||||
intl,
|
}) => {
|
||||||
}) {
|
const intl = useIntl();
|
||||||
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
|
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
|
||||||
const userIsGroupTa = useSelector(selectUserIsGroupTa);
|
const userIsGroupTa = useSelector(selectUserIsGroupTa);
|
||||||
const { inactiveFlags } = learner;
|
|
||||||
const { activeFlags } = learner;
|
|
||||||
const canSeeLearnerReportedStats = (activeFlags || inactiveFlags) && (userHasModerationPrivileges || userIsGroupTa);
|
const canSeeLearnerReportedStats = (activeFlags || inactiveFlags) && (userHasModerationPrivileges || userIsGroupTa);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -35,7 +33,7 @@ function LearnerFooter({
|
|||||||
>
|
>
|
||||||
<div className="d-flex align-items-center">
|
<div className="d-flex align-items-center">
|
||||||
<Icon src={QuestionAnswerOutline} className="icon-size mr-2" />
|
<Icon src={QuestionAnswerOutline} className="icon-size mr-2" />
|
||||||
{learner.threads + learner.responses + learner.replies}
|
{threads + responses + replies}
|
||||||
</div>
|
</div>
|
||||||
</OverlayTrigger>
|
</OverlayTrigger>
|
||||||
<OverlayTrigger
|
<OverlayTrigger
|
||||||
@@ -50,14 +48,14 @@ function LearnerFooter({
|
|||||||
>
|
>
|
||||||
<div className="d-flex align-items-center">
|
<div className="d-flex align-items-center">
|
||||||
<Icon src={Edit} className="icon-size mr-2 ml-4" />
|
<Icon src={Edit} className="icon-size mr-2 ml-4" />
|
||||||
{learner.threads}
|
{threads}
|
||||||
</div>
|
</div>
|
||||||
</OverlayTrigger>
|
</OverlayTrigger>
|
||||||
{Boolean(canSeeLearnerReportedStats) && (
|
{Boolean(canSeeLearnerReportedStats) && (
|
||||||
<OverlayTrigger
|
<OverlayTrigger
|
||||||
placement="right"
|
placement="right"
|
||||||
overlay={(
|
overlay={(
|
||||||
<Tooltip id={`learner-${learner.username}`}>
|
<Tooltip id={`learner-${username}`}>
|
||||||
<div className="d-flex flex-column align-items-start">
|
<div className="d-flex flex-column align-items-start">
|
||||||
{Boolean(activeFlags)
|
{Boolean(activeFlags)
|
||||||
&& (
|
&& (
|
||||||
@@ -83,11 +81,24 @@ function LearnerFooter({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
LearnerFooter.propTypes = {
|
|
||||||
intl: intlShape.isRequired,
|
|
||||||
learner: learnerShape.isRequired,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default injectIntl(LearnerFooter);
|
LearnerFooter.propTypes = {
|
||||||
|
inactiveFlags: PropTypes.number,
|
||||||
|
activeFlags: PropTypes.number,
|
||||||
|
threads: PropTypes.number,
|
||||||
|
responses: PropTypes.number,
|
||||||
|
replies: PropTypes.number,
|
||||||
|
username: PropTypes.string,
|
||||||
|
};
|
||||||
|
|
||||||
|
LearnerFooter.defaultProps = {
|
||||||
|
inactiveFlags: 0,
|
||||||
|
activeFlags: 0,
|
||||||
|
threads: 0,
|
||||||
|
responses: 0,
|
||||||
|
replies: 0,
|
||||||
|
username: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default React.memo(LearnerFooter);
|
||||||
|
|||||||
@@ -3,22 +3,23 @@ import PropTypes from 'prop-types';
|
|||||||
|
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import { Dropdown, DropdownButton } from '@edx/paragon';
|
import { Dropdown, DropdownButton } from '@edx/paragon';
|
||||||
|
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
|
|
||||||
function BreadcrumbDropdown({
|
const BreadcrumbDropdown = ({
|
||||||
currentItem,
|
currentItem,
|
||||||
intl,
|
|
||||||
showAllPath,
|
showAllPath,
|
||||||
items,
|
items,
|
||||||
itemPathFunc,
|
itemPathFunc,
|
||||||
itemLabelFunc,
|
itemLabelFunc,
|
||||||
itemActiveFunc,
|
itemActiveFunc,
|
||||||
itemFilterFunc,
|
itemFilterFunc,
|
||||||
}) {
|
}) => {
|
||||||
|
const intl = useIntl();
|
||||||
const showAllMsg = intl.formatMessage(messages.showAll);
|
const showAllMsg = intl.formatMessage(messages.showAll);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownButton
|
<DropdownButton
|
||||||
title={itemLabelFunc(currentItem) || showAllMsg}
|
title={itemLabelFunc(currentItem) || showAllMsg}
|
||||||
@@ -46,12 +47,11 @@ function BreadcrumbDropdown({
|
|||||||
))}
|
))}
|
||||||
</DropdownButton>
|
</DropdownButton>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
BreadcrumbDropdown.propTypes = {
|
BreadcrumbDropdown.propTypes = {
|
||||||
// eslint-disable-next-line react/forbid-prop-types
|
// eslint-disable-next-line react/forbid-prop-types
|
||||||
currentItem: PropTypes.any,
|
currentItem: PropTypes.any,
|
||||||
intl: intlShape.isRequired,
|
|
||||||
showAllPath: PropTypes.func.isRequired,
|
showAllPath: PropTypes.func.isRequired,
|
||||||
// eslint-disable-next-line react/forbid-prop-types
|
// eslint-disable-next-line react/forbid-prop-types
|
||||||
items: PropTypes.array.isRequired,
|
items: PropTypes.array.isRequired,
|
||||||
@@ -60,9 +60,10 @@ BreadcrumbDropdown.propTypes = {
|
|||||||
itemActiveFunc: PropTypes.func.isRequired,
|
itemActiveFunc: PropTypes.func.isRequired,
|
||||||
itemFilterFunc: PropTypes.func,
|
itemFilterFunc: PropTypes.func,
|
||||||
};
|
};
|
||||||
|
|
||||||
BreadcrumbDropdown.defaultProps = {
|
BreadcrumbDropdown.defaultProps = {
|
||||||
currentItem: null,
|
currentItem: null,
|
||||||
itemFilterFunc: null,
|
itemFilterFunc: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default injectIntl(BreadcrumbDropdown);
|
export default React.memo(BreadcrumbDropdown);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React from 'react';
|
import React, { useCallback } from 'react';
|
||||||
|
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
import { useRouteMatch } from 'react-router';
|
import { useRouteMatch } from 'react-router';
|
||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
import { discussionsPath } from '../../utils';
|
import { discussionsPath } from '../../utils';
|
||||||
import BreadcrumbDropdown from './BreadcrumbDropdown';
|
import BreadcrumbDropdown from './BreadcrumbDropdown';
|
||||||
|
|
||||||
function LegacyBreadcrumbMenu() {
|
const LegacyBreadcrumbMenu = () => {
|
||||||
const {
|
const {
|
||||||
params: {
|
params: {
|
||||||
courseId,
|
courseId,
|
||||||
@@ -21,7 +21,6 @@ function LegacyBreadcrumbMenu() {
|
|||||||
topicId: currentTopicId,
|
topicId: currentTopicId,
|
||||||
},
|
},
|
||||||
} = useRouteMatch([Routes.TOPICS.CATEGORY, Routes.TOPICS.TOPIC]);
|
} = useRouteMatch([Routes.TOPICS.CATEGORY, Routes.TOPICS.TOPIC]);
|
||||||
|
|
||||||
const currentTopic = useSelector(selectTopic(currentTopicId));
|
const currentTopic = useSelector(selectTopic(currentTopicId));
|
||||||
const currentCategory = category || currentTopic?.categoryId;
|
const currentCategory = category || currentTopic?.categoryId;
|
||||||
const decodedCurrentCategory = String(currentCategory).replace('%23', '#');
|
const decodedCurrentCategory = String(currentCategory).replace('%23', '#');
|
||||||
@@ -30,31 +29,68 @@ function LegacyBreadcrumbMenu() {
|
|||||||
const categories = useSelector(selectCategories);
|
const categories = useSelector(selectCategories);
|
||||||
const isNonCoursewareTopic = currentTopic && !currentCategory;
|
const isNonCoursewareTopic = currentTopic && !currentCategory;
|
||||||
|
|
||||||
|
const nonCoursewareItemLabel = useCallback((item) => (
|
||||||
|
item?.name
|
||||||
|
), []);
|
||||||
|
|
||||||
|
const nonCoursewareActive = useCallback((topic) => (
|
||||||
|
topic?.id === currentTopicId
|
||||||
|
), [currentTopicId]);
|
||||||
|
|
||||||
|
const nonCoursewareItemPath = useCallback((topic) => (
|
||||||
|
discussionsPath(Routes.TOPICS.TOPIC, {
|
||||||
|
courseId,
|
||||||
|
topicId: topic.id,
|
||||||
|
})
|
||||||
|
), [courseId]);
|
||||||
|
|
||||||
|
const coursewareItemLabel = useCallback((catId) => (
|
||||||
|
catId
|
||||||
|
), []);
|
||||||
|
|
||||||
|
const coursewareActive = useCallback((catId) => (
|
||||||
|
catId === currentCategory
|
||||||
|
), [currentTopicId]);
|
||||||
|
|
||||||
|
const coursewareItemPath = useCallback((catId) => (
|
||||||
|
discussionsPath(Routes.TOPICS.CATEGORY, {
|
||||||
|
courseId,
|
||||||
|
category: catId,
|
||||||
|
})
|
||||||
|
), [courseId]);
|
||||||
|
|
||||||
|
const categoryItemLabel = useCallback((item) => item?.name, []);
|
||||||
|
|
||||||
|
const categoryActive = useCallback((topic) => (
|
||||||
|
topic?.id === currentTopicId
|
||||||
|
), [currentTopicId]);
|
||||||
|
|
||||||
|
const categoryItemPath = useCallback((topic) => (
|
||||||
|
discussionsPath(Routes.TOPICS.TOPIC, {
|
||||||
|
courseId,
|
||||||
|
topicId: topic.id,
|
||||||
|
})
|
||||||
|
), [courseId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="breadcrumb-menu d-flex flex-row bg-light-200 box-shadow-down-1 px-2.5 py-1">
|
<div className="breadcrumb-menu d-flex flex-row bg-light-200 box-shadow-down-1 px-2.5 py-1">
|
||||||
{isNonCoursewareTopic ? (
|
{isNonCoursewareTopic ? (
|
||||||
<BreadcrumbDropdown
|
<BreadcrumbDropdown
|
||||||
currentItem={currentTopic}
|
currentItem={currentTopic}
|
||||||
itemLabelFunc={(item) => item?.name}
|
itemLabelFunc={nonCoursewareItemLabel}
|
||||||
itemActiveFunc={(topic) => topic?.id === currentTopicId}
|
itemActiveFunc={nonCoursewareActive}
|
||||||
items={nonCoursewareTopics}
|
items={nonCoursewareTopics}
|
||||||
showAllPath={discussionsPath(Routes.TOPICS.ALL, { courseId })}
|
showAllPath={discussionsPath(Routes.TOPICS.ALL, { courseId })}
|
||||||
itemPathFunc={(topic) => discussionsPath(Routes.TOPICS.TOPIC, {
|
itemPathFunc={nonCoursewareItemPath}
|
||||||
courseId,
|
|
||||||
topicId: topic.id,
|
|
||||||
})}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<BreadcrumbDropdown
|
<BreadcrumbDropdown
|
||||||
currentItem={decodedCurrentCategory}
|
currentItem={decodedCurrentCategory}
|
||||||
itemLabelFunc={(catId) => catId}
|
itemLabelFunc={coursewareItemLabel}
|
||||||
itemActiveFunc={(catId) => catId === currentCategory}
|
itemActiveFunc={coursewareActive}
|
||||||
items={categories}
|
items={categories}
|
||||||
showAllPath={discussionsPath(Routes.TOPICS.ALL, { courseId })}
|
showAllPath={discussionsPath(Routes.TOPICS.ALL, { courseId })}
|
||||||
itemPathFunc={(catId) => discussionsPath(Routes.TOPICS.CATEGORY, {
|
itemPathFunc={coursewareItemPath}
|
||||||
courseId,
|
|
||||||
category: catId,
|
|
||||||
})}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{currentCategory && (
|
{currentCategory && (
|
||||||
@@ -62,24 +98,19 @@ function LegacyBreadcrumbMenu() {
|
|||||||
<div className="d-flex py-2">/</div>
|
<div className="d-flex py-2">/</div>
|
||||||
<BreadcrumbDropdown
|
<BreadcrumbDropdown
|
||||||
currentItem={currentTopic}
|
currentItem={currentTopic}
|
||||||
itemLabelFunc={(item) => item?.name}
|
itemLabelFunc={categoryItemLabel}
|
||||||
itemActiveFunc={(topic) => topic?.id === currentTopicId}
|
itemActiveFunc={categoryActive}
|
||||||
items={topicsInCategory}
|
items={topicsInCategory}
|
||||||
showAllPath={discussionsPath(Routes.TOPICS.CATEGORY, {
|
showAllPath={discussionsPath(Routes.TOPICS.CATEGORY, {
|
||||||
courseId,
|
courseId,
|
||||||
category: currentCategory,
|
category: currentCategory,
|
||||||
})}
|
})}
|
||||||
itemPathFunc={(topic) => discussionsPath(Routes.TOPICS.TOPIC, {
|
itemPathFunc={categoryItemPath}
|
||||||
courseId,
|
|
||||||
topicId: topic.id,
|
|
||||||
})}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
LegacyBreadcrumbMenu.propTypes = {};
|
|
||||||
|
|
||||||
export default LegacyBreadcrumbMenu;
|
export default LegacyBreadcrumbMenu;
|
||||||
|
|||||||
@@ -1,21 +1,23 @@
|
|||||||
import React from 'react';
|
import React, { useContext, useMemo } from 'react';
|
||||||
|
|
||||||
import { matchPath, useParams } from 'react-router';
|
import { matchPath } from 'react-router';
|
||||||
import { NavLink } from 'react-router-dom';
|
import { NavLink } from 'react-router-dom';
|
||||||
|
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import { Nav } from '@edx/paragon';
|
import { Nav } from '@edx/paragon';
|
||||||
|
|
||||||
import { Routes } from '../../../data/constants';
|
import { Routes } from '../../../data/constants';
|
||||||
|
import { DiscussionContext } from '../../common/context';
|
||||||
import { useShowLearnersTab } from '../../data/hooks';
|
import { useShowLearnersTab } from '../../data/hooks';
|
||||||
import { discussionsPath } from '../../utils';
|
import { discussionsPath } from '../../utils';
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
|
|
||||||
function NavigationBar({ intl }) {
|
const NavigationBar = () => {
|
||||||
const { courseId } = useParams();
|
const intl = useIntl();
|
||||||
|
const { courseId } = useContext(DiscussionContext);
|
||||||
const showLearnersTab = useShowLearnersTab();
|
const showLearnersTab = useShowLearnersTab();
|
||||||
|
|
||||||
const navLinks = [
|
const navLinks = useMemo(() => ([
|
||||||
{
|
{
|
||||||
route: Routes.POSTS.MY_POSTS,
|
route: Routes.POSTS.MY_POSTS,
|
||||||
labelMessage: messages.myPosts,
|
labelMessage: messages.myPosts,
|
||||||
@@ -29,19 +31,23 @@ function NavigationBar({ intl }) {
|
|||||||
isActive: (match, location) => Boolean(matchPath(location.pathname, { path: Routes.TOPICS.PATH })),
|
isActive: (match, location) => Boolean(matchPath(location.pathname, { path: Routes.TOPICS.PATH })),
|
||||||
labelMessage: messages.allTopics,
|
labelMessage: messages.allTopics,
|
||||||
},
|
},
|
||||||
];
|
]), []);
|
||||||
if (showLearnersTab) {
|
|
||||||
navLinks.push({
|
useMemo(() => {
|
||||||
route: Routes.LEARNERS.PATH,
|
if (showLearnersTab) {
|
||||||
labelMessage: messages.learners,
|
navLinks.push({
|
||||||
});
|
route: Routes.LEARNERS.PATH,
|
||||||
}
|
labelMessage: messages.learners,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [showLearnersTab]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Nav variant="button-group" className="py-2">
|
<Nav variant="pills" className="py-2 nav-button-group">
|
||||||
{navLinks.map(link => (
|
{navLinks.map(link => (
|
||||||
<Nav.Item key={link.route}>
|
<Nav.Item key={link.route}>
|
||||||
<Nav.Link
|
<Nav.Link
|
||||||
|
key={link.route}
|
||||||
as={NavLink}
|
as={NavLink}
|
||||||
to={discussionsPath(link.route, { courseId })}
|
to={discussionsPath(link.route, { courseId })}
|
||||||
isActive={link.isActive}
|
isActive={link.isActive}
|
||||||
@@ -52,10 +58,6 @@ function NavigationBar({ intl }) {
|
|||||||
))}
|
))}
|
||||||
</Nav>
|
</Nav>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
NavigationBar.propTypes = {
|
|
||||||
intl: intlShape.isRequired,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default injectIntl(NavigationBar);
|
export default React.memo(NavigationBar);
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import React, { useContext, useEffect, useState } from 'react';
|
import React, {
|
||||||
|
Suspense, useCallback, useContext, useEffect, useState,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
import { useParams } from 'react-router';
|
|
||||||
import { useHistory, useLocation } from 'react-router-dom';
|
import { useHistory, useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import {
|
import { Button, Icon, IconButton } from '@edx/paragon';
|
||||||
Button, Icon, IconButton, Spinner,
|
|
||||||
} from '@edx/paragon';
|
|
||||||
import { ArrowBack } from '@edx/paragon/icons';
|
import { ArrowBack } from '@edx/paragon/icons';
|
||||||
|
|
||||||
|
import Spinner from '../../components/Spinner';
|
||||||
import { EndorsementStatus, PostsPages, ThreadType } from '../../data/constants';
|
import { EndorsementStatus, PostsPages, ThreadType } from '../../data/constants';
|
||||||
import { useDispatchWithState } from '../../data/hooks';
|
import { useDispatchWithState } from '../../data/hooks';
|
||||||
import { DiscussionContext } from '../common/context';
|
import { DiscussionContext } from '../common/context';
|
||||||
@@ -18,30 +18,45 @@ import { Post } from '../posts';
|
|||||||
import { fetchThread } from '../posts/data/thunks';
|
import { fetchThread } from '../posts/data/thunks';
|
||||||
import { discussionsPath } from '../utils';
|
import { discussionsPath } from '../utils';
|
||||||
import { ResponseEditor } from './comments/comment';
|
import { ResponseEditor } from './comments/comment';
|
||||||
import CommentsSort from './comments/CommentsSort';
|
|
||||||
import CommentsView from './comments/CommentsView';
|
|
||||||
import { useCommentsCount, usePost } from './data/hooks';
|
import { useCommentsCount, usePost } from './data/hooks';
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
|
import { PostCommentsContext } from './postCommentsContext';
|
||||||
|
|
||||||
function PostCommentsView({ intl }) {
|
const CommentsSort = React.lazy(() => import('./comments/CommentsSort'));
|
||||||
const [isLoading, submitDispatch] = useDispatchWithState();
|
const CommentsView = React.lazy(() => import('./comments/CommentsView'));
|
||||||
const { postId } = useParams();
|
|
||||||
const thread = usePost(postId);
|
const PostCommentsView = () => {
|
||||||
const commentsCount = useCommentsCount(postId);
|
const intl = useIntl();
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const isOnDesktop = useIsOnDesktop();
|
const isOnDesktop = useIsOnDesktop();
|
||||||
const [addingResponse, setAddingResponse] = useState(false);
|
const [addingResponse, setAddingResponse] = useState(false);
|
||||||
|
const [isLoading, submitDispatch] = useDispatchWithState();
|
||||||
const {
|
const {
|
||||||
courseId, learnerUsername, category, topicId, page, enableInContextSidebar,
|
courseId, learnerUsername, category, topicId, page, enableInContextSidebar, postId,
|
||||||
} = useContext(DiscussionContext);
|
} = useContext(DiscussionContext);
|
||||||
|
const commentsCount = useCommentsCount(postId);
|
||||||
|
const { closed, id: threadId, type } = usePost(postId);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!thread) { submitDispatch(fetchThread(postId, courseId, true)); }
|
if (!postId) {
|
||||||
setAddingResponse(false);
|
submitDispatch(fetchThread(postId, courseId, true));
|
||||||
}, [postId]);
|
}
|
||||||
|
|
||||||
if (!thread) {
|
return () => {
|
||||||
|
setAddingResponse(false);
|
||||||
|
};
|
||||||
|
}, [postId, courseId]);
|
||||||
|
|
||||||
|
const handleAddResponseButton = useCallback(() => {
|
||||||
|
setAddingResponse(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCloseEditor = useCallback(() => {
|
||||||
|
setAddingResponse(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!threadId) {
|
||||||
if (!isLoading) {
|
if (!isLoading) {
|
||||||
return (
|
return (
|
||||||
<EmptyPage title={intl.formatMessage(messages.noThreadFound)} />
|
<EmptyPage title={intl.formatMessage(messages.noThreadFound)} />
|
||||||
@@ -59,7 +74,12 @@ function PostCommentsView({ intl }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<PostCommentsContext.Provider value={{
|
||||||
|
isClosed: closed,
|
||||||
|
postType: type,
|
||||||
|
postId,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{!isOnDesktop && (
|
{!isOnDesktop && (
|
||||||
enableInContextSidebar ? (
|
enableInContextSidebar ? (
|
||||||
<>
|
<>
|
||||||
@@ -95,49 +115,28 @@ function PostCommentsView({ intl }) {
|
|||||||
<div
|
<div
|
||||||
className="discussion-comments d-flex flex-column card border-0 post-card-margin post-card-padding on-focus"
|
className="discussion-comments d-flex flex-column card border-0 post-card-margin post-card-padding on-focus"
|
||||||
>
|
>
|
||||||
<Post post={thread} handleAddResponseButton={() => setAddingResponse(true)} />
|
<Post handleAddResponseButton={handleAddResponseButton} />
|
||||||
{!thread.closed && (
|
{!closed && (
|
||||||
<ResponseEditor
|
<ResponseEditor
|
||||||
postId={postId}
|
handleCloseEditor={handleCloseEditor}
|
||||||
handleCloseEditor={() => setAddingResponse(false)}
|
|
||||||
addingResponse={addingResponse}
|
addingResponse={addingResponse}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!!commentsCount && <CommentsSort />}
|
<Suspense fallback={(<Spinner />)}>
|
||||||
{thread.type === ThreadType.DISCUSSION && (
|
{!!commentsCount && <CommentsSort />}
|
||||||
<CommentsView
|
{type === ThreadType.DISCUSSION && (
|
||||||
postId={postId}
|
<CommentsView endorsed={EndorsementStatus.DISCUSSION} />
|
||||||
intl={intl}
|
)}
|
||||||
postType={thread.type}
|
{type === ThreadType.QUESTION && (
|
||||||
endorsed={EndorsementStatus.DISCUSSION}
|
<>
|
||||||
isClosed={thread.closed}
|
<CommentsView endorsed={EndorsementStatus.ENDORSED} />
|
||||||
/>
|
<CommentsView endorsed={EndorsementStatus.UNENDORSED} />
|
||||||
)}
|
</>
|
||||||
{thread.type === ThreadType.QUESTION && (
|
)}
|
||||||
<>
|
</Suspense>
|
||||||
<CommentsView
|
</PostCommentsContext.Provider>
|
||||||
postId={postId}
|
|
||||||
intl={intl}
|
|
||||||
postType={thread.type}
|
|
||||||
endorsed={EndorsementStatus.ENDORSED}
|
|
||||||
isClosed={thread.closed}
|
|
||||||
/>
|
|
||||||
<CommentsView
|
|
||||||
postId={postId}
|
|
||||||
intl={intl}
|
|
||||||
postType={thread.type}
|
|
||||||
endorsed={EndorsementStatus.UNENDORSED}
|
|
||||||
isClosed={thread.closed}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
PostCommentsView.propTypes = {
|
|
||||||
intl: intlShape.isRequired,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default injectIntl(PostCommentsView);
|
export default PostCommentsView;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useState } from 'react';
|
|||||||
|
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import {
|
import {
|
||||||
Button, Dropdown, ModalPopup, useToggle,
|
Button, Dropdown, ModalPopup, useToggle,
|
||||||
} from '@edx/paragon';
|
} from '@edx/paragon';
|
||||||
@@ -13,15 +13,17 @@ import { selectCommentSortOrder } from '../data/selectors';
|
|||||||
import { setCommentSortOrder } from '../data/slices';
|
import { setCommentSortOrder } from '../data/slices';
|
||||||
import messages from '../messages';
|
import messages from '../messages';
|
||||||
|
|
||||||
function CommentSortDropdown({ intl }) {
|
const CommentSortDropdown = () => {
|
||||||
|
const intl = useIntl();
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const sortedOrder = useSelector(selectCommentSortOrder);
|
const sortedOrder = useSelector(selectCommentSortOrder);
|
||||||
const [isOpen, open, close] = useToggle(false);
|
const [isOpen, open, close] = useToggle(false);
|
||||||
const [target, setTarget] = useState(null);
|
const [target, setTarget] = useState(null);
|
||||||
const handleActions = (reverseOrder) => {
|
|
||||||
|
const handleActions = useCallback((reverseOrder) => {
|
||||||
close();
|
close();
|
||||||
dispatch(setCommentSortOrder(reverseOrder));
|
dispatch(setCommentSortOrder(reverseOrder));
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const enableCommentsSortTour = useCallback((enabled) => {
|
const enableCommentsSortTour = useCallback((enabled) => {
|
||||||
const data = {
|
const data = {
|
||||||
@@ -94,11 +96,6 @@ function CommentSortDropdown({ intl }) {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
CommentSortDropdown.propTypes = {
|
|
||||||
intl: intlShape.isRequired,
|
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default injectIntl(CommentSortDropdown);
|
export default CommentSortDropdown;
|
||||||
|
|||||||
@@ -1,36 +1,39 @@
|
|||||||
import React, { useMemo, useState } from 'react';
|
import React, { useCallback, useContext, useState } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import { Button, Spinner } from '@edx/paragon';
|
import { Button, Spinner } from '@edx/paragon';
|
||||||
|
|
||||||
import { EndorsementStatus } from '../../../data/constants';
|
import { EndorsementStatus } from '../../../data/constants';
|
||||||
import { useUserCanAddThreadInBlackoutDate } from '../../data/hooks';
|
import { useUserCanAddThreadInBlackoutDate } from '../../data/hooks';
|
||||||
import { filterPosts, isLastElementOfList } from '../../utils';
|
import { isLastElementOfList } from '../../utils';
|
||||||
import { usePostComments } from '../data/hooks';
|
import { usePostComments } from '../data/hooks';
|
||||||
import messages from '../messages';
|
import messages from '../messages';
|
||||||
|
import { PostCommentsContext } from '../postCommentsContext';
|
||||||
import { Comment, ResponseEditor } from './comment';
|
import { Comment, ResponseEditor } from './comment';
|
||||||
|
|
||||||
function CommentsView({
|
const CommentsView = ({ endorsed }) => {
|
||||||
postType,
|
const intl = useIntl();
|
||||||
postId,
|
const [addingResponse, setAddingResponse] = useState(false);
|
||||||
intl,
|
const { isClosed } = useContext(PostCommentsContext);
|
||||||
endorsed,
|
const userCanAddThreadInBlackoutDate = useUserCanAddThreadInBlackoutDate();
|
||||||
isClosed,
|
|
||||||
}) {
|
|
||||||
const {
|
const {
|
||||||
comments,
|
endorsedCommentsIds,
|
||||||
|
unEndorsedCommentsIds,
|
||||||
hasMorePages,
|
hasMorePages,
|
||||||
isLoading,
|
isLoading,
|
||||||
handleLoadMoreResponses,
|
handleLoadMoreResponses,
|
||||||
} = usePostComments(postId, endorsed);
|
} = usePostComments(endorsed);
|
||||||
|
|
||||||
const endorsedComments = useMemo(() => [...filterPosts(comments, 'endorsed')], [comments]);
|
const handleAddResponse = useCallback(() => {
|
||||||
const unEndorsedComments = useMemo(() => [...filterPosts(comments, 'unendorsed')], [comments]);
|
setAddingResponse(true);
|
||||||
const userCanAddThreadInBlackoutDate = useUserCanAddThreadInBlackoutDate();
|
}, []);
|
||||||
const [addingResponse, setAddingResponse] = useState(false);
|
|
||||||
|
|
||||||
const handleDefinition = (message, commentsLength) => (
|
const handleCloseResponseEditor = useCallback(() => {
|
||||||
|
setAddingResponse(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDefinition = useCallback((message, commentsLength) => (
|
||||||
<div
|
<div
|
||||||
className="mx-4 my-14px text-gray-700 font-style"
|
className="mx-4 my-14px text-gray-700 font-style"
|
||||||
role="heading"
|
role="heading"
|
||||||
@@ -38,17 +41,15 @@ function CommentsView({
|
|||||||
>
|
>
|
||||||
{intl.formatMessage(message, { num: commentsLength })}
|
{intl.formatMessage(message, { num: commentsLength })}
|
||||||
</div>
|
</div>
|
||||||
);
|
), []);
|
||||||
|
|
||||||
const handleComments = (postComments, showLoadMoreResponses = false) => (
|
const handleComments = useCallback((postCommentsIds, showLoadMoreResponses = false) => (
|
||||||
<div className="mx-4" role="list">
|
<div className="mx-4" role="list">
|
||||||
{postComments.map((comment) => (
|
{postCommentsIds.map((commentId) => (
|
||||||
<Comment
|
<Comment
|
||||||
comment={comment}
|
commentId={commentId}
|
||||||
key={comment.id}
|
key={commentId}
|
||||||
postType={postType}
|
marginBottom={isLastElementOfList(postCommentsIds, commentId)}
|
||||||
isClosedPost={isClosed}
|
|
||||||
marginBottom={isLastElementOfList(postComments, comment)}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{hasMorePages && !isLoading && !showLoadMoreResponses && (
|
{hasMorePages && !isLoading && !showLoadMoreResponses && (
|
||||||
@@ -68,26 +69,26 @@ function CommentsView({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
), [hasMorePages, isLoading, handleLoadMoreResponses]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{((hasMorePages && isLoading) || !isLoading) && (
|
{((hasMorePages && isLoading) || !isLoading) && (
|
||||||
<>
|
<>
|
||||||
{endorsedComments.length > 0 && (
|
{endorsedCommentsIds.length > 0 && (
|
||||||
<>
|
<>
|
||||||
{handleDefinition(messages.endorsedResponseCount, endorsedComments.length)}
|
{handleDefinition(messages.endorsedResponseCount, endorsedCommentsIds.length)}
|
||||||
{endorsed === EndorsementStatus.DISCUSSION
|
{endorsed === EndorsementStatus.DISCUSSION
|
||||||
? handleComments(endorsedComments, true)
|
? handleComments(endorsedCommentsIds, true)
|
||||||
: handleComments(endorsedComments, false)}
|
: handleComments(endorsedCommentsIds, false)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{endorsed !== EndorsementStatus.ENDORSED && (
|
{endorsed !== EndorsementStatus.ENDORSED && (
|
||||||
<>
|
<>
|
||||||
{handleDefinition(messages.responseCount, unEndorsedComments.length)}
|
{handleDefinition(messages.responseCount, unEndorsedCommentsIds.length)}
|
||||||
{unEndorsedComments.length === 0 && <br />}
|
{unEndorsedCommentsIds.length === 0 && <br />}
|
||||||
{handleComments(unEndorsedComments, false)}
|
{handleComments(unEndorsedCommentsIds, false)}
|
||||||
{(userCanAddThreadInBlackoutDate && !!unEndorsedComments.length && !isClosed) && (
|
{(userCanAddThreadInBlackoutDate && !!unEndorsedCommentsIds.length && !isClosed) && (
|
||||||
<div className="mx-4">
|
<div className="mx-4">
|
||||||
{!addingResponse && (
|
{!addingResponse && (
|
||||||
<Button
|
<Button
|
||||||
@@ -95,17 +96,16 @@ function CommentsView({
|
|||||||
block="true"
|
block="true"
|
||||||
className="card mb-4 px-0 border-0 py-10px mt-2 font-style font-weight-500
|
className="card mb-4 px-0 border-0 py-10px mt-2 font-style font-weight-500
|
||||||
line-height-24 font-size-14 text-primary-500"
|
line-height-24 font-size-14 text-primary-500"
|
||||||
onClick={() => setAddingResponse(true)}
|
onClick={handleAddResponse}
|
||||||
data-testid="add-response"
|
data-testid="add-response"
|
||||||
>
|
>
|
||||||
{intl.formatMessage(messages.addResponse)}
|
{intl.formatMessage(messages.addResponse)}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<ResponseEditor
|
<ResponseEditor
|
||||||
postId={postId}
|
|
||||||
handleCloseEditor={() => setAddingResponse(false)}
|
|
||||||
addWrappingDiv
|
addWrappingDiv
|
||||||
addingResponse={addingResponse}
|
addingResponse={addingResponse}
|
||||||
|
handleCloseEditor={handleCloseResponseEditor}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -115,16 +115,12 @@ function CommentsView({
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
CommentsView.propTypes = {
|
CommentsView.propTypes = {
|
||||||
postId: PropTypes.string.isRequired,
|
|
||||||
postType: PropTypes.string.isRequired,
|
|
||||||
isClosed: PropTypes.bool.isRequired,
|
|
||||||
intl: intlShape.isRequired,
|
|
||||||
endorsed: PropTypes.oneOf([
|
endorsed: PropTypes.oneOf([
|
||||||
EndorsementStatus.ENDORSED, EndorsementStatus.UNENDORSED, EndorsementStatus.DISCUSSION,
|
EndorsementStatus.ENDORSED, EndorsementStatus.UNENDORSED, EndorsementStatus.DISCUSSION,
|
||||||
]).isRequired,
|
]).isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default injectIntl(CommentsView);
|
export default React.memo(CommentsView);
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import React, {
|
import React, {
|
||||||
useCallback,
|
useCallback, useContext, useEffect, useMemo, useState,
|
||||||
useContext, useEffect, useMemo, useState,
|
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import { Button, useToggle } from '@edx/paragon';
|
import { Button, useToggle } from '@edx/paragon';
|
||||||
|
|
||||||
import HTMLLoader from '../../../../components/HTMLLoader';
|
import HTMLLoader from '../../../../components/HTMLLoader';
|
||||||
@@ -15,6 +14,7 @@ import { ContentActions, EndorsementStatus } from '../../../../data/constants';
|
|||||||
import { AlertBanner, Confirmation, EndorsedAlertBanner } from '../../../common';
|
import { AlertBanner, Confirmation, EndorsedAlertBanner } from '../../../common';
|
||||||
import { DiscussionContext } from '../../../common/context';
|
import { DiscussionContext } from '../../../common/context';
|
||||||
import HoverCard from '../../../common/HoverCard';
|
import HoverCard from '../../../common/HoverCard';
|
||||||
|
import { ContentTypes } from '../../../data/constants';
|
||||||
import { useUserCanAddThreadInBlackoutDate } from '../../../data/hooks';
|
import { useUserCanAddThreadInBlackoutDate } from '../../../data/hooks';
|
||||||
import { fetchThread } from '../../../posts/data/thunks';
|
import { fetchThread } from '../../../posts/data/thunks';
|
||||||
import LikeButton from '../../../posts/post/LikeButton';
|
import LikeButton from '../../../posts/post/LikeButton';
|
||||||
@@ -22,88 +22,123 @@ import { useActions } from '../../../utils';
|
|||||||
import {
|
import {
|
||||||
selectCommentCurrentPage,
|
selectCommentCurrentPage,
|
||||||
selectCommentHasMorePages,
|
selectCommentHasMorePages,
|
||||||
|
selectCommentOrResponseById,
|
||||||
selectCommentResponses,
|
selectCommentResponses,
|
||||||
|
selectCommentResponsesIds,
|
||||||
selectCommentSortOrder,
|
selectCommentSortOrder,
|
||||||
} from '../../data/selectors';
|
} from '../../data/selectors';
|
||||||
import { editComment, fetchCommentResponses, removeComment } from '../../data/thunks';
|
import { editComment, fetchCommentResponses, removeComment } from '../../data/thunks';
|
||||||
import messages from '../../messages';
|
import messages from '../../messages';
|
||||||
|
import { PostCommentsContext } from '../../postCommentsContext';
|
||||||
import CommentEditor from './CommentEditor';
|
import CommentEditor from './CommentEditor';
|
||||||
import CommentHeader from './CommentHeader';
|
import CommentHeader from './CommentHeader';
|
||||||
import { commentShape } from './proptypes';
|
|
||||||
import Reply from './Reply';
|
import Reply from './Reply';
|
||||||
|
|
||||||
function Comment({
|
const Comment = ({
|
||||||
postType,
|
commentId,
|
||||||
comment,
|
|
||||||
showFullThread = true,
|
|
||||||
isClosedPost,
|
|
||||||
intl,
|
|
||||||
marginBottom,
|
marginBottom,
|
||||||
}) {
|
showFullThread = true,
|
||||||
|
}) => {
|
||||||
|
const comment = useSelector(selectCommentOrResponseById(commentId));
|
||||||
|
const {
|
||||||
|
id, parentId, childCount, abuseFlagged, endorsed, threadId, endorsedAt, endorsedBy, endorsedByLabel, renderedBody,
|
||||||
|
voted, following, voteCount, authorLabel, author, createdAt, lastEdit, rawBody, closed, closedBy, closeReason,
|
||||||
|
editByLabel, closedByLabel,
|
||||||
|
} = comment;
|
||||||
|
const intl = useIntl();
|
||||||
|
const hasChildren = childCount > 0;
|
||||||
|
const isNested = Boolean(parentId);
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const hasChildren = comment.childCount > 0;
|
const { courseId } = useContext(DiscussionContext);
|
||||||
const isNested = Boolean(comment.parentId);
|
const { isClosed } = useContext(PostCommentsContext);
|
||||||
const inlineReplies = useSelector(selectCommentResponses(comment.id));
|
|
||||||
const [isEditing, setEditing] = useState(false);
|
const [isEditing, setEditing] = useState(false);
|
||||||
|
const [isReplying, setReplying] = useState(false);
|
||||||
const [isDeleting, showDeleteConfirmation, hideDeleteConfirmation] = useToggle(false);
|
const [isDeleting, showDeleteConfirmation, hideDeleteConfirmation] = useToggle(false);
|
||||||
const [isReporting, showReportConfirmation, hideReportConfirmation] = useToggle(false);
|
const [isReporting, showReportConfirmation, hideReportConfirmation] = useToggle(false);
|
||||||
const [isReplying, setReplying] = useState(false);
|
const inlineReplies = useSelector(selectCommentResponses(id));
|
||||||
const hasMorePages = useSelector(selectCommentHasMorePages(comment.id));
|
const inlineRepliesIds = useSelector(selectCommentResponsesIds(id));
|
||||||
const currentPage = useSelector(selectCommentCurrentPage(comment.id));
|
const hasMorePages = useSelector(selectCommentHasMorePages(id));
|
||||||
const userCanAddThreadInBlackoutDate = useUserCanAddThreadInBlackoutDate();
|
const currentPage = useSelector(selectCommentCurrentPage(id));
|
||||||
const { courseId } = useContext(DiscussionContext);
|
|
||||||
const sortedOrder = useSelector(selectCommentSortOrder);
|
const sortedOrder = useSelector(selectCommentSortOrder);
|
||||||
|
const actions = useActions(ContentTypes.COMMENT, id);
|
||||||
|
const userCanAddThreadInBlackoutDate = useUserCanAddThreadInBlackoutDate();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// If the comment has a parent comment, it won't have any children, so don't fetch them.
|
// If the comment has a parent comment, it won't have any children, so don't fetch them.
|
||||||
if (hasChildren && showFullThread) {
|
if (hasChildren && showFullThread) {
|
||||||
dispatch(fetchCommentResponses(comment.id, {
|
dispatch(fetchCommentResponses(id, {
|
||||||
page: 1,
|
page: 1,
|
||||||
reverseOrder: sortedOrder,
|
reverseOrder: sortedOrder,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}, [comment.id, sortedOrder]);
|
}, [id, sortedOrder]);
|
||||||
|
|
||||||
const actions = useActions({
|
const endorseIcons = useMemo(() => (
|
||||||
...comment,
|
actions.find(({ action }) => action === EndorsementStatus.ENDORSED)
|
||||||
postType,
|
), [actions]);
|
||||||
});
|
|
||||||
const endorseIcons = actions.find(({ action }) => action === EndorsementStatus.ENDORSED);
|
const handleEditContent = useCallback(() => {
|
||||||
|
setEditing(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCommentEndorse = useCallback(async () => {
|
||||||
|
await dispatch(editComment(id, { endorsed: !endorsed }, ContentActions.ENDORSE));
|
||||||
|
await dispatch(fetchThread(threadId, courseId));
|
||||||
|
}, [id, endorsed, threadId]);
|
||||||
|
|
||||||
const handleAbusedFlag = useCallback(() => {
|
const handleAbusedFlag = useCallback(() => {
|
||||||
if (comment.abuseFlagged) {
|
if (abuseFlagged) {
|
||||||
dispatch(editComment(comment.id, { flagged: !comment.abuseFlagged }));
|
dispatch(editComment(id, { flagged: !abuseFlagged }));
|
||||||
} else {
|
} else {
|
||||||
showReportConfirmation();
|
showReportConfirmation();
|
||||||
}
|
}
|
||||||
}, [comment.abuseFlagged, comment.id, dispatch, showReportConfirmation]);
|
}, [abuseFlagged, id, showReportConfirmation]);
|
||||||
|
|
||||||
const handleDeleteConfirmation = () => {
|
const handleDeleteConfirmation = useCallback(() => {
|
||||||
dispatch(removeComment(comment.id));
|
dispatch(removeComment(id));
|
||||||
hideDeleteConfirmation();
|
hideDeleteConfirmation();
|
||||||
};
|
}, [id, hideDeleteConfirmation]);
|
||||||
|
|
||||||
const handleReportConfirmation = () => {
|
const handleReportConfirmation = useCallback(() => {
|
||||||
dispatch(editComment(comment.id, { flagged: !comment.abuseFlagged }));
|
dispatch(editComment(id, { flagged: !abuseFlagged }));
|
||||||
hideReportConfirmation();
|
hideReportConfirmation();
|
||||||
};
|
}, [abuseFlagged, id, hideReportConfirmation]);
|
||||||
|
|
||||||
const actionHandlers = useMemo(() => ({
|
const actionHandlers = useMemo(() => ({
|
||||||
[ContentActions.EDIT_CONTENT]: () => setEditing(true),
|
[ContentActions.EDIT_CONTENT]: handleEditContent,
|
||||||
[ContentActions.ENDORSE]: async () => {
|
[ContentActions.ENDORSE]: handleCommentEndorse,
|
||||||
await dispatch(editComment(comment.id, { endorsed: !comment.endorsed }, ContentActions.ENDORSE));
|
|
||||||
await dispatch(fetchThread(comment.threadId, courseId));
|
|
||||||
},
|
|
||||||
[ContentActions.DELETE]: showDeleteConfirmation,
|
[ContentActions.DELETE]: showDeleteConfirmation,
|
||||||
[ContentActions.REPORT]: () => handleAbusedFlag(),
|
[ContentActions.REPORT]: handleAbusedFlag,
|
||||||
}), [showDeleteConfirmation, dispatch, comment.id, comment.endorsed, comment.threadId, courseId, handleAbusedFlag]);
|
}), [handleEditContent, handleCommentEndorse, showDeleteConfirmation, handleAbusedFlag]);
|
||||||
|
|
||||||
const handleLoadMoreComments = () => (
|
const handleLoadMoreComments = useCallback(() => (
|
||||||
dispatch(fetchCommentResponses(comment.id, {
|
dispatch(fetchCommentResponses(id, {
|
||||||
page: currentPage + 1,
|
page: currentPage + 1,
|
||||||
reverseOrder: sortedOrder,
|
reverseOrder: sortedOrder,
|
||||||
}))
|
}))
|
||||||
);
|
), [id, currentPage, sortedOrder]);
|
||||||
|
|
||||||
|
const handleAddCommentButton = useCallback(() => {
|
||||||
|
if (userCanAddThreadInBlackoutDate) {
|
||||||
|
setReplying(true);
|
||||||
|
}
|
||||||
|
}, [userCanAddThreadInBlackoutDate]);
|
||||||
|
|
||||||
|
const handleCommentLike = useCallback(async () => {
|
||||||
|
await dispatch(editComment(id, { voted: !voted }));
|
||||||
|
}, [id, voted]);
|
||||||
|
|
||||||
|
const handleCloseEditor = useCallback(() => {
|
||||||
|
setEditing(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleAddCommentReply = useCallback(() => {
|
||||||
|
setReplying(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCloseReplyEditor = useCallback(() => {
|
||||||
|
setReplying(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classNames({ 'mb-3': (showFullThread && !marginBottom) })}>
|
<div className={classNames({ 'mb-3': (showFullThread && !marginBottom) })}>
|
||||||
@@ -111,7 +146,7 @@ function Comment({
|
|||||||
<div
|
<div
|
||||||
tabIndex="0"
|
tabIndex="0"
|
||||||
className="d-flex flex-column card on-focus border-0"
|
className="d-flex flex-column card on-focus border-0"
|
||||||
data-testid={`comment-${comment.id}`}
|
data-testid={`comment-${id}`}
|
||||||
role="listitem"
|
role="listitem"
|
||||||
>
|
>
|
||||||
<Confirmation
|
<Confirmation
|
||||||
@@ -123,7 +158,7 @@ function Comment({
|
|||||||
closeButtonVaraint="tertiary"
|
closeButtonVaraint="tertiary"
|
||||||
confirmButtonText={intl.formatMessage(messages.deleteConfirmationDelete)}
|
confirmButtonText={intl.formatMessage(messages.deleteConfirmationDelete)}
|
||||||
/>
|
/>
|
||||||
{!comment.abuseFlagged && (
|
{!abuseFlagged && (
|
||||||
<Confirmation
|
<Confirmation
|
||||||
isOpen={isReporting}
|
isOpen={isReporting}
|
||||||
title={intl.formatMessage(messages.reportResponseTitle)}
|
title={intl.formatMessage(messages.reportResponseTitle)}
|
||||||
@@ -133,49 +168,78 @@ function Comment({
|
|||||||
confirmButtonVariant="danger"
|
confirmButtonVariant="danger"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<EndorsedAlertBanner postType={postType} content={comment} />
|
<EndorsedAlertBanner
|
||||||
|
endorsed={endorsed}
|
||||||
|
endorsedAt={endorsedAt}
|
||||||
|
endorsedBy={endorsedBy}
|
||||||
|
endorsedByLabel={endorsedByLabel}
|
||||||
|
/>
|
||||||
<div className="d-flex flex-column post-card-comment px-4 pt-3.5 pb-10px" tabIndex="0">
|
<div className="d-flex flex-column post-card-comment px-4 pt-3.5 pb-10px" tabIndex="0">
|
||||||
<HoverCard
|
<HoverCard
|
||||||
commentOrPost={comment}
|
id={id}
|
||||||
|
contentType={ContentTypes.COMMENT}
|
||||||
actionHandlers={actionHandlers}
|
actionHandlers={actionHandlers}
|
||||||
handleResponseCommentButton={() => setReplying(true)}
|
handleResponseCommentButton={handleAddCommentButton}
|
||||||
onLike={() => dispatch(editComment(comment.id, { voted: !comment.voted }))}
|
|
||||||
addResponseCommentButtonMessage={intl.formatMessage(messages.addComment)}
|
addResponseCommentButtonMessage={intl.formatMessage(messages.addComment)}
|
||||||
isClosedPost={isClosedPost}
|
onLike={handleCommentLike}
|
||||||
|
voted={voted}
|
||||||
|
following={following}
|
||||||
endorseIcons={endorseIcons}
|
endorseIcons={endorseIcons}
|
||||||
/>
|
/>
|
||||||
<AlertBanner content={comment} />
|
<AlertBanner
|
||||||
<CommentHeader comment={comment} />
|
author={author}
|
||||||
{isEditing
|
abuseFlagged={abuseFlagged}
|
||||||
? (
|
lastEdit={lastEdit}
|
||||||
<CommentEditor comment={comment} onCloseEditor={() => setEditing(false)} formClasses="pt-3" />
|
closed={closed}
|
||||||
)
|
closedBy={closedBy}
|
||||||
: (
|
closeReason={closeReason}
|
||||||
<HTMLLoader
|
editByLabel={editByLabel}
|
||||||
cssClassName="comment-body html-loader text-break mt-14px font-style text-primary-500"
|
closedByLabel={closedByLabel}
|
||||||
componentId="comment"
|
/>
|
||||||
htmlNode={comment.renderedBody}
|
<CommentHeader
|
||||||
testId={comment.id}
|
author={author}
|
||||||
/>
|
authorLabel={authorLabel}
|
||||||
)}
|
abuseFlagged={abuseFlagged}
|
||||||
{comment.voted && (
|
closed={closed}
|
||||||
|
createdAt={createdAt}
|
||||||
|
lastEdit={lastEdit}
|
||||||
|
/>
|
||||||
|
{isEditing ? (
|
||||||
|
<CommentEditor
|
||||||
|
comment={{
|
||||||
|
author,
|
||||||
|
id,
|
||||||
|
lastEdit,
|
||||||
|
threadId,
|
||||||
|
parentId,
|
||||||
|
rawBody,
|
||||||
|
}}
|
||||||
|
onCloseEditor={handleCloseEditor}
|
||||||
|
formClasses="pt-3"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<HTMLLoader
|
||||||
|
cssClassName="comment-body html-loader text-break mt-14px font-style text-primary-500"
|
||||||
|
componentId="comment"
|
||||||
|
htmlNode={renderedBody}
|
||||||
|
testId={id}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{voted && (
|
||||||
<div className="ml-n1.5 mt-10px">
|
<div className="ml-n1.5 mt-10px">
|
||||||
<LikeButton
|
<LikeButton
|
||||||
count={comment.voteCount}
|
count={voteCount}
|
||||||
onClick={() => dispatch(editComment(comment.id, { voted: !comment.voted }))}
|
onClick={handleCommentLike}
|
||||||
voted={comment.voted}
|
voted={voted}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{inlineReplies.length > 0 && (
|
{inlineRepliesIds.length > 0 && (
|
||||||
<div className="d-flex flex-column mt-0.5" role="list">
|
<div className="d-flex flex-column mt-0.5" role="list">
|
||||||
{/* Pass along intl since component used here is the one before it's injected with `injectIntl` */}
|
{inlineRepliesIds.map(replyId => (
|
||||||
{inlineReplies.map(inlineReply => (
|
|
||||||
<Reply
|
<Reply
|
||||||
reply={inlineReply}
|
responseId={replyId}
|
||||||
postType={postType}
|
key={replyId}
|
||||||
key={inlineReply.id}
|
|
||||||
intl={intl}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -195,46 +259,39 @@ function Comment({
|
|||||||
isReplying ? (
|
isReplying ? (
|
||||||
<div className="mt-2.5">
|
<div className="mt-2.5">
|
||||||
<CommentEditor
|
<CommentEditor
|
||||||
comment={{ threadId: comment.threadId, parentId: comment.id }}
|
comment={{ threadId, parentId: id }}
|
||||||
edit={false}
|
edit={false}
|
||||||
onCloseEditor={() => setReplying(false)}
|
onCloseEditor={handleCloseReplyEditor}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
!isClosed && userCanAddThreadInBlackoutDate && (inlineReplies.length >= 5) && (
|
||||||
{!isClosedPost && userCanAddThreadInBlackoutDate && (inlineReplies.length >= 5)
|
<Button
|
||||||
&& (
|
className="d-flex flex-grow mt-2 font-size-14 font-style font-weight-500 text-primary-500"
|
||||||
<Button
|
variant="plain"
|
||||||
className="d-flex flex-grow mt-2 font-size-14 font-style font-weight-500 text-primary-500"
|
style={{ height: '36px' }}
|
||||||
variant="plain"
|
onClick={handleAddCommentReply}
|
||||||
style={{ height: '36px' }}
|
>
|
||||||
onClick={() => setReplying(true)}
|
{intl.formatMessage(messages.addComment)}
|
||||||
>
|
</Button>
|
||||||
{intl.formatMessage(messages.addComment)}
|
)
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
Comment.propTypes = {
|
Comment.propTypes = {
|
||||||
postType: PropTypes.oneOf(['discussion', 'question']).isRequired,
|
commentId: PropTypes.string.isRequired,
|
||||||
comment: commentShape.isRequired,
|
|
||||||
showFullThread: PropTypes.bool,
|
|
||||||
isClosedPost: PropTypes.bool,
|
|
||||||
intl: intlShape.isRequired,
|
|
||||||
marginBottom: PropTypes.bool,
|
marginBottom: PropTypes.bool,
|
||||||
|
showFullThread: PropTypes.bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
Comment.defaultProps = {
|
Comment.defaultProps = {
|
||||||
|
marginBottom: false,
|
||||||
showFullThread: true,
|
showFullThread: true,
|
||||||
isClosedPost: false,
|
|
||||||
marginBottom: true,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default injectIntl(Comment);
|
export default React.memo(Comment);
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import React, { useContext, useRef } from 'react';
|
import React, { useCallback, useContext, useRef } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
import { Formik } from 'formik';
|
import { Formik } from 'formik';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
import * as Yup from 'yup';
|
import * as Yup from 'yup';
|
||||||
|
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import { AppContext } from '@edx/frontend-platform/react';
|
import { AppContext } from '@edx/frontend-platform/react';
|
||||||
import { Button, Form, StatefulButton } from '@edx/paragon';
|
import { Button, Form, StatefulButton } from '@edx/paragon';
|
||||||
|
|
||||||
@@ -25,12 +25,15 @@ import { addComment, editComment } from '../../data/thunks';
|
|||||||
import messages from '../../messages';
|
import messages from '../../messages';
|
||||||
|
|
||||||
function CommentEditor({
|
function CommentEditor({
|
||||||
intl,
|
|
||||||
comment,
|
comment,
|
||||||
onCloseEditor,
|
|
||||||
edit,
|
edit,
|
||||||
formClasses,
|
formClasses,
|
||||||
|
onCloseEditor,
|
||||||
}) {
|
}) {
|
||||||
|
const {
|
||||||
|
id, threadId, parentId, rawBody, author, lastEdit,
|
||||||
|
} = comment;
|
||||||
|
const intl = useIntl();
|
||||||
const editorRef = useRef(null);
|
const editorRef = useRef(null);
|
||||||
const { authenticatedUser } = useContext(AppContext);
|
const { authenticatedUser } = useContext(AppContext);
|
||||||
const { enableInContextSidebar } = useContext(DiscussionContext);
|
const { enableInContextSidebar } = useContext(DiscussionContext);
|
||||||
@@ -42,7 +45,7 @@ function CommentEditor({
|
|||||||
|
|
||||||
const canDisplayEditReason = (reasonCodesEnabled && edit
|
const canDisplayEditReason = (reasonCodesEnabled && edit
|
||||||
&& (userHasModerationPrivileges || userIsGroupTa || userIsStaff)
|
&& (userHasModerationPrivileges || userIsGroupTa || userIsStaff)
|
||||||
&& comment?.author !== authenticatedUser.username
|
&& author !== authenticatedUser.username
|
||||||
);
|
);
|
||||||
|
|
||||||
const editReasonCodeValidation = canDisplayEditReason && {
|
const editReasonCodeValidation = canDisplayEditReason && {
|
||||||
@@ -56,34 +59,34 @@ function CommentEditor({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const initialValues = {
|
const initialValues = {
|
||||||
comment: comment.rawBody,
|
comment: rawBody,
|
||||||
editReasonCode: comment?.lastEdit?.reasonCode || (userIsStaff ? 'violates-guidelines' : ''),
|
editReasonCode: lastEdit?.reasonCode || (userIsStaff ? 'violates-guidelines' : ''),
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCloseEditor = (resetForm) => {
|
const handleCloseEditor = useCallback((resetForm) => {
|
||||||
resetForm({ values: initialValues });
|
resetForm({ values: initialValues });
|
||||||
onCloseEditor();
|
onCloseEditor();
|
||||||
};
|
}, [onCloseEditor, initialValues]);
|
||||||
|
|
||||||
const saveUpdatedComment = async (values, { resetForm }) => {
|
const saveUpdatedComment = useCallback(async (values, { resetForm }) => {
|
||||||
if (comment.id) {
|
if (id) {
|
||||||
const payload = {
|
const payload = {
|
||||||
...values,
|
...values,
|
||||||
editReasonCode: values.editReasonCode || undefined,
|
editReasonCode: values.editReasonCode || undefined,
|
||||||
};
|
};
|
||||||
await dispatch(editComment(comment.id, payload));
|
await dispatch(editComment(id, payload));
|
||||||
} else {
|
} else {
|
||||||
await dispatch(addComment(values.comment, comment.threadId, comment.parentId, enableInContextSidebar));
|
await dispatch(addComment(values.comment, threadId, parentId, enableInContextSidebar));
|
||||||
}
|
}
|
||||||
/* istanbul ignore if: TinyMCE is mocked so this cannot be easily tested */
|
/* istanbul ignore if: TinyMCE is mocked so this cannot be easily tested */
|
||||||
if (editorRef.current) {
|
if (editorRef.current) {
|
||||||
editorRef.current.plugins.autosave.removeDraft();
|
editorRef.current.plugins.autosave.removeDraft();
|
||||||
}
|
}
|
||||||
handleCloseEditor(resetForm);
|
handleCloseEditor(resetForm);
|
||||||
};
|
}, [id, threadId, parentId, enableInContextSidebar, handleCloseEditor]);
|
||||||
// The editorId is used to autosave contents to localstorage. This format means that the autosave is scoped to
|
// The editorId is used to autosave contents to localstorage. This format means that the autosave is scoped to
|
||||||
// the current comment id, or the current comment parent or the curren thread.
|
// the current comment id, or the current comment parent or the curren thread.
|
||||||
const editorId = `comment-editor-${comment.id || comment.parentId || comment.threadId}`;
|
const editorId = `comment-editor-${id || parentId || threadId}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Formik
|
<Formik
|
||||||
@@ -177,22 +180,28 @@ function CommentEditor({
|
|||||||
|
|
||||||
CommentEditor.propTypes = {
|
CommentEditor.propTypes = {
|
||||||
comment: PropTypes.shape({
|
comment: PropTypes.shape({
|
||||||
|
author: PropTypes.string,
|
||||||
id: PropTypes.string,
|
id: PropTypes.string,
|
||||||
threadId: PropTypes.string.isRequired,
|
lastEdit: PropTypes.object,
|
||||||
parentId: PropTypes.string,
|
parentId: PropTypes.string,
|
||||||
rawBody: PropTypes.string,
|
rawBody: PropTypes.string,
|
||||||
author: PropTypes.string,
|
threadId: PropTypes.string.isRequired,
|
||||||
lastEdit: PropTypes.object,
|
}),
|
||||||
}).isRequired,
|
|
||||||
onCloseEditor: PropTypes.func.isRequired,
|
|
||||||
intl: intlShape.isRequired,
|
|
||||||
edit: PropTypes.bool,
|
edit: PropTypes.bool,
|
||||||
formClasses: PropTypes.string,
|
formClasses: PropTypes.string,
|
||||||
|
onCloseEditor: PropTypes.func.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
CommentEditor.defaultProps = {
|
CommentEditor.defaultProps = {
|
||||||
edit: true,
|
edit: true,
|
||||||
|
comment: {
|
||||||
|
author: null,
|
||||||
|
id: null,
|
||||||
|
lastEdit: null,
|
||||||
|
parentId: null,
|
||||||
|
rawBody: '',
|
||||||
|
},
|
||||||
formClasses: '',
|
formClasses: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default injectIntl(CommentEditor);
|
export default React.memo(CommentEditor);
|
||||||
|
|||||||
@@ -1,20 +1,29 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
import { injectIntl } from '@edx/frontend-platform/i18n';
|
|
||||||
import { Avatar } from '@edx/paragon';
|
import { Avatar } from '@edx/paragon';
|
||||||
|
|
||||||
import { AvatarOutlineAndLabelColors } from '../../../../data/constants';
|
import { AvatarOutlineAndLabelColors } from '../../../../data/constants';
|
||||||
import { AuthorLabel } from '../../../common';
|
import { AuthorLabel } from '../../../common';
|
||||||
import { useAlertBannerVisible } from '../../../data/hooks';
|
import { useAlertBannerVisible } from '../../../data/hooks';
|
||||||
import { commentShape } from './proptypes';
|
|
||||||
|
|
||||||
function CommentHeader({
|
const CommentHeader = ({
|
||||||
comment,
|
author,
|
||||||
}) {
|
authorLabel,
|
||||||
const colorClass = AvatarOutlineAndLabelColors[comment.authorLabel];
|
abuseFlagged,
|
||||||
const hasAnyAlert = useAlertBannerVisible(comment);
|
closed,
|
||||||
|
createdAt,
|
||||||
|
lastEdit,
|
||||||
|
}) => {
|
||||||
|
const colorClass = AvatarOutlineAndLabelColors[authorLabel];
|
||||||
|
const hasAnyAlert = useAlertBannerVisible({
|
||||||
|
author,
|
||||||
|
abuseFlagged,
|
||||||
|
lastEdit,
|
||||||
|
closed,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classNames('d-flex flex-row justify-content-between', {
|
<div className={classNames('d-flex flex-row justify-content-between', {
|
||||||
@@ -24,27 +33,41 @@ function CommentHeader({
|
|||||||
<div className="align-items-center d-flex flex-row">
|
<div className="align-items-center d-flex flex-row">
|
||||||
<Avatar
|
<Avatar
|
||||||
className={`border-0 ml-0.5 mr-2.5 ${colorClass ? `outline-${colorClass}` : 'outline-anonymous'}`}
|
className={`border-0 ml-0.5 mr-2.5 ${colorClass ? `outline-${colorClass}` : 'outline-anonymous'}`}
|
||||||
alt={comment.author}
|
alt={author}
|
||||||
style={{
|
style={{
|
||||||
width: '32px',
|
width: '32px',
|
||||||
height: '32px',
|
height: '32px',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<AuthorLabel
|
<AuthorLabel
|
||||||
author={comment.author}
|
author={author}
|
||||||
authorLabel={comment.authorLabel}
|
authorLabel={authorLabel}
|
||||||
labelColor={colorClass && `text-${colorClass}`}
|
labelColor={colorClass && `text-${colorClass}`}
|
||||||
linkToProfile
|
linkToProfile
|
||||||
postCreatedAt={comment.createdAt}
|
postCreatedAt={createdAt}
|
||||||
postOrComment
|
postOrComment
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
CommentHeader.propTypes = {
|
|
||||||
comment: commentShape.isRequired,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default injectIntl(CommentHeader);
|
CommentHeader.propTypes = {
|
||||||
|
author: PropTypes.string.isRequired,
|
||||||
|
authorLabel: PropTypes.string,
|
||||||
|
abuseFlagged: PropTypes.bool.isRequired,
|
||||||
|
closed: PropTypes.bool,
|
||||||
|
createdAt: PropTypes.string.isRequired,
|
||||||
|
lastEdit: PropTypes.shape({
|
||||||
|
editorUsername: PropTypes.string,
|
||||||
|
reason: PropTypes.string,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
CommentHeader.defaultProps = {
|
||||||
|
authorLabel: null,
|
||||||
|
closed: undefined,
|
||||||
|
lastEdit: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default React.memo(CommentHeader);
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import React, { useCallback, useMemo, useState } from 'react';
|
import React, { useCallback, useMemo, useState } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
import { useDispatch } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
import * as timeago from 'timeago.js';
|
import * as timeago from 'timeago.js';
|
||||||
|
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import { Avatar, useToggle } from '@edx/paragon';
|
import { Avatar, useToggle } from '@edx/paragon';
|
||||||
|
|
||||||
import HTMLLoader from '../../../../components/HTMLLoader';
|
import HTMLLoader from '../../../../components/HTMLLoader';
|
||||||
@@ -13,57 +13,71 @@ import {
|
|||||||
ActionsDropdown, AlertBanner, AuthorLabel, Confirmation,
|
ActionsDropdown, AlertBanner, AuthorLabel, Confirmation,
|
||||||
} from '../../../common';
|
} from '../../../common';
|
||||||
import timeLocale from '../../../common/time-locale';
|
import timeLocale from '../../../common/time-locale';
|
||||||
|
import { ContentTypes } from '../../../data/constants';
|
||||||
import { useAlertBannerVisible } from '../../../data/hooks';
|
import { useAlertBannerVisible } from '../../../data/hooks';
|
||||||
|
import { selectCommentOrResponseById } from '../../data/selectors';
|
||||||
import { editComment, removeComment } from '../../data/thunks';
|
import { editComment, removeComment } from '../../data/thunks';
|
||||||
import messages from '../../messages';
|
import messages from '../../messages';
|
||||||
import CommentEditor from './CommentEditor';
|
import CommentEditor from './CommentEditor';
|
||||||
import { commentShape } from './proptypes';
|
|
||||||
|
|
||||||
function Reply({
|
const Reply = ({ responseId }) => {
|
||||||
reply,
|
|
||||||
postType,
|
|
||||||
intl,
|
|
||||||
}) {
|
|
||||||
timeago.register('time-locale', timeLocale);
|
timeago.register('time-locale', timeLocale);
|
||||||
|
const {
|
||||||
|
id, abuseFlagged, author, authorLabel, endorsed, lastEdit, closed, closedBy,
|
||||||
|
closeReason, createdAt, threadId, parentId, rawBody, renderedBody, editByLabel, closedByLabel,
|
||||||
|
} = useSelector(selectCommentOrResponseById(responseId));
|
||||||
|
const intl = useIntl();
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const [isEditing, setEditing] = useState(false);
|
const [isEditing, setEditing] = useState(false);
|
||||||
const [isDeleting, showDeleteConfirmation, hideDeleteConfirmation] = useToggle(false);
|
const [isDeleting, showDeleteConfirmation, hideDeleteConfirmation] = useToggle(false);
|
||||||
const [isReporting, showReportConfirmation, hideReportConfirmation] = useToggle(false);
|
const [isReporting, showReportConfirmation, hideReportConfirmation] = useToggle(false);
|
||||||
|
const colorClass = AvatarOutlineAndLabelColors[authorLabel];
|
||||||
|
const hasAnyAlert = useAlertBannerVisible({
|
||||||
|
author,
|
||||||
|
abuseFlagged,
|
||||||
|
lastEdit,
|
||||||
|
closed,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleDeleteConfirmation = useCallback(() => {
|
||||||
|
dispatch(removeComment(id));
|
||||||
|
hideDeleteConfirmation();
|
||||||
|
}, [id, hideDeleteConfirmation]);
|
||||||
|
|
||||||
|
const handleReportConfirmation = useCallback(() => {
|
||||||
|
dispatch(editComment(id, { flagged: !abuseFlagged }));
|
||||||
|
hideReportConfirmation();
|
||||||
|
}, [abuseFlagged, id, hideReportConfirmation]);
|
||||||
|
|
||||||
|
const handleEditContent = useCallback(() => {
|
||||||
|
setEditing(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleReplyEndorse = useCallback(() => {
|
||||||
|
dispatch(editComment(id, { endorsed: !endorsed }, ContentActions.ENDORSE));
|
||||||
|
}, [endorsed, id]);
|
||||||
|
|
||||||
const handleAbusedFlag = useCallback(() => {
|
const handleAbusedFlag = useCallback(() => {
|
||||||
if (reply.abuseFlagged) {
|
if (abuseFlagged) {
|
||||||
dispatch(editComment(reply.id, { flagged: !reply.abuseFlagged }));
|
dispatch(editComment(id, { flagged: !abuseFlagged }));
|
||||||
} else {
|
} else {
|
||||||
showReportConfirmation();
|
showReportConfirmation();
|
||||||
}
|
}
|
||||||
}, [dispatch, reply.abuseFlagged, reply.id, showReportConfirmation]);
|
}, [abuseFlagged, id, showReportConfirmation]);
|
||||||
|
|
||||||
const handleDeleteConfirmation = () => {
|
const handleCloseEditor = useCallback(() => {
|
||||||
dispatch(removeComment(reply.id));
|
setEditing(false);
|
||||||
hideDeleteConfirmation();
|
}, []);
|
||||||
};
|
|
||||||
|
|
||||||
const handleReportConfirmation = () => {
|
|
||||||
dispatch(editComment(reply.id, { flagged: !reply.abuseFlagged }));
|
|
||||||
hideReportConfirmation();
|
|
||||||
};
|
|
||||||
|
|
||||||
const actionHandlers = useMemo(() => ({
|
const actionHandlers = useMemo(() => ({
|
||||||
[ContentActions.EDIT_CONTENT]: () => setEditing(true),
|
[ContentActions.EDIT_CONTENT]: handleEditContent,
|
||||||
[ContentActions.ENDORSE]: () => dispatch(editComment(
|
[ContentActions.ENDORSE]: handleReplyEndorse,
|
||||||
reply.id,
|
|
||||||
{ endorsed: !reply.endorsed },
|
|
||||||
ContentActions.ENDORSE,
|
|
||||||
)),
|
|
||||||
[ContentActions.DELETE]: showDeleteConfirmation,
|
[ContentActions.DELETE]: showDeleteConfirmation,
|
||||||
[ContentActions.REPORT]: () => handleAbusedFlag(),
|
[ContentActions.REPORT]: handleAbusedFlag,
|
||||||
}), [dispatch, handleAbusedFlag, reply.endorsed, reply.id, showDeleteConfirmation]);
|
}), [handleEditContent, handleReplyEndorse, showDeleteConfirmation, handleAbusedFlag]);
|
||||||
|
|
||||||
const colorClass = AvatarOutlineAndLabelColors[reply.authorLabel];
|
|
||||||
const hasAnyAlert = useAlertBannerVisible(reply);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="d-flex flex-column mt-2.5 " data-testid={`reply-${reply.id}`} role="listitem">
|
<div className="d-flex flex-column mt-2.5 " data-testid={`reply-${id}`} role="listitem">
|
||||||
<Confirmation
|
<Confirmation
|
||||||
isOpen={isDeleting}
|
isOpen={isDeleting}
|
||||||
title={intl.formatMessage(messages.deleteCommentTitle)}
|
title={intl.formatMessage(messages.deleteCommentTitle)}
|
||||||
@@ -73,7 +87,7 @@ function Reply({
|
|||||||
closeButtonVaraint="tertiary"
|
closeButtonVaraint="tertiary"
|
||||||
confirmButtonText={intl.formatMessage(messages.deleteConfirmationDelete)}
|
confirmButtonText={intl.formatMessage(messages.deleteConfirmationDelete)}
|
||||||
/>
|
/>
|
||||||
{!reply.abuseFlagged && (
|
{!abuseFlagged && (
|
||||||
<Confirmation
|
<Confirmation
|
||||||
isOpen={isReporting}
|
isOpen={isReporting}
|
||||||
title={intl.formatMessage(messages.reportCommentTitle)}
|
title={intl.formatMessage(messages.reportCommentTitle)}
|
||||||
@@ -89,16 +103,24 @@ function Reply({
|
|||||||
<Avatar />
|
<Avatar />
|
||||||
</div>
|
</div>
|
||||||
<div className="w-100">
|
<div className="w-100">
|
||||||
<AlertBanner content={reply} intl={intl} />
|
<AlertBanner
|
||||||
|
author={author}
|
||||||
|
abuseFlagged={abuseFlagged}
|
||||||
|
closed={closed}
|
||||||
|
closedBy={closedBy}
|
||||||
|
closeReason={closeReason}
|
||||||
|
lastEdit={lastEdit}
|
||||||
|
editByLabel={editByLabel}
|
||||||
|
closedByLabel={closedByLabel}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="d-flex">
|
<div className="d-flex">
|
||||||
<div className="d-flex mr-3 mt-2.5">
|
<div className="d-flex mr-3 mt-2.5">
|
||||||
<Avatar
|
<Avatar
|
||||||
className={`ml-0.5 mt-0.5 border-0 ${colorClass ? `outline-${colorClass}` : 'outline-anonymous'}`}
|
className={`ml-0.5 mt-0.5 border-0 ${colorClass ? `outline-${colorClass}` : 'outline-anonymous'}`}
|
||||||
alt={reply.author}
|
alt={author}
|
||||||
style={{
|
style={{
|
||||||
width: '32px',
|
width: '32px',
|
||||||
height: '32px',
|
height: '32px',
|
||||||
@@ -111,42 +133,50 @@ function Reply({
|
|||||||
>
|
>
|
||||||
<div className="d-flex flex-row justify-content-between" style={{ height: '24px' }}>
|
<div className="d-flex flex-row justify-content-between" style={{ height: '24px' }}>
|
||||||
<AuthorLabel
|
<AuthorLabel
|
||||||
author={reply.author}
|
author={author}
|
||||||
authorLabel={reply.authorLabel}
|
authorLabel={authorLabel}
|
||||||
labelColor={colorClass && `text-${colorClass}`}
|
labelColor={colorClass && `text-${colorClass}`}
|
||||||
linkToProfile
|
linkToProfile
|
||||||
postCreatedAt={reply.createdAt}
|
postCreatedAt={createdAt}
|
||||||
postOrComment
|
postOrComment
|
||||||
/>
|
/>
|
||||||
<div className="ml-auto d-flex">
|
<div className="ml-auto d-flex">
|
||||||
<ActionsDropdown
|
<ActionsDropdown
|
||||||
commentOrPost={{
|
|
||||||
...reply,
|
|
||||||
postType,
|
|
||||||
}}
|
|
||||||
actionHandlers={actionHandlers}
|
actionHandlers={actionHandlers}
|
||||||
|
contentType={ContentTypes.COMMENT}
|
||||||
iconSize="inline"
|
iconSize="inline"
|
||||||
|
id={id}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{isEditing
|
{isEditing ? (
|
||||||
? <CommentEditor comment={reply} onCloseEditor={() => setEditing(false)} />
|
<CommentEditor
|
||||||
: (
|
comment={{
|
||||||
<HTMLLoader
|
id,
|
||||||
componentId="reply"
|
threadId,
|
||||||
htmlNode={reply.renderedBody}
|
parentId,
|
||||||
cssClassName="html-loader text-break font-style text-primary-500"
|
rawBody,
|
||||||
testId={reply.id}
|
author,
|
||||||
/>
|
lastEdit,
|
||||||
)}
|
}}
|
||||||
|
onCloseEditor={handleCloseEditor}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<HTMLLoader
|
||||||
|
componentId="reply"
|
||||||
|
htmlNode={renderedBody}
|
||||||
|
cssClassName="html-loader text-break font-style text-primary-500"
|
||||||
|
testId={id}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
Reply.propTypes = {
|
|
||||||
postType: PropTypes.oneOf(['discussion', 'question']).isRequired,
|
|
||||||
reply: commentShape.isRequired,
|
|
||||||
intl: intlShape.isRequired,
|
|
||||||
};
|
};
|
||||||
export default injectIntl(Reply);
|
|
||||||
|
Reply.propTypes = {
|
||||||
|
responseId: PropTypes.string.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default React.memo(Reply);
|
||||||
|
|||||||
@@ -1,36 +1,34 @@
|
|||||||
import React, { useEffect } from 'react';
|
import React, { useContext, useEffect } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
import { injectIntl } from '@edx/frontend-platform/i18n';
|
import { DiscussionContext } from '../../../common/context';
|
||||||
|
|
||||||
import CommentEditor from './CommentEditor';
|
import CommentEditor from './CommentEditor';
|
||||||
|
|
||||||
function ResponseEditor({
|
const ResponseEditor = ({
|
||||||
postId,
|
|
||||||
addWrappingDiv,
|
addWrappingDiv,
|
||||||
handleCloseEditor,
|
handleCloseEditor,
|
||||||
addingResponse,
|
addingResponse,
|
||||||
}) {
|
}) => {
|
||||||
|
const { postId } = useContext(DiscussionContext);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
handleCloseEditor();
|
handleCloseEditor();
|
||||||
}, [postId]);
|
}, [postId]);
|
||||||
|
|
||||||
return addingResponse
|
return addingResponse && (
|
||||||
&& (
|
<div className={classNames({ 'bg-white p-4 mb-4 rounded mt-2': addWrappingDiv })}>
|
||||||
<div className={classNames({ 'bg-white p-4 mb-4 rounded mt-2': addWrappingDiv })}>
|
<CommentEditor
|
||||||
<CommentEditor
|
comment={{ threadId: postId }}
|
||||||
comment={{ threadId: postId }}
|
edit={false}
|
||||||
edit={false}
|
onCloseEditor={handleCloseEditor}
|
||||||
onCloseEditor={handleCloseEditor}
|
/>
|
||||||
/>
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
};
|
||||||
}
|
|
||||||
|
|
||||||
ResponseEditor.propTypes = {
|
ResponseEditor.propTypes = {
|
||||||
postId: PropTypes.string.isRequired,
|
|
||||||
addWrappingDiv: PropTypes.bool,
|
addWrappingDiv: PropTypes.bool,
|
||||||
handleCloseEditor: PropTypes.func.isRequired,
|
handleCloseEditor: PropTypes.func.isRequired,
|
||||||
addingResponse: PropTypes.bool.isRequired,
|
addingResponse: PropTypes.bool.isRequired,
|
||||||
@@ -40,4 +38,4 @@ ResponseEditor.defaultProps = {
|
|||||||
addWrappingDiv: false,
|
addWrappingDiv: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default injectIntl(ResponseEditor);
|
export default React.memo(ResponseEditor);
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export async function getThreadComments(
|
|||||||
pageSize,
|
pageSize,
|
||||||
reverseOrder,
|
reverseOrder,
|
||||||
enableInContextSidebar = false,
|
enableInContextSidebar = false,
|
||||||
|
signal,
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
const params = snakeCaseObject({
|
const params = snakeCaseObject({
|
||||||
@@ -40,7 +41,7 @@ export async function getThreadComments(
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { data } = await getAuthenticatedHttpClient()
|
const { data } = await getAuthenticatedHttpClient()
|
||||||
.get(getCommentsApiUrl(), { params });
|
.get(getCommentsApiUrl(), { params: { ...params, signal } });
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { useContext, useEffect } from 'react';
|
import {
|
||||||
|
useCallback, useContext, useEffect, useMemo,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
|
||||||
@@ -9,20 +11,21 @@ import { useDispatchWithState } from '../../../data/hooks';
|
|||||||
import { DiscussionContext } from '../../common/context';
|
import { DiscussionContext } from '../../common/context';
|
||||||
import { selectThread } from '../../posts/data/selectors';
|
import { selectThread } from '../../posts/data/selectors';
|
||||||
import { markThreadAsRead } from '../../posts/data/thunks';
|
import { markThreadAsRead } from '../../posts/data/thunks';
|
||||||
|
import { filterPosts } from '../../utils';
|
||||||
import {
|
import {
|
||||||
selectCommentSortOrder, selectThreadComments, selectThreadCurrentPage, selectThreadHasMorePages,
|
selectCommentSortOrder, selectThreadComments, selectThreadCurrentPage, selectThreadHasMorePages,
|
||||||
} from './selectors';
|
} from './selectors';
|
||||||
import { fetchThreadComments } from './thunks';
|
import { fetchThreadComments } from './thunks';
|
||||||
|
|
||||||
function trackLoadMoreEvent(postId, params) {
|
const trackLoadMoreEvent = (postId, params) => (
|
||||||
sendTrackEvent(
|
sendTrackEvent(
|
||||||
'edx.forum.responses.loadMore',
|
'edx.forum.responses.loadMore',
|
||||||
{
|
{
|
||||||
postId,
|
postId,
|
||||||
params,
|
params,
|
||||||
},
|
},
|
||||||
);
|
)
|
||||||
}
|
);
|
||||||
|
|
||||||
export function usePost(postId) {
|
export function usePost(postId) {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
@@ -34,18 +37,26 @@ export function usePost(postId) {
|
|||||||
}
|
}
|
||||||
}, [postId]);
|
}, [postId]);
|
||||||
|
|
||||||
return thread;
|
return thread || {};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePostComments(postId, endorsed = null) {
|
export function usePostComments(endorsed = null) {
|
||||||
|
const { enableInContextSidebar, postId } = useContext(DiscussionContext);
|
||||||
const [isLoading, dispatch] = useDispatchWithState();
|
const [isLoading, dispatch] = useDispatchWithState();
|
||||||
const comments = useSelector(selectThreadComments(postId, endorsed));
|
const comments = useSelector(selectThreadComments(postId, endorsed));
|
||||||
const reverseOrder = useSelector(selectCommentSortOrder);
|
const reverseOrder = useSelector(selectCommentSortOrder);
|
||||||
const hasMorePages = useSelector(selectThreadHasMorePages(postId, endorsed));
|
const hasMorePages = useSelector(selectThreadHasMorePages(postId, endorsed));
|
||||||
const currentPage = useSelector(selectThreadCurrentPage(postId, endorsed));
|
const currentPage = useSelector(selectThreadCurrentPage(postId, endorsed));
|
||||||
const { enableInContextSidebar } = useContext(DiscussionContext);
|
|
||||||
|
|
||||||
const handleLoadMoreResponses = async () => {
|
const endorsedCommentsIds = useMemo(() => (
|
||||||
|
[...filterPosts(comments, 'endorsed')].map(comment => comment.id)
|
||||||
|
), [comments]);
|
||||||
|
|
||||||
|
const unEndorsedCommentsIds = useMemo(() => (
|
||||||
|
[...filterPosts(comments, 'unendorsed')].map(comment => comment.id)
|
||||||
|
), [comments]);
|
||||||
|
|
||||||
|
const handleLoadMoreResponses = useCallback(async () => {
|
||||||
const params = {
|
const params = {
|
||||||
endorsed,
|
endorsed,
|
||||||
page: currentPage + 1,
|
page: currentPage + 1,
|
||||||
@@ -53,19 +64,27 @@ export function usePostComments(postId, endorsed = null) {
|
|||||||
};
|
};
|
||||||
await dispatch(fetchThreadComments(postId, params));
|
await dispatch(fetchThreadComments(postId, params));
|
||||||
trackLoadMoreEvent(postId, params);
|
trackLoadMoreEvent(postId, params);
|
||||||
};
|
}, [currentPage, endorsed, postId, reverseOrder]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const abortController = new AbortController();
|
||||||
|
|
||||||
dispatch(fetchThreadComments(postId, {
|
dispatch(fetchThreadComments(postId, {
|
||||||
endorsed,
|
endorsed,
|
||||||
page: 1,
|
page: 1,
|
||||||
reverseOrder,
|
reverseOrder,
|
||||||
enableInContextSidebar,
|
enableInContextSidebar,
|
||||||
|
signal: abortController.signal,
|
||||||
}));
|
}));
|
||||||
}, [postId, reverseOrder]);
|
|
||||||
|
return () => {
|
||||||
|
abortController.abort();
|
||||||
|
};
|
||||||
|
}, [postId, endorsed, reverseOrder, enableInContextSidebar]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
comments,
|
endorsedCommentsIds,
|
||||||
|
unEndorsedCommentsIds,
|
||||||
hasMorePages,
|
hasMorePages,
|
||||||
isLoading,
|
isLoading,
|
||||||
handleLoadMoreResponses,
|
handleLoadMoreResponses,
|
||||||
@@ -77,5 +96,9 @@ export function useCommentsCount(postId) {
|
|||||||
const endorsedQuestions = useSelector(selectThreadComments(postId, EndorsementStatus.ENDORSED));
|
const endorsedQuestions = useSelector(selectThreadComments(postId, EndorsementStatus.ENDORSED));
|
||||||
const unendorsedQuestions = useSelector(selectThreadComments(postId, EndorsementStatus.UNENDORSED));
|
const unendorsedQuestions = useSelector(selectThreadComments(postId, EndorsementStatus.UNENDORSED));
|
||||||
|
|
||||||
return [...discussions, ...endorsedQuestions, ...unendorsedQuestions].length;
|
const commentsLength = useMemo(() => (
|
||||||
|
[...discussions, ...endorsedQuestions, ...unendorsedQuestions].length
|
||||||
|
), [discussions, endorsedQuestions, unendorsedQuestions]);
|
||||||
|
|
||||||
|
return commentsLength;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ import { createSelector } from '@reduxjs/toolkit';
|
|||||||
const selectCommentsById = state => state.comments.commentsById;
|
const selectCommentsById = state => state.comments.commentsById;
|
||||||
const mapIdToComment = (ids, comments) => ids.map(id => comments[id]);
|
const mapIdToComment = (ids, comments) => ids.map(id => comments[id]);
|
||||||
|
|
||||||
|
export const selectCommentOrResponseById = commentOrResponseId => createSelector(
|
||||||
|
selectCommentsById,
|
||||||
|
comments => comments[commentOrResponseId],
|
||||||
|
);
|
||||||
|
|
||||||
export const selectThreadComments = (threadId, endorsed = null) => createSelector(
|
export const selectThreadComments = (threadId, endorsed = null) => createSelector(
|
||||||
[
|
[
|
||||||
state => state.comments.commentsInThreads[threadId]?.[endorsed] || [],
|
state => state.comments.commentsInThreads[threadId]?.[endorsed] || [],
|
||||||
@@ -12,6 +17,10 @@ export const selectThreadComments = (threadId, endorsed = null) => createSelecto
|
|||||||
mapIdToComment,
|
mapIdToComment,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const selectCommentResponsesIds = commentId => (
|
||||||
|
state => state.comments.commentsInComments[commentId] || []
|
||||||
|
);
|
||||||
|
|
||||||
export const selectCommentResponses = commentId => createSelector(
|
export const selectCommentResponses = commentId => createSelector(
|
||||||
[
|
[
|
||||||
state => state.comments.commentsInComments[commentId] || [],
|
state => state.comments.commentsInComments[commentId] || [],
|
||||||
|
|||||||
@@ -81,13 +81,14 @@ export function fetchThreadComments(
|
|||||||
reverseOrder,
|
reverseOrder,
|
||||||
endorsed = EndorsementStatus.DISCUSSION,
|
endorsed = EndorsementStatus.DISCUSSION,
|
||||||
enableInContextSidebar,
|
enableInContextSidebar,
|
||||||
|
signal,
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
return async (dispatch) => {
|
return async (dispatch) => {
|
||||||
try {
|
try {
|
||||||
dispatch(fetchCommentsRequest());
|
dispatch(fetchCommentsRequest());
|
||||||
const data = await getThreadComments(threadId, {
|
const data = await getThreadComments(threadId, {
|
||||||
page, reverseOrder, endorsed, enableInContextSidebar,
|
page, reverseOrder, endorsed, enableInContextSidebar, signal,
|
||||||
});
|
});
|
||||||
dispatch(fetchCommentsSuccess({
|
dispatch(fetchCommentsSuccess({
|
||||||
...normaliseComments(camelCaseObject(data)),
|
...normaliseComments(camelCaseObject(data)),
|
||||||
|
|||||||
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 classNames from 'classnames';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
|
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
import { selectAreThreadsFiltered } from '../data/selectors';
|
import { selectAreThreadsFiltered } from '../data/selectors';
|
||||||
import { selectTopicFilter } from '../in-context-topics/data/selectors';
|
import { selectTopicFilter } from '../in-context-topics/data/selectors';
|
||||||
import messages from '../messages';
|
import messages from '../messages';
|
||||||
|
|
||||||
function NoResults({ intl }) {
|
const NoResults = () => {
|
||||||
|
const intl = useIntl();
|
||||||
const postsFiltered = useSelector(selectAreThreadsFiltered);
|
const postsFiltered = useSelector(selectAreThreadsFiltered);
|
||||||
const inContextTopicsFilter = useSelector(selectTopicFilter);
|
const inContextTopicsFilter = useSelector(selectTopicFilter);
|
||||||
const topicsFilter = useSelector(({ topics }) => topics.filter);
|
const topicsFilter = useSelector(({ topics }) => topics.filter);
|
||||||
@@ -17,6 +20,7 @@ function NoResults({ intl }) {
|
|||||||
|| (learnersFilter !== null) || (inContextTopicsFilter !== '');
|
|| (learnersFilter !== null) || (inContextTopicsFilter !== '');
|
||||||
|
|
||||||
let helpMessage = messages.removeFilters;
|
let helpMessage = messages.removeFilters;
|
||||||
|
|
||||||
if (!isFiltered) {
|
if (!isFiltered) {
|
||||||
return null;
|
return null;
|
||||||
} if (filters.search || learnersFilter) {
|
} if (filters.search || learnersFilter) {
|
||||||
@@ -24,6 +28,7 @@ function NoResults({ intl }) {
|
|||||||
} if (topicsFilter || inContextTopicsFilter) {
|
} if (topicsFilter || inContextTopicsFilter) {
|
||||||
helpMessage = messages.removeKeywordsOnly;
|
helpMessage = messages.removeKeywordsOnly;
|
||||||
}
|
}
|
||||||
|
|
||||||
const titleCssClasses = classNames(
|
const titleCssClasses = classNames(
|
||||||
{ 'font-weight-normal text-primary-500': topicsFilter || learnersFilter },
|
{ 'font-weight-normal text-primary-500': topicsFilter || learnersFilter },
|
||||||
);
|
);
|
||||||
@@ -37,10 +42,6 @@ function NoResults({ intl }) {
|
|||||||
<small className={textCssClasses}>{intl.formatMessage(helpMessage)}</small>
|
<small className={textCssClasses}>{intl.formatMessage(helpMessage)}</small>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
NoResults.propTypes = {
|
|
||||||
intl: intlShape.isRequired,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default injectIntl(NoResults);
|
export default NoResults;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import PropTypes from 'prop-types';
|
|||||||
|
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import { AppContext } from '@edx/frontend-platform/react';
|
import { AppContext } from '@edx/frontend-platform/react';
|
||||||
import { Button, Spinner } from '@edx/paragon';
|
import { Button, Spinner } from '@edx/paragon';
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ import { DiscussionContext } from '../common/context';
|
|||||||
import { selectconfigLoadingStatus, selectUserHasModerationPrivileges, selectUserIsStaff } from '../data/selectors';
|
import { selectconfigLoadingStatus, selectUserHasModerationPrivileges, selectUserIsStaff } from '../data/selectors';
|
||||||
import { fetchUserPosts } from '../learners/data/thunks';
|
import { fetchUserPosts } from '../learners/data/thunks';
|
||||||
import messages from '../messages';
|
import messages from '../messages';
|
||||||
import { filterPosts } from '../utils';
|
import { usePostList } from './data/hooks';
|
||||||
import {
|
import {
|
||||||
selectThreadFilters, selectThreadNextPage, selectThreadSorting, threadsLoadingStatus,
|
selectThreadFilters, selectThreadNextPage, selectThreadSorting, threadsLoadingStatus,
|
||||||
} from './data/selectors';
|
} from './data/selectors';
|
||||||
@@ -22,25 +22,24 @@ import { fetchThreads } from './data/thunks';
|
|||||||
import NoResults from './NoResults';
|
import NoResults from './NoResults';
|
||||||
import { PostLink } from './post';
|
import { PostLink } from './post';
|
||||||
|
|
||||||
function PostsList({
|
const PostsList = ({
|
||||||
posts, topics, intl, isTopicTab, parentIsLoading,
|
postsIds, topicsIds, isTopicTab, parentIsLoading,
|
||||||
}) {
|
}) => {
|
||||||
|
const intl = useIntl();
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const {
|
|
||||||
courseId,
|
|
||||||
page,
|
|
||||||
} = useContext(DiscussionContext);
|
|
||||||
const loadingStatus = useSelector(threadsLoadingStatus());
|
|
||||||
const { authenticatedUser } = useContext(AppContext);
|
const { authenticatedUser } = useContext(AppContext);
|
||||||
|
const { courseId, page } = useContext(DiscussionContext);
|
||||||
|
const loadingStatus = useSelector(threadsLoadingStatus());
|
||||||
const orderBy = useSelector(selectThreadSorting());
|
const orderBy = useSelector(selectThreadSorting());
|
||||||
const filters = useSelector(selectThreadFilters());
|
const filters = useSelector(selectThreadFilters());
|
||||||
const nextPage = useSelector(selectThreadNextPage());
|
const nextPage = useSelector(selectThreadNextPage());
|
||||||
const showOwnPosts = page === 'my-posts';
|
|
||||||
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
|
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
|
||||||
const userIsStaff = useSelector(selectUserIsStaff);
|
const userIsStaff = useSelector(selectUserIsStaff);
|
||||||
const configStatus = useSelector(selectconfigLoadingStatus);
|
const configStatus = useSelector(selectconfigLoadingStatus);
|
||||||
|
const sortedPostsIds = usePostList(postsIds);
|
||||||
|
const showOwnPosts = page === 'my-posts';
|
||||||
|
|
||||||
const loadThreads = (topicIds, pageNum = undefined, isFilterChanged = false) => {
|
const loadThreads = useCallback((topicIds, pageNum = undefined, isFilterChanged = false) => {
|
||||||
const params = {
|
const params = {
|
||||||
orderBy,
|
orderBy,
|
||||||
filters,
|
filters,
|
||||||
@@ -50,75 +49,68 @@ function PostsList({
|
|||||||
topicIds,
|
topicIds,
|
||||||
isFilterChanged,
|
isFilterChanged,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (showOwnPosts && filters.search === '') {
|
if (showOwnPosts && filters.search === '') {
|
||||||
dispatch(fetchUserPosts(courseId, params));
|
dispatch(fetchUserPosts(courseId, params));
|
||||||
} else {
|
} else {
|
||||||
dispatch(fetchThreads(courseId, params));
|
dispatch(fetchThreads(courseId, params));
|
||||||
}
|
}
|
||||||
};
|
}, [courseId, orderBy, filters, showOwnPosts, authenticatedUser.username, userHasModerationPrivileges, userIsStaff]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (topics !== undefined && configStatus === RequestStatus.SUCCESSFUL) {
|
if (topicsIds !== undefined && configStatus === RequestStatus.SUCCESSFUL) {
|
||||||
loadThreads(topics);
|
loadThreads(topicsIds);
|
||||||
}
|
}
|
||||||
}, [courseId, filters, orderBy, page, JSON.stringify(topics), configStatus]);
|
}, [courseId, filters, orderBy, page, JSON.stringify(topicsIds), configStatus]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isTopicTab) { loadThreads(topics, 1, true); }
|
if (isTopicTab) {
|
||||||
|
loadThreads(topicsIds, 1, true);
|
||||||
|
}
|
||||||
}, [filters]);
|
}, [filters]);
|
||||||
|
|
||||||
const checkIsSelected = (id) => window.location.pathname.includes(id);
|
const postInstances = useMemo(() => (
|
||||||
const pinnedPosts = useMemo(() => filterPosts(posts, 'pinned'), [posts]);
|
sortedPostsIds?.map((postId, idx) => (
|
||||||
const unpinnedPosts = useMemo(() => filterPosts(posts, 'unpinned'), [posts]);
|
|
||||||
|
|
||||||
const postInstances = useCallback((sortedPosts) => (
|
|
||||||
sortedPosts.map((post, idx) => (
|
|
||||||
<PostLink
|
<PostLink
|
||||||
post={post}
|
postId={postId}
|
||||||
key={post.id}
|
|
||||||
isSelected={checkIsSelected}
|
|
||||||
idx={idx}
|
idx={idx}
|
||||||
showDivider={(sortedPosts.length - 1) !== idx}
|
key={postId}
|
||||||
|
showDivider={(sortedPostsIds.length - 1) !== idx}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
), []);
|
), [sortedPostsIds]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{!parentIsLoading && postInstances(pinnedPosts)}
|
{!parentIsLoading && postInstances}
|
||||||
{!parentIsLoading && postInstances(unpinnedPosts)}
|
{sortedPostsIds?.length === 0 && loadingStatus === RequestStatus.SUCCESSFUL && <NoResults />}
|
||||||
{posts?.length === 0 && loadingStatus === RequestStatus.SUCCESSFUL && <NoResults />}
|
|
||||||
{loadingStatus === RequestStatus.IN_PROGRESS || parentIsLoading ? (
|
{loadingStatus === RequestStatus.IN_PROGRESS || parentIsLoading ? (
|
||||||
<div className="d-flex justify-content-center p-4 mx-auto my-auto">
|
<div className="d-flex justify-content-center p-4 mx-auto my-auto">
|
||||||
<Spinner animation="border" variant="primary" size="lg" />
|
<Spinner animation="border" variant="primary" size="lg" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
nextPage && loadingStatus === RequestStatus.SUCCESSFUL && (
|
nextPage && loadingStatus === RequestStatus.SUCCESSFUL && (
|
||||||
<Button onClick={() => loadThreads(topics, nextPage)} variant="primary" size="md">
|
<Button onClick={() => loadThreads(topicsIds, nextPage)} variant="primary" size="md">
|
||||||
{intl.formatMessage(messages.loadMorePosts)}
|
{intl.formatMessage(messages.loadMorePosts)}
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
PostsList.propTypes = {
|
PostsList.propTypes = {
|
||||||
posts: PropTypes.arrayOf(PropTypes.shape({
|
postsIds: PropTypes.arrayOf(PropTypes.string),
|
||||||
pinned: PropTypes.bool.isRequired,
|
topicsIds: PropTypes.arrayOf(PropTypes.string),
|
||||||
id: PropTypes.string.isRequired,
|
|
||||||
})),
|
|
||||||
topics: PropTypes.arrayOf(PropTypes.string),
|
|
||||||
isTopicTab: PropTypes.bool,
|
isTopicTab: PropTypes.bool,
|
||||||
parentIsLoading: PropTypes.bool,
|
parentIsLoading: PropTypes.bool,
|
||||||
intl: intlShape.isRequired,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
PostsList.defaultProps = {
|
PostsList.defaultProps = {
|
||||||
posts: [],
|
postsIds: [],
|
||||||
topics: undefined,
|
topicsIds: undefined,
|
||||||
isTopicTab: false,
|
isTopicTab: false,
|
||||||
parentIsLoading: undefined,
|
parentIsLoading: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default injectIntl(PostsList);
|
export default React.memo(PostsList);
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import React, { useContext, useEffect } from 'react';
|
import React, {
|
||||||
|
useCallback, useContext, useEffect, useMemo,
|
||||||
|
} from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
import isEmpty from 'lodash/isEmpty';
|
import isEmpty from 'lodash/isEmpty';
|
||||||
@@ -13,39 +15,42 @@ import { fetchCourseTopicsV3 } from '../in-context-topics/data/thunks';
|
|||||||
import { selectTopics } from '../topics/data/selectors';
|
import { selectTopics } from '../topics/data/selectors';
|
||||||
import { fetchCourseTopics } from '../topics/data/thunks';
|
import { fetchCourseTopics } from '../topics/data/thunks';
|
||||||
import { handleKeyDown } from '../utils';
|
import { handleKeyDown } from '../utils';
|
||||||
import { selectAllThreads, selectTopicThreads } from './data/selectors';
|
import { selectAllThreadsIds, selectTopicThreadsIds } from './data/selectors';
|
||||||
import { setSearchQuery } from './data/slices';
|
import { setSearchQuery } from './data/slices';
|
||||||
import PostFilterBar from './post-filter-bar/PostFilterBar';
|
import PostFilterBar from './post-filter-bar/PostFilterBar';
|
||||||
import PostsList from './PostsList';
|
import PostsList from './PostsList';
|
||||||
|
|
||||||
function AllPostsList() {
|
const AllPostsList = () => {
|
||||||
const posts = useSelector(selectAllThreads);
|
const postsIds = useSelector(selectAllThreadsIds);
|
||||||
return <PostsList posts={posts} topics={null} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function TopicPostsList({ topicId }) {
|
return <PostsList postsIds={postsIds} topicsIds={null} />;
|
||||||
const posts = useSelector(selectTopicThreads([topicId]));
|
};
|
||||||
return <PostsList posts={posts} topics={[topicId]} isTopicTab />;
|
|
||||||
}
|
const TopicPostsList = React.memo(({ topicId }) => {
|
||||||
|
const postsIds = useSelector(selectTopicThreadsIds([topicId]));
|
||||||
|
|
||||||
|
return <PostsList postsIds={postsIds} topicsIds={[topicId]} isTopicTab />;
|
||||||
|
});
|
||||||
|
|
||||||
TopicPostsList.propTypes = {
|
TopicPostsList.propTypes = {
|
||||||
topicId: PropTypes.string.isRequired,
|
topicId: PropTypes.string.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
function CategoryPostsList({ category }) {
|
const CategoryPostsList = React.memo(({ category }) => {
|
||||||
const { enableInContextSidebar } = useContext(DiscussionContext);
|
const { enableInContextSidebar } = useContext(DiscussionContext);
|
||||||
const groupedCategory = useSelector(selectCurrentCategoryGrouping)(category);
|
const groupedCategory = useSelector(selectCurrentCategoryGrouping)(category);
|
||||||
// If grouping at subsection is enabled, only apply it when browsing discussions in context in the learning MFE.
|
// If grouping at subsection is enabled, only apply it when browsing discussions in context in the learning MFE.
|
||||||
const topicIds = useSelector(selectTopicsUnderCategory)(enableInContextSidebar ? groupedCategory : category);
|
const topicIds = useSelector(selectTopicsUnderCategory)(enableInContextSidebar ? groupedCategory : category);
|
||||||
const posts = useSelector(enableInContextSidebar ? selectAllThreads : selectTopicThreads(topicIds));
|
const postsIds = useSelector(enableInContextSidebar ? selectAllThreadsIds : selectTopicThreadsIds(topicIds));
|
||||||
return <PostsList posts={posts} topics={topicIds} />;
|
|
||||||
}
|
return <PostsList postsIds={postsIds} topicsIds={topicIds} />;
|
||||||
|
});
|
||||||
|
|
||||||
CategoryPostsList.propTypes = {
|
CategoryPostsList.propTypes = {
|
||||||
category: PropTypes.string.isRequired,
|
category: PropTypes.string.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
function PostsView() {
|
const PostsView = () => {
|
||||||
const {
|
const {
|
||||||
topicId,
|
topicId,
|
||||||
category,
|
category,
|
||||||
@@ -68,15 +73,19 @@ function PostsView() {
|
|||||||
}
|
}
|
||||||
}, [topics]);
|
}, [topics]);
|
||||||
|
|
||||||
let postsListComponent;
|
const handleOnClear = useCallback(() => {
|
||||||
|
dispatch(setSearchQuery(''));
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (topicId) {
|
const postsListComponent = useMemo(() => {
|
||||||
postsListComponent = <TopicPostsList topicId={topicId} />;
|
if (topicId) {
|
||||||
} else if (category) {
|
return <TopicPostsList topicId={topicId} />;
|
||||||
postsListComponent = <CategoryPostsList category={category} />;
|
}
|
||||||
} else {
|
if (category) {
|
||||||
postsListComponent = <AllPostsList />;
|
return <CategoryPostsList category={category} />;
|
||||||
}
|
}
|
||||||
|
return <AllPostsList />;
|
||||||
|
}, [topicId, category]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="discussion-posts d-flex flex-column h-100">
|
<div className="discussion-posts d-flex flex-column h-100">
|
||||||
@@ -85,7 +94,7 @@ function PostsView() {
|
|||||||
count={resultsFound}
|
count={resultsFound}
|
||||||
text={searchString}
|
text={searchString}
|
||||||
loadingStatus={loadingStatus}
|
loadingStatus={loadingStatus}
|
||||||
onClear={() => dispatch(setSearchQuery(''))}
|
onClear={handleOnClear}
|
||||||
textSearchRewrite={textSearchRewrite}
|
textSearchRewrite={textSearchRewrite}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -96,9 +105,6 @@ function PostsView() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
PostsView.propTypes = {
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default PostsView;
|
export default PostsView;
|
||||||
|
|||||||
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,
|
mapIdsToThreads,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const selectTopicThreadsIds = topicIds => state => (
|
||||||
|
(topicIds || []).flatMap(topicId => state.threads.threadsInTopic[topicId] || [])
|
||||||
|
);
|
||||||
|
|
||||||
|
export const selectThreadsByIds = ids => createSelector(
|
||||||
|
[selectThreads],
|
||||||
|
(threads) => mapIdsToThreads(ids, threads),
|
||||||
|
);
|
||||||
|
|
||||||
export const selectThread = threadId => createSelector(
|
export const selectThread = threadId => createSelector(
|
||||||
[selectThreads],
|
[selectThreads],
|
||||||
(threads) => threads?.[threadId],
|
(threads) => threads?.[threadId],
|
||||||
@@ -37,6 +46,11 @@ export const selectAllThreads = createSelector(
|
|||||||
(pages, threads) => pages.flatMap(ids => mapIdsToThreads(ids, threads)),
|
(pages, threads) => pages.flatMap(ids => mapIdsToThreads(ids, threads)),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const selectAllThreadsIds = createSelector(
|
||||||
|
[state => state.threads.pages],
|
||||||
|
pages => pages.flatMap(ids => ids),
|
||||||
|
);
|
||||||
|
|
||||||
export const threadsLoadingStatus = () => state => state.threads.status;
|
export const threadsLoadingStatus = () => state => state.threads.status;
|
||||||
|
|
||||||
export const selectThreadSorting = () => state => state.threads.sortedBy;
|
export const selectThreadSorting = () => state => state.threads.sortedBy;
|
||||||
|
|||||||
@@ -15,7 +15,10 @@ const mergeThreadsInTopics = (dataFromState, dataFromPayload) => {
|
|||||||
const values = Object.values(obj);
|
const values = Object.values(obj);
|
||||||
keys.forEach((key, index) => {
|
keys.forEach((key, index) => {
|
||||||
if (!acc[key]) { acc[key] = []; }
|
if (!acc[key]) { acc[key] = []; }
|
||||||
if (Array.isArray(acc[key])) { acc[key] = acc[key].concat(values[index]); } else { acc[key].push(values[index]); }
|
if (Array.isArray(acc[key])) {
|
||||||
|
const uniqueValues = [...new Set(acc[key].concat(values[index]))];
|
||||||
|
acc[key] = uniqueValues;
|
||||||
|
} else { acc[key].push(values[index]); }
|
||||||
return acc;
|
return acc;
|
||||||
});
|
});
|
||||||
return acc;
|
return acc;
|
||||||
@@ -68,29 +71,29 @@ const threadsSlice = createSlice({
|
|||||||
state.status = RequestStatus.IN_PROGRESS;
|
state.status = RequestStatus.IN_PROGRESS;
|
||||||
},
|
},
|
||||||
fetchThreadsSuccess: (state, { payload }) => {
|
fetchThreadsSuccess: (state, { payload }) => {
|
||||||
if (state.author !== payload.author) {
|
const {
|
||||||
|
author, page, ids, threadsById, isFilterChanged, threadsInTopic, avatars, pagination, textSearchRewrite,
|
||||||
|
} = payload;
|
||||||
|
|
||||||
|
if (state.author !== author) {
|
||||||
state.pages = [];
|
state.pages = [];
|
||||||
state.author = payload.author;
|
state.author = author;
|
||||||
}
|
}
|
||||||
if (state.pages[payload.page - 1]) {
|
if (!state.pages[page - 1]) {
|
||||||
state.pages[payload.page - 1] = [...state.pages[payload.page - 1], ...payload.ids];
|
state.pages[page - 1] = ids;
|
||||||
} else {
|
} else {
|
||||||
state.pages[payload.page - 1] = payload.ids;
|
state.pages[page - 1] = [...new Set([...state.pages[page - 1], ...ids])];
|
||||||
}
|
}
|
||||||
state.status = RequestStatus.SUCCESSFUL;
|
state.status = RequestStatus.SUCCESSFUL;
|
||||||
state.threadsById = { ...state.threadsById, ...payload.threadsById };
|
state.threadsById = { ...state.threadsById, ...threadsById };
|
||||||
// filter
|
state.threadsInTopic = (isFilterChanged || page === 1)
|
||||||
if (payload.isFilterChanged) {
|
? { ...threadsInTopic }
|
||||||
state.threadsInTopic = { ...payload.threadsInTopic };
|
: mergeThreadsInTopics(state.threadsInTopic, threadsInTopic);
|
||||||
} else {
|
state.avatars = { ...state.avatars, ...avatars };
|
||||||
state.threadsInTopic = mergeThreadsInTopics(state.threadsInTopic, payload.threadsInTopic);
|
state.nextPage = (page < pagination.numPages) ? page + 1 : null;
|
||||||
}
|
state.totalPages = pagination.numPages;
|
||||||
|
state.totalThreads = pagination.count;
|
||||||
state.avatars = { ...state.avatars, ...payload.avatars };
|
state.textSearchRewrite = textSearchRewrite;
|
||||||
state.nextPage = (payload.page < payload.pagination.numPages) ? payload.page + 1 : null;
|
|
||||||
state.totalPages = payload.pagination.numPages;
|
|
||||||
state.totalThreads = payload.pagination.count;
|
|
||||||
state.textSearchRewrite = payload.textSearchRewrite;
|
|
||||||
},
|
},
|
||||||
fetchThreadsFailed: (state) => {
|
fetchThreadsFailed: (state) => {
|
||||||
state.status = RequestStatus.FAILED;
|
state.status = RequestStatus.FAILED;
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import React, { useContext } from 'react';
|
import React, { useCallback, useContext } from 'react';
|
||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import {
|
import {
|
||||||
Button, Icon, IconButton,
|
Button, Icon, IconButton,
|
||||||
} from '@edx/paragon';
|
} from '@edx/paragon';
|
||||||
@@ -21,18 +21,21 @@ import messages from './messages';
|
|||||||
|
|
||||||
import './actionBar.scss';
|
import './actionBar.scss';
|
||||||
|
|
||||||
function PostActionsBar({
|
const PostActionsBar = () => {
|
||||||
intl,
|
const intl = useIntl();
|
||||||
}) {
|
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const loadingStatus = useSelector(selectconfigLoadingStatus);
|
const loadingStatus = useSelector(selectconfigLoadingStatus);
|
||||||
const enableInContext = useSelector(selectEnableInContext);
|
const enableInContext = useSelector(selectEnableInContext);
|
||||||
const userCanAddThreadInBlackoutDate = useUserCanAddThreadInBlackoutDate();
|
const userCanAddThreadInBlackoutDate = useUserCanAddThreadInBlackoutDate();
|
||||||
const { enableInContextSidebar, page } = useContext(DiscussionContext);
|
const { enableInContextSidebar, page } = useContext(DiscussionContext);
|
||||||
|
|
||||||
const handleCloseInContext = () => {
|
const handleCloseInContext = useCallback(() => {
|
||||||
postMessageToParent('learning.events.sidebar.close');
|
postMessageToParent('learning.events.sidebar.close');
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
|
const handleAddPost = useCallback(() => {
|
||||||
|
dispatch(showPostEditor());
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classNames('d-flex justify-content-end flex-grow-1', { 'py-1': !enableInContextSidebar })}>
|
<div className={classNames('d-flex justify-content-end flex-grow-1', { 'py-1': !enableInContextSidebar })}>
|
||||||
@@ -53,7 +56,7 @@ function PostActionsBar({
|
|||||||
variant={enableInContextSidebar ? 'plain' : 'brand'}
|
variant={enableInContextSidebar ? 'plain' : 'brand'}
|
||||||
className={classNames('my-0 font-style border-0 line-height-24',
|
className={classNames('my-0 font-style border-0 line-height-24',
|
||||||
{ 'px-3 py-10px border-0': enableInContextSidebar })}
|
{ 'px-3 py-10px border-0': enableInContextSidebar })}
|
||||||
onClick={() => dispatch(showPostEditor())}
|
onClick={handleAddPost}
|
||||||
size={enableInContextSidebar ? 'md' : 'sm'}
|
size={enableInContextSidebar ? 'md' : 'sm'}
|
||||||
>
|
>
|
||||||
{intl.formatMessage(messages.addAPost)}
|
{intl.formatMessage(messages.addAPost)}
|
||||||
@@ -77,10 +80,6 @@ function PostActionsBar({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
PostActionsBar.propTypes = {
|
|
||||||
intl: intlShape.isRequired,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default injectIntl(PostActionsBar);
|
export default PostActionsBar;
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import React, {
|
import React, {
|
||||||
useContext, useEffect, useRef,
|
useCallback, useContext, useEffect, useRef,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
import classNames from 'classnames';
|
|
||||||
import { Formik } from 'formik';
|
import { Formik } from 'formik';
|
||||||
import { isEmpty } from 'lodash';
|
import { isEmpty } from 'lodash';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
@@ -13,7 +12,7 @@ import * as Yup from 'yup';
|
|||||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import { AppContext } from '@edx/frontend-platform/react';
|
import { AppContext } from '@edx/frontend-platform/react';
|
||||||
import {
|
import {
|
||||||
Button, Card, Form, Spinner, StatefulButton,
|
Button, Form, Spinner, StatefulButton,
|
||||||
} from '@edx/paragon';
|
} from '@edx/paragon';
|
||||||
import { Help, Post } from '@edx/paragon/icons';
|
import { Help, Post } from '@edx/paragon/icons';
|
||||||
|
|
||||||
@@ -49,58 +48,22 @@ import { hidePostEditor } from '../data';
|
|||||||
import { selectThread } from '../data/selectors';
|
import { selectThread } from '../data/selectors';
|
||||||
import { createNewThread, fetchThread, updateExistingThread } from '../data/thunks';
|
import { createNewThread, fetchThread, updateExistingThread } from '../data/thunks';
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
|
import PostTypeCard from './PostTypeCard';
|
||||||
|
|
||||||
function DiscussionPostType({
|
const PostEditor = ({
|
||||||
value,
|
|
||||||
type,
|
|
||||||
selected,
|
|
||||||
icon,
|
|
||||||
}) {
|
|
||||||
const { enableInContextSidebar } = useContext(DiscussionContext);
|
|
||||||
// Need to use regular label since Form.Label doesn't support overriding htmlFor
|
|
||||||
return (
|
|
||||||
<label htmlFor={`post-type-${value}`} className="d-flex p-0 my-0 mr-3">
|
|
||||||
<Form.Radio value={value} id={`post-type-${value}`} className="sr-only">{type}</Form.Radio>
|
|
||||||
<Card
|
|
||||||
className={classNames('border-2 shadow-none', {
|
|
||||||
'border-primary': selected,
|
|
||||||
'border-light-400': !selected,
|
|
||||||
})}
|
|
||||||
style={{ cursor: 'pointer', width: `${enableInContextSidebar ? '10.021rem' : '14.25rem'}` }}
|
|
||||||
>
|
|
||||||
<Card.Section className="px-4 py-3 d-flex flex-column align-items-center">
|
|
||||||
<span className="text-primary-300 mb-0.5">{icon}</span>
|
|
||||||
<span className="text-gray-700">{type}</span>
|
|
||||||
</Card.Section>
|
|
||||||
</Card>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
DiscussionPostType.propTypes = {
|
|
||||||
value: PropTypes.string.isRequired,
|
|
||||||
type: PropTypes.string.isRequired,
|
|
||||||
selected: PropTypes.bool.isRequired,
|
|
||||||
icon: PropTypes.element.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
function PostEditor({
|
|
||||||
editExisting,
|
editExisting,
|
||||||
}) {
|
}) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const { authenticatedUser } = useContext(AppContext);
|
|
||||||
const dispatch = useDispatch();
|
|
||||||
const editorRef = useRef(null);
|
|
||||||
const [submitting, dispatchSubmit] = useDispatchWithState();
|
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const commentsPagePath = useCommentsPagePath();
|
const dispatch = useDispatch();
|
||||||
const {
|
const editorRef = useRef(null);
|
||||||
courseId,
|
const { courseId, postId } = useParams();
|
||||||
postId,
|
const { authenticatedUser } = useContext(AppContext);
|
||||||
} = useParams();
|
|
||||||
const { category, enableInContextSidebar } = useContext(DiscussionContext);
|
const { category, enableInContextSidebar } = useContext(DiscussionContext);
|
||||||
const topicId = useCurrentDiscussionTopic();
|
const topicId = useCurrentDiscussionTopic();
|
||||||
|
const commentsPagePath = useCommentsPagePath();
|
||||||
|
const [submitting, dispatchSubmit] = useDispatchWithState();
|
||||||
const enableInContext = useSelector(selectEnableInContext);
|
const enableInContext = useSelector(selectEnableInContext);
|
||||||
const nonCoursewareTopics = useSelector(enableInContext ? inContextNonCourseware : selectNonCoursewareTopics);
|
const nonCoursewareTopics = useSelector(enableInContext ? inContextNonCourseware : selectNonCoursewareTopics);
|
||||||
const nonCoursewareIds = useSelector(enableInContext ? inContextCoursewareIds : selectNonCoursewareIds);
|
const nonCoursewareIds = useSelector(enableInContext ? inContextCoursewareIds : selectNonCoursewareIds);
|
||||||
@@ -114,6 +77,7 @@ function PostEditor({
|
|||||||
const { reasonCodesEnabled, editReasons } = useSelector(selectModerationSettings);
|
const { reasonCodesEnabled, editReasons } = useSelector(selectModerationSettings);
|
||||||
const userIsStaff = useSelector(selectUserIsStaff);
|
const userIsStaff = useSelector(selectUserIsStaff);
|
||||||
const archivedTopics = useSelector(selectArchivedTopics);
|
const archivedTopics = useSelector(selectArchivedTopics);
|
||||||
|
const postEditorId = `post-editor-${editExisting ? postId : 'new'}`;
|
||||||
|
|
||||||
const canDisplayEditReason = (reasonCodesEnabled && editExisting
|
const canDisplayEditReason = (reasonCodesEnabled && editExisting
|
||||||
&& (userHasModerationPrivileges || userIsGroupTa || userIsStaff)
|
&& (userHasModerationPrivileges || userIsGroupTa || userIsStaff)
|
||||||
@@ -124,7 +88,7 @@ function PostEditor({
|
|||||||
editReasonCode: Yup.string().required(intl.formatMessage(messages.editReasonCodeError)),
|
editReasonCode: Yup.string().required(intl.formatMessage(messages.editReasonCodeError)),
|
||||||
};
|
};
|
||||||
|
|
||||||
const canSelectCohort = (tId) => {
|
const canSelectCohort = useCallback((tId) => {
|
||||||
// If the user isn't privileged, they can't edit the cohort.
|
// If the user isn't privileged, they can't edit the cohort.
|
||||||
// If the topic is being edited the cohort can't be changed.
|
// If the topic is being edited the cohort can't be changed.
|
||||||
if (!userHasModerationPrivileges) {
|
if (!userHasModerationPrivileges) {
|
||||||
@@ -135,7 +99,7 @@ function PostEditor({
|
|||||||
}
|
}
|
||||||
const isCohorting = settings.alwaysDivideInlineDiscussions || settings.dividedInlineDiscussions.includes(tId);
|
const isCohorting = settings.alwaysDivideInlineDiscussions || settings.dividedInlineDiscussions.includes(tId);
|
||||||
return isCohorting;
|
return isCohorting;
|
||||||
};
|
}, [nonCoursewareIds, settings, userHasModerationPrivileges]);
|
||||||
|
|
||||||
const initialValues = {
|
const initialValues = {
|
||||||
postType: post?.type || 'discussion',
|
postType: post?.type || 'discussion',
|
||||||
@@ -145,11 +109,13 @@ function PostEditor({
|
|||||||
follow: isEmpty(post?.following) ? true : post?.following,
|
follow: isEmpty(post?.following) ? true : post?.following,
|
||||||
anonymous: allowAnonymous ? false : undefined,
|
anonymous: allowAnonymous ? false : undefined,
|
||||||
anonymousToPeers: allowAnonymousToPeers ? false : undefined,
|
anonymousToPeers: allowAnonymousToPeers ? false : undefined,
|
||||||
editReasonCode: post?.lastEdit?.reasonCode || (userIsStaff && canDisplayEditReason ? 'violates-guidelines' : undefined),
|
|
||||||
cohort: post?.cohort || 'default',
|
cohort: post?.cohort || 'default',
|
||||||
|
editReasonCode: post?.lastEdit?.reasonCode || (
|
||||||
|
userIsStaff && canDisplayEditReason ? 'violates-guidelines' : undefined
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
const hideEditor = (resetForm) => {
|
const hideEditor = useCallback((resetForm) => {
|
||||||
resetForm({ values: initialValues });
|
resetForm({ values: initialValues });
|
||||||
if (editExisting) {
|
if (editExisting) {
|
||||||
const newLocation = discussionsPath(commentsPagePath, {
|
const newLocation = discussionsPath(commentsPagePath, {
|
||||||
@@ -162,10 +128,14 @@ function PostEditor({
|
|||||||
history.push(newLocation);
|
history.push(newLocation);
|
||||||
}
|
}
|
||||||
dispatch(hidePostEditor());
|
dispatch(hidePostEditor());
|
||||||
};
|
}, [postId, topicId, post?.author, category, editExisting, commentsPagePath, location]);
|
||||||
|
|
||||||
// null stands for no cohort restriction ("All learners" option)
|
// null stands for no cohort restriction ("All learners" option)
|
||||||
const selectedCohort = (cohort) => (cohort === 'default' ? null : cohort);
|
const selectedCohort = useCallback((cohort) => (
|
||||||
const submitForm = async (values, { resetForm }) => {
|
cohort === 'default' ? null : cohort),
|
||||||
|
[]);
|
||||||
|
|
||||||
|
const submitForm = useCallback(async (values, { resetForm }) => {
|
||||||
if (editExisting) {
|
if (editExisting) {
|
||||||
await dispatchSubmit(updateExistingThread(postId, {
|
await dispatchSubmit(updateExistingThread(postId, {
|
||||||
topicId: values.topic,
|
topicId: values.topic,
|
||||||
@@ -195,7 +165,10 @@ function PostEditor({
|
|||||||
editorRef.current.plugins.autosave.removeDraft();
|
editorRef.current.plugins.autosave.removeDraft();
|
||||||
}
|
}
|
||||||
hideEditor(resetForm);
|
hideEditor(resetForm);
|
||||||
};
|
}, [
|
||||||
|
allowAnonymous, allowAnonymousToPeers, canSelectCohort, editExisting,
|
||||||
|
enableInContextSidebar, hideEditor, postId, selectedCohort, topicId,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (userHasModerationPrivileges && isEmpty(cohorts)) {
|
if (userHasModerationPrivileges && isEmpty(cohorts)) {
|
||||||
@@ -246,8 +219,6 @@ function PostEditor({
|
|||||||
...editReasonCodeValidation,
|
...editReasonCodeValidation,
|
||||||
});
|
});
|
||||||
|
|
||||||
const postEditorId = `post-editor-${editExisting ? postId : 'new'}`;
|
|
||||||
|
|
||||||
const handleInContextSelectLabel = (section, subsection) => (
|
const handleInContextSelectLabel = (section, subsection) => (
|
||||||
`${section.displayName} / ${subsection.displayName}` || intl.formatMessage(messages.unnamedTopics)
|
`${section.displayName} / ${subsection.displayName}` || intl.formatMessage(messages.unnamedTopics)
|
||||||
);
|
);
|
||||||
@@ -258,66 +229,65 @@ function PostEditor({
|
|||||||
initialValues={initialValues}
|
initialValues={initialValues}
|
||||||
validationSchema={validationSchema}
|
validationSchema={validationSchema}
|
||||||
onSubmit={submitForm}
|
onSubmit={submitForm}
|
||||||
>{
|
>{({
|
||||||
({
|
values,
|
||||||
values,
|
errors,
|
||||||
errors,
|
touched,
|
||||||
touched,
|
handleSubmit,
|
||||||
handleSubmit,
|
handleBlur,
|
||||||
handleBlur,
|
handleChange,
|
||||||
handleChange,
|
resetForm,
|
||||||
resetForm,
|
}) => (
|
||||||
}) => (
|
<Form className="m-4 card p-4 post-form" onSubmit={handleSubmit}>
|
||||||
<Form className="m-4 card p-4 post-form" onSubmit={handleSubmit}>
|
<h4 className="mb-4 font-style font-size-16" style={{ lineHeight: '16px' }}>
|
||||||
<h4 className="mb-4 font-style font-size-16" style={{ lineHeight: '16px' }}>
|
{editExisting
|
||||||
{editExisting
|
? intl.formatMessage(messages.editPostHeading)
|
||||||
? intl.formatMessage(messages.editPostHeading)
|
: intl.formatMessage(messages.addPostHeading)}
|
||||||
: intl.formatMessage(messages.addPostHeading)}
|
</h4>
|
||||||
</h4>
|
<Form.RadioSet
|
||||||
<Form.RadioSet
|
name="postType"
|
||||||
name="postType"
|
className="d-flex flex-row flex-wrap"
|
||||||
className="d-flex flex-row flex-wrap"
|
value={values.postType}
|
||||||
value={values.postType}
|
onChange={handleChange}
|
||||||
onChange={handleChange}
|
onBlur={handleBlur}
|
||||||
onBlur={handleBlur}
|
aria-label={intl.formatMessage(messages.postTitle)}
|
||||||
aria-label={intl.formatMessage(messages.postTitle)}
|
>
|
||||||
>
|
<PostTypeCard
|
||||||
<DiscussionPostType
|
value="discussion"
|
||||||
value="discussion"
|
selected={values.postType === 'discussion'}
|
||||||
selected={values.postType === 'discussion'}
|
type={intl.formatMessage(messages.discussionType)}
|
||||||
type={intl.formatMessage(messages.discussionType)}
|
icon={<Post />}
|
||||||
icon={<Post />}
|
/>
|
||||||
/>
|
<PostTypeCard
|
||||||
<DiscussionPostType
|
value="question"
|
||||||
value="question"
|
selected={values.postType === 'question'}
|
||||||
selected={values.postType === 'question'}
|
type={intl.formatMessage(messages.questionType)}
|
||||||
type={intl.formatMessage(messages.questionType)}
|
icon={<Help />}
|
||||||
icon={<Help />}
|
/>
|
||||||
/>
|
</Form.RadioSet>
|
||||||
</Form.RadioSet>
|
<div className="d-flex flex-row my-4.5 justify-content-between">
|
||||||
<div className="d-flex flex-row my-4.5 justify-content-between">
|
<Form.Group className="w-100 m-0">
|
||||||
<Form.Group className="w-100 m-0">
|
<Form.Control
|
||||||
<Form.Control
|
className="m-0"
|
||||||
className="m-0"
|
name="topic"
|
||||||
name="topic"
|
as="select"
|
||||||
as="select"
|
value={values.topic}
|
||||||
value={values.topic}
|
onChange={handleChange}
|
||||||
onChange={handleChange}
|
onBlur={handleBlur}
|
||||||
onBlur={handleBlur}
|
aria-describedby="topicAreaInput"
|
||||||
aria-describedby="topicAreaInput"
|
floatingLabel={intl.formatMessage(messages.topicArea)}
|
||||||
floatingLabel={intl.formatMessage(messages.topicArea)}
|
disabled={enableInContextSidebar}
|
||||||
disabled={enableInContextSidebar}
|
>
|
||||||
>
|
{nonCoursewareTopics.map(topic => (
|
||||||
{nonCoursewareTopics.map(topic => (
|
<option
|
||||||
<option
|
key={topic.id}
|
||||||
key={topic.id}
|
value={topic.id}
|
||||||
value={topic.id}
|
>{topic.name || intl.formatMessage(messages.unnamedSubTopics)}
|
||||||
>{topic.name || intl.formatMessage(messages.unnamedSubTopics)}
|
</option>
|
||||||
</option>
|
))}
|
||||||
))}
|
{enableInContext ? (
|
||||||
{enableInContext ? (
|
<>
|
||||||
<>
|
{coursewareTopics?.map(section => (
|
||||||
{coursewareTopics?.map(section => (
|
|
||||||
section?.children?.map(subsection => (
|
section?.children?.map(subsection => (
|
||||||
<optgroup
|
<optgroup
|
||||||
label={handleInContextSelectLabel(section, subsection)}
|
label={handleInContextSelectLabel(section, subsection)}
|
||||||
@@ -330,177 +300,172 @@ function PostEditor({
|
|||||||
))}
|
))}
|
||||||
</optgroup>
|
</optgroup>
|
||||||
))
|
))
|
||||||
|
))}
|
||||||
|
{(userIsStaff || userIsGroupTa || userHasModerationPrivileges) && (
|
||||||
|
<optgroup label={intl.formatMessage(messages.archivedTopics)}>
|
||||||
|
{archivedTopics.map(topic => (
|
||||||
|
<option key={topic.id} value={topic.id}>
|
||||||
|
{topic.name || intl.formatMessage(messages.unnamedSubTopics)}
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
{(userIsStaff || userIsGroupTa || userHasModerationPrivileges) && (
|
</optgroup>
|
||||||
<optgroup label={intl.formatMessage(messages.archivedTopics)}>
|
)}
|
||||||
{archivedTopics.map(topic => (
|
</>
|
||||||
<option key={topic.id} value={topic.id}>
|
) : (
|
||||||
{topic.name || intl.formatMessage(messages.unnamedSubTopics)}
|
coursewareTopics.map(categoryObj => (
|
||||||
</option>
|
<optgroup
|
||||||
))}
|
label={categoryObj.name || intl.formatMessage(messages.unnamedTopics)}
|
||||||
</optgroup>
|
key={categoryObj.id}
|
||||||
)}
|
>
|
||||||
</>
|
{categoryObj.topics.map(subtopic => (
|
||||||
) : (
|
<option key={subtopic.id} value={subtopic.id}>
|
||||||
coursewareTopics.map(categoryObj => (
|
{subtopic.name || intl.formatMessage(messages.unnamedSubTopics)}
|
||||||
<optgroup
|
</option>
|
||||||
label={categoryObj.name || intl.formatMessage(messages.unnamedTopics)}
|
))}
|
||||||
key={categoryObj.id}
|
</optgroup>
|
||||||
>
|
))
|
||||||
{categoryObj.topics.map(subtopic => (
|
)}
|
||||||
<option key={subtopic.id} value={subtopic.id}>
|
</Form.Control>
|
||||||
{subtopic.name || intl.formatMessage(messages.unnamedSubTopics)}
|
</Form.Group>
|
||||||
</option>
|
{canSelectCohort(values.topic) && (
|
||||||
))}
|
<Form.Group className="w-100 ml-3 mb-0">
|
||||||
</optgroup>
|
<Form.Control
|
||||||
))
|
className="m-0"
|
||||||
)}
|
name="cohort"
|
||||||
</Form.Control>
|
as="select"
|
||||||
</Form.Group>
|
value={values.cohort}
|
||||||
{canSelectCohort(values.topic) && (
|
onChange={handleChange}
|
||||||
<Form.Group className="w-100 ml-3 mb-0">
|
onBlur={handleBlur}
|
||||||
<Form.Control
|
aria-describedby="cohortAreaInput"
|
||||||
className="m-0"
|
floatingLabel={intl.formatMessage(messages.cohortVisibility)}
|
||||||
name="cohort"
|
|
||||||
as="select"
|
|
||||||
value={values.cohort}
|
|
||||||
onChange={handleChange}
|
|
||||||
onBlur={handleBlur}
|
|
||||||
aria-describedby="cohortAreaInput"
|
|
||||||
floatingLabel={intl.formatMessage(messages.cohortVisibility)}
|
|
||||||
>
|
|
||||||
<option value="default">{intl.formatMessage(messages.cohortVisibilityAllLearners)}</option>
|
|
||||||
{cohorts.map(cohort => (
|
|
||||||
<option key={cohort.id} value={cohort.id}>{cohort.name}</option>
|
|
||||||
))}
|
|
||||||
</Form.Control>
|
|
||||||
</Form.Group>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="d-flex flex-row mb-4.5 justify-content-between">
|
|
||||||
<Form.Group
|
|
||||||
className="w-100 m-0"
|
|
||||||
isInvalid={isFormikFieldInvalid('title', {
|
|
||||||
errors,
|
|
||||||
touched,
|
|
||||||
})}
|
|
||||||
>
|
>
|
||||||
<Form.Control
|
<option value="default">{intl.formatMessage(messages.cohortVisibilityAllLearners)}</option>
|
||||||
className="m-0"
|
{cohorts.map(cohort => (
|
||||||
name="title"
|
<option key={cohort.id} value={cohort.id}>{cohort.name}</option>
|
||||||
type="text"
|
))}
|
||||||
onChange={handleChange}
|
</Form.Control>
|
||||||
onBlur={handleBlur}
|
</Form.Group>
|
||||||
aria-describedby="titleInput"
|
)}
|
||||||
floatingLabel={intl.formatMessage(messages.postTitle)}
|
</div>
|
||||||
value={values.title}
|
<div className="d-flex flex-row mb-4.5 justify-content-between">
|
||||||
/>
|
<Form.Group
|
||||||
<FormikErrorFeedback name="title" />
|
className="w-100 m-0"
|
||||||
</Form.Group>
|
isInvalid={isFormikFieldInvalid('title', {
|
||||||
{canDisplayEditReason && (
|
errors,
|
||||||
<Form.Group
|
touched,
|
||||||
className="w-100 ml-4 mb-0"
|
})}
|
||||||
isInvalid={isFormikFieldInvalid('editReasonCode', {
|
>
|
||||||
errors,
|
<Form.Control
|
||||||
touched,
|
className="m-0"
|
||||||
})}
|
name="title"
|
||||||
>
|
type="text"
|
||||||
<Form.Control
|
onChange={handleChange}
|
||||||
name="editReasonCode"
|
onBlur={handleBlur}
|
||||||
className="m-0"
|
aria-describedby="titleInput"
|
||||||
as="select"
|
floatingLabel={intl.formatMessage(messages.postTitle)}
|
||||||
value={values.editReasonCode}
|
value={values.title}
|
||||||
onChange={handleChange}
|
/>
|
||||||
onBlur={handleBlur}
|
<FormikErrorFeedback name="title" />
|
||||||
aria-describedby="editReasonCodeInput"
|
</Form.Group>
|
||||||
floatingLabel={intl.formatMessage(messages.editReasonCode)}
|
{canDisplayEditReason && (
|
||||||
>
|
<Form.Group
|
||||||
<option key="empty" value="">---</option>
|
className="w-100 ml-4 mb-0"
|
||||||
{editReasons.map(({ code, label }) => (
|
isInvalid={isFormikFieldInvalid('editReasonCode', {
|
||||||
<option key={code} value={code}>{label}</option>
|
errors,
|
||||||
))}
|
touched,
|
||||||
</Form.Control>
|
})}
|
||||||
<FormikErrorFeedback name="editReasonCode" />
|
>
|
||||||
</Form.Group>
|
<Form.Control
|
||||||
)}
|
name="editReasonCode"
|
||||||
</div>
|
className="m-0"
|
||||||
<div className="mb-3">
|
as="select"
|
||||||
<TinyMCEEditor
|
value={values.editReasonCode}
|
||||||
onInit={
|
onChange={handleChange}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
aria-describedby="editReasonCodeInput"
|
||||||
|
floatingLabel={intl.formatMessage(messages.editReasonCode)}
|
||||||
|
>
|
||||||
|
<option key="empty" value="">---</option>
|
||||||
|
{editReasons.map(({ code, label }) => (
|
||||||
|
<option key={code} value={code}>{label}</option>
|
||||||
|
))}
|
||||||
|
</Form.Control>
|
||||||
|
<FormikErrorFeedback name="editReasonCode" />
|
||||||
|
</Form.Group>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<TinyMCEEditor
|
||||||
|
onInit={
|
||||||
/* istanbul ignore next: TinyMCE is mocked so this cannot be easily tested */
|
/* istanbul ignore next: TinyMCE is mocked so this cannot be easily tested */
|
||||||
(_, editor) => {
|
(_, editor) => {
|
||||||
editorRef.current = editor;
|
editorRef.current = editor;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
id={postEditorId}
|
id={postEditorId}
|
||||||
value={values.comment}
|
value={values.comment}
|
||||||
onEditorChange={formikCompatibleHandler(handleChange, 'comment')}
|
onEditorChange={formikCompatibleHandler(handleChange, 'comment')}
|
||||||
onBlur={formikCompatibleHandler(handleBlur, 'comment')}
|
onBlur={formikCompatibleHandler(handleBlur, 'comment')}
|
||||||
/>
|
/>
|
||||||
<FormikErrorFeedback name="comment" />
|
<FormikErrorFeedback name="comment" />
|
||||||
</div>
|
</div>
|
||||||
|
<PostPreviewPanel htmlNode={values.comment} isPost editExisting={editExisting} />
|
||||||
<PostPreviewPanel htmlNode={values.comment} isPost editExisting={editExisting} />
|
<div className="d-flex flex-row mt-n4 w-75 text-primary font-style">
|
||||||
|
{!editExisting && (
|
||||||
<div className="d-flex flex-row mt-n4 w-75 text-primary font-style">
|
<>
|
||||||
{!editExisting && (
|
<Form.Group>
|
||||||
<>
|
<Form.Checkbox
|
||||||
<Form.Group>
|
name="follow"
|
||||||
<Form.Checkbox
|
checked={values.follow}
|
||||||
name="follow"
|
onChange={handleChange}
|
||||||
checked={values.follow}
|
onBlur={handleBlur}
|
||||||
onChange={handleChange}
|
className="mr-4.5"
|
||||||
onBlur={handleBlur}
|
>
|
||||||
className="mr-4.5"
|
<span className="font-size-14">
|
||||||
>
|
{intl.formatMessage(messages.followPost)}
|
||||||
<span className="font-size-14">
|
</span>
|
||||||
{intl.formatMessage(messages.followPost)}
|
</Form.Checkbox>
|
||||||
</span>
|
</Form.Group>
|
||||||
</Form.Checkbox>
|
{allowAnonymousToPeers && (
|
||||||
</Form.Group>
|
<Form.Group>
|
||||||
{allowAnonymousToPeers && (
|
<Form.Checkbox
|
||||||
<Form.Group>
|
name="anonymousToPeers"
|
||||||
<Form.Checkbox
|
checked={values.anonymousToPeers}
|
||||||
name="anonymousToPeers"
|
onChange={handleChange}
|
||||||
checked={values.anonymousToPeers}
|
onBlur={handleBlur}
|
||||||
onChange={handleChange}
|
>
|
||||||
onBlur={handleBlur}
|
<span className="font-size-14">
|
||||||
>
|
{intl.formatMessage(messages.anonymousToPeersPost)}
|
||||||
<span className="font-size-14">
|
</span>
|
||||||
{intl.formatMessage(messages.anonymousToPeersPost)}
|
</Form.Checkbox>
|
||||||
</span>
|
</Form.Group>
|
||||||
</Form.Checkbox>
|
|
||||||
</Form.Group>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
|
)}
|
||||||
<div className="d-flex justify-content-end">
|
</div>
|
||||||
<Button
|
<div className="d-flex justify-content-end">
|
||||||
variant="outline-primary"
|
<Button
|
||||||
onClick={() => hideEditor(resetForm)}
|
variant="outline-primary"
|
||||||
>
|
onClick={() => hideEditor(resetForm)}
|
||||||
{intl.formatMessage(messages.cancel)}
|
>
|
||||||
</Button>
|
{intl.formatMessage(messages.cancel)}
|
||||||
<StatefulButton
|
</Button>
|
||||||
labels={{
|
<StatefulButton
|
||||||
default: intl.formatMessage(messages.submit),
|
labels={{
|
||||||
pending: intl.formatMessage(messages.submitting),
|
default: intl.formatMessage(messages.submit),
|
||||||
}}
|
pending: intl.formatMessage(messages.submitting),
|
||||||
state={submitting ? 'pending' : 'default'}
|
}}
|
||||||
className="ml-2"
|
state={submitting ? 'pending' : 'default'}
|
||||||
variant="primary"
|
className="ml-2"
|
||||||
onClick={handleSubmit}
|
variant="primary"
|
||||||
/>
|
onClick={handleSubmit}
|
||||||
</div>
|
/>
|
||||||
</Form>
|
</div>
|
||||||
)
|
</Form>
|
||||||
}
|
)}
|
||||||
</Formik>
|
</Formik>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
PostEditor.propTypes = {
|
PostEditor.propTypes = {
|
||||||
editExisting: PropTypes.bool,
|
editExisting: PropTypes.bool,
|
||||||
@@ -510,4 +475,4 @@ PostEditor.defaultProps = {
|
|||||||
editExisting: false,
|
editExisting: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default PostEditor;
|
export default React.memo(PostEditor);
|
||||||
|
|||||||
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, {
|
import React, {
|
||||||
useContext, useEffect, useMemo, useState,
|
useCallback, useContext, useEffect, useMemo, useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
@@ -9,7 +9,7 @@ import { useDispatch, useSelector } from 'react-redux';
|
|||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
|
|
||||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import {
|
import {
|
||||||
Collapsible, Form, Icon, Spinner,
|
Collapsible, Form, Icon, Spinner,
|
||||||
} from '@edx/paragon';
|
} from '@edx/paragon';
|
||||||
@@ -29,7 +29,7 @@ import {
|
|||||||
import { selectThreadFilters, selectThreadSorting } from '../data/selectors';
|
import { selectThreadFilters, selectThreadSorting } from '../data/selectors';
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
|
|
||||||
export const ActionItem = ({
|
export const ActionItem = React.memo(({
|
||||||
id,
|
id,
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
@@ -52,7 +52,7 @@ export const ActionItem = ({
|
|||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
);
|
));
|
||||||
|
|
||||||
ActionItem.propTypes = {
|
ActionItem.propTypes = {
|
||||||
id: PropTypes.string.isRequired,
|
id: PropTypes.string.isRequired,
|
||||||
@@ -61,9 +61,8 @@ ActionItem.propTypes = {
|
|||||||
selected: PropTypes.string.isRequired,
|
selected: PropTypes.string.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
function PostFilterBar({
|
const PostFilterBar = () => {
|
||||||
intl,
|
const intl = useIntl();
|
||||||
}) {
|
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const { courseId } = useParams();
|
const { courseId } = useParams();
|
||||||
const { page } = useContext(DiscussionContext);
|
const { page } = useContext(DiscussionContext);
|
||||||
@@ -75,11 +74,13 @@ function PostFilterBar({
|
|||||||
const cohorts = useSelector(selectCourseCohorts);
|
const cohorts = useSelector(selectCourseCohorts);
|
||||||
const [isOpen, setOpen] = useState(false);
|
const [isOpen, setOpen] = useState(false);
|
||||||
|
|
||||||
const selectedCohort = useMemo(() => cohorts.find(cohort => (
|
const selectedCohort = useMemo(() => (
|
||||||
toString(cohort.id) === currentFilters.cohort)),
|
cohorts.find(cohort => (
|
||||||
[currentFilters.cohort]);
|
toString(cohort.id) === currentFilters.cohort
|
||||||
|
))
|
||||||
|
), [cohorts, currentFilters.cohort]);
|
||||||
|
|
||||||
const handleSortFilterChange = (event) => {
|
const handleSortFilterChange = useCallback((event) => {
|
||||||
const currentType = currentFilters.postType;
|
const currentType = currentFilters.postType;
|
||||||
const currentStatus = currentFilters.status;
|
const currentStatus = currentFilters.status;
|
||||||
const {
|
const {
|
||||||
@@ -93,6 +94,7 @@ function PostFilterBar({
|
|||||||
cohortFilter: selectedCohort,
|
cohortFilter: selectedCohort,
|
||||||
triggeredBy: name,
|
triggeredBy: name,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (name === 'type') {
|
if (name === 'type') {
|
||||||
dispatch(setPostsTypeFilter(value));
|
dispatch(setPostsTypeFilter(value));
|
||||||
if (
|
if (
|
||||||
@@ -103,6 +105,7 @@ function PostFilterBar({
|
|||||||
}
|
}
|
||||||
filterContentEventProperties.threadTypeFilter = value;
|
filterContentEventProperties.threadTypeFilter = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (name === 'status') {
|
if (name === 'status') {
|
||||||
dispatch(setStatusFilter(value));
|
dispatch(setStatusFilter(value));
|
||||||
if (value === PostsStatusFilter.UNANSWERED && currentType !== ThreadType.QUESTION) {
|
if (value === PostsStatusFilter.UNANSWERED && currentType !== ThreadType.QUESTION) {
|
||||||
@@ -115,16 +118,23 @@ function PostFilterBar({
|
|||||||
}
|
}
|
||||||
filterContentEventProperties.statusFilter = value;
|
filterContentEventProperties.statusFilter = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (name === 'sort') {
|
if (name === 'sort') {
|
||||||
dispatch(setSortedBy(value));
|
dispatch(setSortedBy(value));
|
||||||
filterContentEventProperties.sortFilter = value;
|
filterContentEventProperties.sortFilter = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (name === 'cohort') {
|
if (name === 'cohort') {
|
||||||
dispatch(setCohortFilter(value));
|
dispatch(setCohortFilter(value));
|
||||||
filterContentEventProperties.cohortFilter = value;
|
filterContentEventProperties.cohortFilter = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
sendTrackEvent('edx.forum.filter.content', filterContentEventProperties);
|
sendTrackEvent('edx.forum.filter.content', filterContentEventProperties);
|
||||||
};
|
}, [currentFilters, currentSorting, dispatch, selectedCohort]);
|
||||||
|
|
||||||
|
const handleToggle = useCallback(() => {
|
||||||
|
setOpen(!isOpen);
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (userHasModerationPrivileges && isEmpty(cohorts)) {
|
if (userHasModerationPrivileges && isEmpty(cohorts)) {
|
||||||
@@ -132,10 +142,48 @@ function PostFilterBar({
|
|||||||
}
|
}
|
||||||
}, [courseId, userHasModerationPrivileges]);
|
}, [courseId, userHasModerationPrivileges]);
|
||||||
|
|
||||||
|
const renderCohortFilter = useMemo(() => (
|
||||||
|
userHasModerationPrivileges && (
|
||||||
|
<>
|
||||||
|
<div className="border-bottom my-2" />
|
||||||
|
{status === RequestStatus.IN_PROGRESS ? (
|
||||||
|
<div className="d-flex justify-content-center p-4">
|
||||||
|
<Spinner animation="border" variant="primary" size="lg" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="d-flex flex-row pt-2">
|
||||||
|
<Form.RadioSet
|
||||||
|
name="cohort"
|
||||||
|
className="d-flex flex-column list-group list-group-flush w-100"
|
||||||
|
value={currentFilters.cohort}
|
||||||
|
onChange={handleSortFilterChange}
|
||||||
|
>
|
||||||
|
<ActionItem
|
||||||
|
id="all-groups"
|
||||||
|
label="All groups"
|
||||||
|
value=""
|
||||||
|
selected={currentFilters.cohort}
|
||||||
|
/>
|
||||||
|
{cohorts.map(cohort => (
|
||||||
|
<ActionItem
|
||||||
|
key={cohort.id}
|
||||||
|
id={toString(cohort.id)}
|
||||||
|
label={capitalize(cohort.name)}
|
||||||
|
value={toString(cohort.id)}
|
||||||
|
selected={currentFilters.cohort}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Form.RadioSet>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
), [cohorts, currentFilters.cohort, handleSortFilterChange, status, userHasModerationPrivileges]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Collapsible.Advanced
|
<Collapsible.Advanced
|
||||||
open={isOpen}
|
open={isOpen}
|
||||||
onToggle={() => setOpen(!isOpen)}
|
onToggle={handleToggle}
|
||||||
className="filter-bar collapsible-card-lg border-0"
|
className="filter-bar collapsible-card-lg border-0"
|
||||||
>
|
>
|
||||||
<Collapsible.Trigger className="collapsible-trigger border-0">
|
<Collapsible.Trigger className="collapsible-trigger border-0">
|
||||||
@@ -157,7 +205,6 @@ function PostFilterBar({
|
|||||||
<Icon src={Tune} />
|
<Icon src={Tune} />
|
||||||
</Collapsible.Visible>
|
</Collapsible.Visible>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
</Collapsible.Trigger>
|
</Collapsible.Trigger>
|
||||||
<Collapsible.Body className="collapsible-body px-4 pb-3 pt-0">
|
<Collapsible.Body className="collapsible-body px-4 pb-3 pt-0">
|
||||||
<Form>
|
<Form>
|
||||||
@@ -260,49 +307,11 @@ function PostFilterBar({
|
|||||||
/>
|
/>
|
||||||
</Form.RadioSet>
|
</Form.RadioSet>
|
||||||
</div>
|
</div>
|
||||||
{userHasModerationPrivileges && (
|
{renderCohortFilter}
|
||||||
<>
|
|
||||||
<div className="border-bottom my-2" />
|
|
||||||
{status === RequestStatus.IN_PROGRESS ? (
|
|
||||||
<div className="d-flex justify-content-center p-4">
|
|
||||||
<Spinner animation="border" variant="primary" size="lg" />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="d-flex flex-row pt-2">
|
|
||||||
<Form.RadioSet
|
|
||||||
name="cohort"
|
|
||||||
className="d-flex flex-column list-group list-group-flush w-100"
|
|
||||||
value={currentFilters.cohort}
|
|
||||||
onChange={handleSortFilterChange}
|
|
||||||
>
|
|
||||||
<ActionItem
|
|
||||||
id="all-groups"
|
|
||||||
label="All groups"
|
|
||||||
value=""
|
|
||||||
selected={currentFilters.cohort}
|
|
||||||
/>
|
|
||||||
{cohorts.map(cohort => (
|
|
||||||
<ActionItem
|
|
||||||
key={cohort.id}
|
|
||||||
id={cohort.id}
|
|
||||||
label={capitalize(cohort.name)}
|
|
||||||
value={toString(cohort.id)}
|
|
||||||
selected={currentFilters.cohort}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Form.RadioSet>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Form>
|
</Form>
|
||||||
</Collapsible.Body>
|
</Collapsible.Body>
|
||||||
</Collapsible.Advanced>
|
</Collapsible.Advanced>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
PostFilterBar.propTypes = {
|
|
||||||
intl: intlShape.isRequired,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default injectIntl(PostFilterBar);
|
export default React.memo(PostFilterBar);
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, {
|
||||||
|
useCallback, useEffect, useRef, useState,
|
||||||
|
} from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
|
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import {
|
import {
|
||||||
ActionRow,
|
ActionRow,
|
||||||
Button,
|
Button,
|
||||||
@@ -14,24 +16,23 @@ import {
|
|||||||
import { selectModerationSettings } from '../../data/selectors';
|
import { selectModerationSettings } from '../../data/selectors';
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
|
|
||||||
function ClosePostReasonModal({
|
const ClosePostReasonModal = ({
|
||||||
intl,
|
|
||||||
isOpen,
|
isOpen,
|
||||||
onCancel,
|
onCancel,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
}) {
|
}) => {
|
||||||
|
const intl = useIntl();
|
||||||
const scrollTo = useRef(null);
|
const scrollTo = useRef(null);
|
||||||
const [reasonCode, setReasonCode] = useState(null);
|
const [reasonCode, setReasonCode] = useState(null);
|
||||||
|
|
||||||
const { postCloseReasons } = useSelector(selectModerationSettings);
|
const { postCloseReasons } = useSelector(selectModerationSettings);
|
||||||
|
|
||||||
const onChange = event => {
|
const onChange = useCallback(event => {
|
||||||
if (event.target.value) {
|
if (event.target.value) {
|
||||||
setReasonCode(event.target.value);
|
setReasonCode(event.target.value);
|
||||||
} else {
|
} else {
|
||||||
setReasonCode(null);
|
setReasonCode(null);
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
/* istanbul ignore if: This API is not available in the test environment. */
|
/* istanbul ignore if: This API is not available in the test environment. */
|
||||||
@@ -87,13 +88,12 @@ function ClosePostReasonModal({
|
|||||||
</ModalDialog.Footer>
|
</ModalDialog.Footer>
|
||||||
</ModalDialog>
|
</ModalDialog>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
ClosePostReasonModal.propTypes = {
|
ClosePostReasonModal.propTypes = {
|
||||||
intl: intlShape.isRequired,
|
|
||||||
isOpen: PropTypes.bool.isRequired,
|
isOpen: PropTypes.bool.isRequired,
|
||||||
onCancel: PropTypes.func.isRequired,
|
onCancel: PropTypes.func.isRequired,
|
||||||
onConfirm: PropTypes.func.isRequired,
|
onConfirm: PropTypes.func.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default injectIntl(ClosePostReasonModal);
|
export default React.memo(ClosePostReasonModal);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react';
|
import React, { useCallback } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import {
|
import {
|
||||||
Icon, IconButton, OverlayTrigger, Tooltip,
|
Icon, IconButton, OverlayTrigger, Tooltip,
|
||||||
} from '@edx/paragon';
|
} from '@edx/paragon';
|
||||||
@@ -9,19 +9,16 @@ import {
|
|||||||
import { ThumbUpFilled, ThumbUpOutline } from '../../../components/icons';
|
import { ThumbUpFilled, ThumbUpOutline } from '../../../components/icons';
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
|
|
||||||
function LikeButton({
|
const LikeButton = ({ count, onClick, voted }) => {
|
||||||
count,
|
const intl = useIntl();
|
||||||
intl,
|
|
||||||
onClick,
|
const handleClick = useCallback((e) => {
|
||||||
voted,
|
|
||||||
}) {
|
|
||||||
const handleClick = (e) => {
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (onClick) {
|
if (onClick) {
|
||||||
onClick();
|
onClick();
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="d-flex align-items-center mr-36px text-primary-500">
|
<div className="d-flex align-items-center mr-36px text-primary-500">
|
||||||
@@ -47,11 +44,10 @@ function LikeButton({
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
LikeButton.propTypes = {
|
LikeButton.propTypes = {
|
||||||
count: PropTypes.number.isRequired,
|
count: PropTypes.number.isRequired,
|
||||||
intl: intlShape.isRequired,
|
|
||||||
onClick: PropTypes.func,
|
onClick: PropTypes.func,
|
||||||
voted: PropTypes.bool,
|
voted: PropTypes.bool,
|
||||||
};
|
};
|
||||||
@@ -61,4 +57,4 @@ LikeButton.defaultProps = {
|
|||||||
onClick: undefined,
|
onClick: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default injectIntl(LikeButton);
|
export default React.memo(LikeButton);
|
||||||
|
|||||||
@@ -2,11 +2,12 @@ import React, { useCallback, useContext, useMemo } from 'react';
|
|||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
import { toString } from 'lodash';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
import { useHistory, useLocation } from 'react-router-dom';
|
import { useHistory, useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
import { getConfig } from '@edx/frontend-platform';
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import { Hyperlink, useToggle } from '@edx/paragon';
|
import { Hyperlink, useToggle } from '@edx/paragon';
|
||||||
|
|
||||||
import HTMLLoader from '../../../components/HTMLLoader';
|
import HTMLLoader from '../../../components/HTMLLoader';
|
||||||
@@ -15,102 +16,119 @@ import { selectorForUnitSubsection, selectTopicContext } from '../../../data/sel
|
|||||||
import { AlertBanner, Confirmation } from '../../common';
|
import { AlertBanner, Confirmation } from '../../common';
|
||||||
import { DiscussionContext } from '../../common/context';
|
import { DiscussionContext } from '../../common/context';
|
||||||
import HoverCard from '../../common/HoverCard';
|
import HoverCard from '../../common/HoverCard';
|
||||||
|
import { ContentTypes } from '../../data/constants';
|
||||||
import { selectModerationSettings, selectUserHasModerationPrivileges } from '../../data/selectors';
|
import { selectModerationSettings, selectUserHasModerationPrivileges } from '../../data/selectors';
|
||||||
import { selectTopic } from '../../topics/data/selectors';
|
import { selectTopic } from '../../topics/data/selectors';
|
||||||
|
import { selectThread } from '../data/selectors';
|
||||||
import { removeThread, updateExistingThread } from '../data/thunks';
|
import { removeThread, updateExistingThread } from '../data/thunks';
|
||||||
import ClosePostReasonModal from './ClosePostReasonModal';
|
import ClosePostReasonModal from './ClosePostReasonModal';
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
import PostFooter from './PostFooter';
|
import PostFooter from './PostFooter';
|
||||||
import PostHeader from './PostHeader';
|
import PostHeader from './PostHeader';
|
||||||
import { postShape } from './proptypes';
|
|
||||||
|
|
||||||
function Post({
|
const Post = ({ handleAddResponseButton }) => {
|
||||||
post,
|
const { enableInContextSidebar, postId } = useContext(DiscussionContext);
|
||||||
intl,
|
const {
|
||||||
handleAddResponseButton,
|
topicId, abuseFlagged, closed, pinned, voted, hasEndorsed, following, closedBy, voteCount, groupId, groupName,
|
||||||
}) {
|
closeReason, authorLabel, type: postType, author, title, createdAt, renderedBody, lastEdit, editByLabel,
|
||||||
|
closedByLabel,
|
||||||
|
} = useSelector(selectThread(postId));
|
||||||
|
const intl = useIntl();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const { enableInContextSidebar } = useContext(DiscussionContext);
|
|
||||||
const courseId = useSelector((state) => state.config.id);
|
const courseId = useSelector((state) => state.config.id);
|
||||||
const topic = useSelector(selectTopic(post.topicId));
|
const topic = useSelector(selectTopic(topicId));
|
||||||
const getTopicSubsection = useSelector(selectorForUnitSubsection);
|
const getTopicSubsection = useSelector(selectorForUnitSubsection);
|
||||||
const topicContext = useSelector(selectTopicContext(post.topicId));
|
const topicContext = useSelector(selectTopicContext(topicId));
|
||||||
const { reasonCodesEnabled } = useSelector(selectModerationSettings);
|
const { reasonCodesEnabled } = useSelector(selectModerationSettings);
|
||||||
const [isDeleting, showDeleteConfirmation, hideDeleteConfirmation] = useToggle(false);
|
const [isDeleting, showDeleteConfirmation, hideDeleteConfirmation] = useToggle(false);
|
||||||
const [isReporting, showReportConfirmation, hideReportConfirmation] = useToggle(false);
|
const [isReporting, showReportConfirmation, hideReportConfirmation] = useToggle(false);
|
||||||
const [isClosing, showClosePostModal, hideClosePostModal] = useToggle(false);
|
const [isClosing, showClosePostModal, hideClosePostModal] = useToggle(false);
|
||||||
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
|
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
|
||||||
const displayPostFooter = post.following || post.voteCount || post.closed
|
const displayPostFooter = following || voteCount || closed || (groupId && userHasModerationPrivileges);
|
||||||
|| (post.groupId && userHasModerationPrivileges);
|
|
||||||
|
|
||||||
const handleAbusedFlag = useCallback(() => {
|
const handleDeleteConfirmation = useCallback(async () => {
|
||||||
if (post.abuseFlagged) {
|
await dispatch(removeThread(postId));
|
||||||
dispatch(updateExistingThread(post.id, { flagged: !post.abuseFlagged }));
|
|
||||||
} else {
|
|
||||||
showReportConfirmation();
|
|
||||||
}
|
|
||||||
}, [dispatch, post.abuseFlagged, post.id, showReportConfirmation]);
|
|
||||||
|
|
||||||
const handleDeleteConfirmation = async () => {
|
|
||||||
await dispatch(removeThread(post.id));
|
|
||||||
history.push({
|
history.push({
|
||||||
pathname: '.',
|
pathname: '.',
|
||||||
search: enableInContextSidebar && '?inContextSidebar',
|
search: enableInContextSidebar && '?inContextSidebar',
|
||||||
});
|
});
|
||||||
hideDeleteConfirmation();
|
hideDeleteConfirmation();
|
||||||
};
|
}, [enableInContextSidebar, postId, hideDeleteConfirmation]);
|
||||||
|
|
||||||
const handleReportConfirmation = () => {
|
const handleReportConfirmation = useCallback(() => {
|
||||||
dispatch(updateExistingThread(post.id, { flagged: !post.abuseFlagged }));
|
dispatch(updateExistingThread(postId, { flagged: !abuseFlagged }));
|
||||||
hideReportConfirmation();
|
hideReportConfirmation();
|
||||||
};
|
}, [abuseFlagged, postId, hideReportConfirmation]);
|
||||||
|
|
||||||
|
const handlePostContentEdit = useCallback(() => history.push({
|
||||||
|
...location,
|
||||||
|
pathname: `${location.pathname}/edit`,
|
||||||
|
}), [location.pathname]);
|
||||||
|
|
||||||
|
const handlePostClose = useCallback(() => {
|
||||||
|
if (closed) {
|
||||||
|
dispatch(updateExistingThread(postId, { closed: false }));
|
||||||
|
} else if (reasonCodesEnabled) {
|
||||||
|
showClosePostModal();
|
||||||
|
} else {
|
||||||
|
dispatch(updateExistingThread(postId, { closed: true }));
|
||||||
|
}
|
||||||
|
}, [closed, postId, reasonCodesEnabled, showClosePostModal]);
|
||||||
|
|
||||||
|
const handlePostCopyLink = useCallback(() => navigator.clipboard.writeText(
|
||||||
|
`${window.location.origin}/${courseId}/posts/${postId}`,
|
||||||
|
), [window.location.origin, postId, courseId]);
|
||||||
|
|
||||||
|
const handlePostPin = useCallback(() => dispatch(updateExistingThread(
|
||||||
|
postId, { pinned: !pinned },
|
||||||
|
)), [postId, pinned]);
|
||||||
|
|
||||||
|
const handlePostReport = useCallback(() => {
|
||||||
|
if (abuseFlagged) {
|
||||||
|
dispatch(updateExistingThread(postId, { flagged: !abuseFlagged }));
|
||||||
|
} else {
|
||||||
|
showReportConfirmation();
|
||||||
|
}
|
||||||
|
}, [abuseFlagged, postId, showReportConfirmation]);
|
||||||
|
|
||||||
const actionHandlers = useMemo(() => ({
|
const actionHandlers = useMemo(() => ({
|
||||||
[ContentActions.EDIT_CONTENT]: () => history.push({
|
[ContentActions.EDIT_CONTENT]: handlePostContentEdit,
|
||||||
...location,
|
|
||||||
pathname: `${location.pathname}/edit`,
|
|
||||||
}),
|
|
||||||
[ContentActions.DELETE]: showDeleteConfirmation,
|
[ContentActions.DELETE]: showDeleteConfirmation,
|
||||||
[ContentActions.CLOSE]: () => {
|
[ContentActions.CLOSE]: handlePostClose,
|
||||||
if (post.closed) {
|
[ContentActions.COPY_LINK]: handlePostCopyLink,
|
||||||
dispatch(updateExistingThread(post.id, { closed: false }));
|
[ContentActions.PIN]: handlePostPin,
|
||||||
} else if (reasonCodesEnabled) {
|
[ContentActions.REPORT]: handlePostReport,
|
||||||
showClosePostModal();
|
|
||||||
} else {
|
|
||||||
dispatch(updateExistingThread(post.id, { closed: true }));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[ContentActions.COPY_LINK]: () => { navigator.clipboard.writeText(`${window.location.origin}/${courseId}/posts/${post.id}`); },
|
|
||||||
[ContentActions.PIN]: () => dispatch(updateExistingThread(post.id, { pinned: !post.pinned })),
|
|
||||||
[ContentActions.REPORT]: () => handleAbusedFlag(),
|
|
||||||
}), [
|
}), [
|
||||||
showDeleteConfirmation,
|
handlePostClose, handlePostContentEdit, handlePostCopyLink, handlePostPin, handlePostReport, showDeleteConfirmation,
|
||||||
history,
|
|
||||||
location,
|
|
||||||
post.closed,
|
|
||||||
post.id,
|
|
||||||
post.pinned,
|
|
||||||
reasonCodesEnabled,
|
|
||||||
dispatch,
|
|
||||||
showClosePostModal,
|
|
||||||
courseId,
|
|
||||||
handleAbusedFlag,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const getTopicCategoryName = topicData => (
|
const handleClosePostConfirmation = useCallback((closeReasonCode) => {
|
||||||
topicData.usageKey ? getTopicSubsection(topicData.usageKey)?.displayName : topicData.categoryId
|
dispatch(updateExistingThread(postId, { closed: true, closeReasonCode }));
|
||||||
);
|
hideClosePostModal();
|
||||||
|
}, [postId, hideClosePostModal]);
|
||||||
|
|
||||||
const getTopicInfo = topicData => (
|
const handlePostLike = useCallback(() => {
|
||||||
|
dispatch(updateExistingThread(postId, { voted: !voted }));
|
||||||
|
}, [postId, voted]);
|
||||||
|
|
||||||
|
const handlePostFollow = useCallback(() => {
|
||||||
|
dispatch(updateExistingThread(postId, { following: !following }));
|
||||||
|
}, [postId, following]);
|
||||||
|
|
||||||
|
const getTopicCategoryName = useCallback(topicData => (
|
||||||
|
topicData.usageKey ? getTopicSubsection(topicData.usageKey)?.displayName : topicData.categoryId
|
||||||
|
), [getTopicSubsection]);
|
||||||
|
|
||||||
|
const getTopicInfo = useCallback(topicData => (
|
||||||
getTopicCategoryName(topicData) ? `${getTopicCategoryName(topicData)} / ${topicData.name}` : `${topicData.name}`
|
getTopicCategoryName(topicData) ? `${getTopicCategoryName(topicData)} / ${topicData.name}` : `${topicData.name}`
|
||||||
);
|
), [getTopicCategoryName]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="d-flex flex-column w-100 mw-100 post-card-comment"
|
className="d-flex flex-column w-100 mw-100 post-card-comment"
|
||||||
data-testid={`post-${post.id}`}
|
data-testid={`post-${postId}`}
|
||||||
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
|
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
|
||||||
tabIndex="0"
|
tabIndex="0"
|
||||||
>
|
>
|
||||||
@@ -123,7 +141,7 @@ function Post({
|
|||||||
closeButtonVaraint="tertiary"
|
closeButtonVaraint="tertiary"
|
||||||
confirmButtonText={intl.formatMessage(messages.deleteConfirmationDelete)}
|
confirmButtonText={intl.formatMessage(messages.deleteConfirmationDelete)}
|
||||||
/>
|
/>
|
||||||
{!post.abuseFlagged && (
|
{!abuseFlagged && (
|
||||||
<Confirmation
|
<Confirmation
|
||||||
isOpen={isReporting}
|
isOpen={isReporting}
|
||||||
title={intl.formatMessage(messages.reportPostTitle)}
|
title={intl.formatMessage(messages.reportPostTitle)}
|
||||||
@@ -134,18 +152,39 @@ function Post({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<HoverCard
|
<HoverCard
|
||||||
commentOrPost={post}
|
id={postId}
|
||||||
|
contentType={ContentTypes.POST}
|
||||||
actionHandlers={actionHandlers}
|
actionHandlers={actionHandlers}
|
||||||
handleResponseCommentButton={handleAddResponseButton}
|
handleResponseCommentButton={handleAddResponseButton}
|
||||||
addResponseCommentButtonMessage={intl.formatMessage(messages.addResponse)}
|
addResponseCommentButtonMessage={intl.formatMessage(messages.addResponse)}
|
||||||
onLike={() => dispatch(updateExistingThread(post.id, { voted: !post.voted }))}
|
onLike={handlePostLike}
|
||||||
onFollow={() => dispatch(updateExistingThread(post.id, { following: !post.following }))}
|
onFollow={handlePostFollow}
|
||||||
isClosedPost={post.closed}
|
voted={voted}
|
||||||
|
following={following}
|
||||||
|
/>
|
||||||
|
<AlertBanner
|
||||||
|
author={author}
|
||||||
|
abuseFlagged={abuseFlagged}
|
||||||
|
lastEdit={lastEdit}
|
||||||
|
closed={closed}
|
||||||
|
closedBy={closedBy}
|
||||||
|
closeReason={closeReason}
|
||||||
|
editByLabel={editByLabel}
|
||||||
|
closedByLabel={closedByLabel}
|
||||||
|
/>
|
||||||
|
<PostHeader
|
||||||
|
abuseFlagged={abuseFlagged}
|
||||||
|
author={author}
|
||||||
|
authorLabel={authorLabel}
|
||||||
|
closed={closed}
|
||||||
|
createdAt={createdAt}
|
||||||
|
hasEndorsed={hasEndorsed}
|
||||||
|
lastEdit={lastEdit}
|
||||||
|
postType={postType}
|
||||||
|
title={title}
|
||||||
/>
|
/>
|
||||||
<AlertBanner content={post} />
|
|
||||||
<PostHeader post={post} />
|
|
||||||
<div className="d-flex mt-14px text-break font-style text-primary-500">
|
<div className="d-flex mt-14px text-break font-style text-primary-500">
|
||||||
<HTMLLoader htmlNode={post.renderedBody} componentId="post" cssClassName="html-loader" testId={post.id} />
|
<HTMLLoader htmlNode={renderedBody} componentId="post" cssClassName="html-loader" testId={postId} />
|
||||||
</div>
|
</div>
|
||||||
{(topicContext || topic) && (
|
{(topicContext || topic) && (
|
||||||
<div
|
<div
|
||||||
@@ -153,42 +192,54 @@ function Post({
|
|||||||
{ 'w-100': enableInContextSidebar, 'mb-1': !displayPostFooter })}
|
{ 'w-100': enableInContextSidebar, 'mb-1': !displayPostFooter })}
|
||||||
style={{ lineHeight: '20px' }}
|
style={{ lineHeight: '20px' }}
|
||||||
>
|
>
|
||||||
<span className="text-gray-500" style={{ lineHeight: '20px' }}>{intl.formatMessage(messages.relatedTo)}{' '}</span>
|
<span className="text-gray-500" style={{ lineHeight: '20px' }}>
|
||||||
|
{intl.formatMessage(messages.relatedTo)}{' '}
|
||||||
|
</span>
|
||||||
<Hyperlink
|
<Hyperlink
|
||||||
destination={topicContext ? topicContext.unitLink : `${getConfig().BASE_URL}/${courseId}/topics/${post.topicId}`}
|
|
||||||
target="_top"
|
target="_top"
|
||||||
|
destination={topicContext ? (
|
||||||
|
topicContext.unitLink
|
||||||
|
) : (
|
||||||
|
`${getConfig().BASE_URL}/${courseId}/topics/${topicId}`
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{(topicContext && !topic)
|
{(topicContext && !topic) ? (
|
||||||
? (
|
<>
|
||||||
<>
|
<span className="w-auto">{topicContext.chapterName}</span>
|
||||||
<span className="w-auto">{topicContext.chapterName}</span>
|
<span className="mx-1">/</span>
|
||||||
<span className="mx-1">/</span>
|
<span className="w-auto">{topicContext.verticalName}</span>
|
||||||
<span className="w-auto">{topicContext.verticalName}</span>
|
<span className="mx-1">/</span>
|
||||||
<span className="mx-1">/</span>
|
<span className="w-auto">{topicContext.unitName}</span>
|
||||||
<span className="w-auto">{topicContext.unitName}</span>
|
</>
|
||||||
</>
|
) : (
|
||||||
)
|
getTopicInfo(topic)
|
||||||
: getTopicInfo(topic)}
|
)}
|
||||||
</Hyperlink>
|
</Hyperlink>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{displayPostFooter && <PostFooter post={post} userHasModerationPrivileges={userHasModerationPrivileges} />}
|
{displayPostFooter && (
|
||||||
|
<PostFooter
|
||||||
|
id={postId}
|
||||||
|
voteCount={voteCount}
|
||||||
|
voted={voted}
|
||||||
|
following={following}
|
||||||
|
groupId={toString(groupId)}
|
||||||
|
groupName={groupName}
|
||||||
|
closed={closed}
|
||||||
|
userHasModerationPrivileges={userHasModerationPrivileges}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<ClosePostReasonModal
|
<ClosePostReasonModal
|
||||||
isOpen={isClosing}
|
isOpen={isClosing}
|
||||||
onCancel={hideClosePostModal}
|
onCancel={hideClosePostModal}
|
||||||
onConfirm={closeReasonCode => {
|
onConfirm={handleClosePostConfirmation}
|
||||||
dispatch(updateExistingThread(post.id, { closed: true, closeReasonCode }));
|
|
||||||
hideClosePostModal();
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
Post.propTypes = {
|
Post.propTypes = {
|
||||||
intl: intlShape.isRequired,
|
|
||||||
post: postShape.isRequired,
|
|
||||||
handleAddResponseButton: PropTypes.func.isRequired,
|
handleAddResponseButton: PropTypes.func.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default injectIntl(Post);
|
export default React.memo(Post);
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import React from 'react';
|
import React, { useCallback } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
import { useDispatch } from 'react-redux';
|
import { useDispatch } from 'react-redux';
|
||||||
|
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import {
|
import {
|
||||||
Icon, IconButton, OverlayTrigger, Tooltip,
|
Icon, IconButton, OverlayTrigger, Tooltip,
|
||||||
} from '@edx/paragon';
|
} from '@edx/paragon';
|
||||||
@@ -13,36 +13,46 @@ import { StarFilled, StarOutline } from '../../../components/icons';
|
|||||||
import { updateExistingThread } from '../data/thunks';
|
import { updateExistingThread } from '../data/thunks';
|
||||||
import LikeButton from './LikeButton';
|
import LikeButton from './LikeButton';
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
import { postShape } from './proptypes';
|
|
||||||
|
|
||||||
function PostFooter({
|
const PostFooter = ({
|
||||||
intl,
|
closed,
|
||||||
post,
|
following,
|
||||||
|
groupId,
|
||||||
|
groupName,
|
||||||
|
id,
|
||||||
userHasModerationPrivileges,
|
userHasModerationPrivileges,
|
||||||
}) {
|
voted,
|
||||||
|
voteCount,
|
||||||
|
}) => {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
const handlePostLike = useCallback(() => {
|
||||||
|
dispatch(updateExistingThread(id, { voted: !voted }));
|
||||||
|
}, [id, voted]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="d-flex align-items-center ml-n1.5 mt-10px" style={{ height: '32px' }} data-testid="post-footer">
|
<div className="d-flex align-items-center ml-n1.5 mt-10px" style={{ height: '32px' }} data-testid="post-footer">
|
||||||
{post.voteCount !== 0 && (
|
{voteCount !== 0 && (
|
||||||
<LikeButton
|
<LikeButton
|
||||||
count={post.voteCount}
|
count={voteCount}
|
||||||
onClick={() => dispatch(updateExistingThread(post.id, { voted: !post.voted }))}
|
onClick={handlePostLike}
|
||||||
voted={post.voted}
|
voted={voted}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{post.following && (
|
{following && (
|
||||||
<OverlayTrigger
|
<OverlayTrigger
|
||||||
overlay={(
|
overlay={(
|
||||||
<Tooltip id={`follow-${post.id}-tooltip`}>
|
<Tooltip id={`follow-${id}-tooltip`}>
|
||||||
{intl.formatMessage(post.following ? messages.unFollow : messages.follow)}
|
{intl.formatMessage(following ? messages.unFollow : messages.follow)}
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<IconButton
|
<IconButton
|
||||||
src={post.following ? StarFilled : StarOutline}
|
src={following ? StarFilled : StarOutline}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
dispatch(updateExistingThread(post.id, { following: !post.following }));
|
dispatch(updateExistingThread(id, { following: !following }));
|
||||||
return true;
|
return true;
|
||||||
}}
|
}}
|
||||||
iconAs={Icon}
|
iconAs={Icon}
|
||||||
@@ -53,10 +63,10 @@ function PostFooter({
|
|||||||
</OverlayTrigger>
|
</OverlayTrigger>
|
||||||
)}
|
)}
|
||||||
<div className="d-flex flex-fill justify-content-end align-items-center">
|
<div className="d-flex flex-fill justify-content-end align-items-center">
|
||||||
{post.groupId && userHasModerationPrivileges && (
|
{groupId && userHasModerationPrivileges && (
|
||||||
<OverlayTrigger
|
<OverlayTrigger
|
||||||
overlay={(
|
overlay={(
|
||||||
<Tooltip id={`visibility-${post.id}-tooltip`}>{post.groupName}</Tooltip>
|
<Tooltip id={`visibility-${id}-tooltip`}>{groupName}</Tooltip>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span data-testid="cohort-icon">
|
<span data-testid="cohort-icon">
|
||||||
@@ -71,36 +81,43 @@ function PostFooter({
|
|||||||
</span>
|
</span>
|
||||||
</OverlayTrigger>
|
</OverlayTrigger>
|
||||||
)}
|
)}
|
||||||
|
{closed && (
|
||||||
{post.closed
|
<OverlayTrigger
|
||||||
&& (
|
overlay={(
|
||||||
<OverlayTrigger
|
<Tooltip id={`closed-${id}-tooltip`}>
|
||||||
overlay={(
|
{intl.formatMessage(messages.postClosed)}
|
||||||
<Tooltip id={`closed-${post.id}-tooltip`}>
|
</Tooltip>
|
||||||
{intl.formatMessage(messages.postClosed)}
|
)}
|
||||||
</Tooltip>
|
>
|
||||||
)}
|
<Icon
|
||||||
>
|
src={Locked}
|
||||||
<Icon
|
style={{
|
||||||
src={Locked}
|
width: '1rem',
|
||||||
className="text-primary-500"
|
height: '1rem',
|
||||||
style={{
|
marginLeft: '19.5px',
|
||||||
width: '1rem',
|
}}
|
||||||
height: '1rem',
|
/>
|
||||||
marginLeft: '19.5px',
|
</OverlayTrigger>
|
||||||
}}
|
)}
|
||||||
/>
|
|
||||||
</OverlayTrigger>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
PostFooter.propTypes = {
|
PostFooter.propTypes = {
|
||||||
intl: intlShape.isRequired,
|
voteCount: PropTypes.number.isRequired,
|
||||||
post: postShape.isRequired,
|
voted: PropTypes.bool.isRequired,
|
||||||
|
following: PropTypes.bool.isRequired,
|
||||||
|
id: PropTypes.string.isRequired,
|
||||||
|
groupId: PropTypes.string,
|
||||||
|
groupName: PropTypes.string,
|
||||||
|
closed: PropTypes.bool.isRequired,
|
||||||
userHasModerationPrivileges: PropTypes.bool.isRequired,
|
userHasModerationPrivileges: PropTypes.bool.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default injectIntl(PostFooter);
|
PostFooter.defaultProps = {
|
||||||
|
groupId: null,
|
||||||
|
groupName: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default React.memo(PostFooter);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
|
|||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import { Avatar, Badge, Icon } from '@edx/paragon';
|
import { Avatar, Badge, Icon } from '@edx/paragon';
|
||||||
|
|
||||||
import { Issue, Question } from '../../../components/icons';
|
import { Issue, Question } from '../../../components/icons';
|
||||||
@@ -11,36 +11,35 @@ import { AvatarOutlineAndLabelColors, ThreadType } from '../../../data/constants
|
|||||||
import { AuthorLabel } from '../../common';
|
import { AuthorLabel } from '../../common';
|
||||||
import { useAlertBannerVisible } from '../../data/hooks';
|
import { useAlertBannerVisible } from '../../data/hooks';
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
import { postShape } from './proptypes';
|
|
||||||
|
|
||||||
export function PostAvatar({
|
export const PostAvatar = React.memo(({
|
||||||
post, authorLabel, fromPostLink, read,
|
author, postType, authorLabel, fromPostLink, read,
|
||||||
}) {
|
}) => {
|
||||||
const outlineColor = AvatarOutlineAndLabelColors[authorLabel];
|
const outlineColor = AvatarOutlineAndLabelColors[authorLabel];
|
||||||
|
|
||||||
const avatarSize = useMemo(() => {
|
const avatarSize = useMemo(() => {
|
||||||
let size = '2rem';
|
let size = '2rem';
|
||||||
if (post.type === ThreadType.DISCUSSION && !fromPostLink) {
|
if (postType === ThreadType.DISCUSSION && !fromPostLink) {
|
||||||
size = '2rem';
|
size = '2rem';
|
||||||
} else if (post.type === ThreadType.QUESTION) {
|
} else if (postType === ThreadType.QUESTION) {
|
||||||
size = '1.5rem';
|
size = '1.5rem';
|
||||||
}
|
}
|
||||||
return size;
|
return size;
|
||||||
}, [post.type]);
|
}, [postType]);
|
||||||
|
|
||||||
const avatarSpacing = useMemo(() => {
|
const avatarSpacing = useMemo(() => {
|
||||||
let spacing = 'mr-3 ';
|
let spacing = 'mr-3 ';
|
||||||
if (post.type === ThreadType.DISCUSSION && fromPostLink) {
|
if (postType === ThreadType.DISCUSSION && fromPostLink) {
|
||||||
spacing += 'pt-2 ml-0.5';
|
spacing += 'pt-2 ml-0.5';
|
||||||
} else if (post.type === ThreadType.DISCUSSION) {
|
} else if (postType === ThreadType.DISCUSSION) {
|
||||||
spacing += 'ml-0.5 mt-0.5';
|
spacing += 'ml-0.5 mt-0.5';
|
||||||
}
|
}
|
||||||
return spacing;
|
return spacing;
|
||||||
}, [post.type]);
|
}, [postType]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={avatarSpacing}>
|
<div className={avatarSpacing}>
|
||||||
{post.type === ThreadType.QUESTION && (
|
{postType === ThreadType.QUESTION && (
|
||||||
<Icon
|
<Icon
|
||||||
src={read ? Issue : Question}
|
src={read ? Issue : Question}
|
||||||
className={classNames('position-absolute bg-white rounded-circle question-icon-size', {
|
className={classNames('position-absolute bg-white rounded-circle question-icon-size', {
|
||||||
@@ -52,21 +51,22 @@ export function PostAvatar({
|
|||||||
className={classNames('border-0 mt-1', {
|
className={classNames('border-0 mt-1', {
|
||||||
[`outline-${outlineColor}`]: outlineColor,
|
[`outline-${outlineColor}`]: outlineColor,
|
||||||
'outline-anonymous': !outlineColor,
|
'outline-anonymous': !outlineColor,
|
||||||
'mt-3 ml-2': post.type === ThreadType.QUESTION && fromPostLink,
|
'mt-3 ml-2': postType === ThreadType.QUESTION && fromPostLink,
|
||||||
'avarat-img-position mt-17px': post.type === ThreadType.QUESTION,
|
'avarat-img-position mt-17px': postType === ThreadType.QUESTION,
|
||||||
})}
|
})}
|
||||||
style={{
|
style={{
|
||||||
height: avatarSize,
|
height: avatarSize,
|
||||||
width: avatarSize,
|
width: avatarSize,
|
||||||
}}
|
}}
|
||||||
alt={post.author}
|
alt={author}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
PostAvatar.propTypes = {
|
PostAvatar.propTypes = {
|
||||||
post: postShape.isRequired,
|
author: PropTypes.string.isRequired,
|
||||||
|
postType: PropTypes.string.isRequired,
|
||||||
authorLabel: PropTypes.string,
|
authorLabel: PropTypes.string,
|
||||||
fromPostLink: PropTypes.bool,
|
fromPostLink: PropTypes.bool,
|
||||||
read: PropTypes.bool,
|
read: PropTypes.bool,
|
||||||
@@ -78,65 +78,86 @@ PostAvatar.defaultProps = {
|
|||||||
read: false,
|
read: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
function PostHeader({
|
const PostHeader = ({
|
||||||
intl,
|
abuseFlagged,
|
||||||
post,
|
author,
|
||||||
|
authorLabel,
|
||||||
|
closed,
|
||||||
|
createdAt,
|
||||||
|
hasEndorsed,
|
||||||
|
lastEdit,
|
||||||
|
title,
|
||||||
|
postType,
|
||||||
preview,
|
preview,
|
||||||
}) {
|
}) => {
|
||||||
const showAnsweredBadge = preview && post.hasEndorsed && post.type === ThreadType.QUESTION;
|
const intl = useIntl();
|
||||||
const authorLabelColor = AvatarOutlineAndLabelColors[post.authorLabel];
|
const showAnsweredBadge = preview && hasEndorsed && postType === ThreadType.QUESTION;
|
||||||
const hasAnyAlert = useAlertBannerVisible(post);
|
const authorLabelColor = AvatarOutlineAndLabelColors[authorLabel];
|
||||||
|
const hasAnyAlert = useAlertBannerVisible({
|
||||||
|
author, abuseFlagged, lastEdit, closed,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classNames('d-flex flex-fill mw-100', { 'mt-10px': hasAnyAlert && !preview })}>
|
<div className={classNames('d-flex flex-fill mw-100', { 'mt-10px': hasAnyAlert && !preview })}>
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
<PostAvatar post={post} authorLabel={post.authorLabel} />
|
<PostAvatar postType={postType} author={author} authorLabel={authorLabel} />
|
||||||
</div>
|
</div>
|
||||||
<div className="align-items-center d-flex flex-row">
|
<div className="align-items-center d-flex flex-row">
|
||||||
<div className="d-flex flex-column justify-content-start mw-100">
|
<div className="d-flex flex-column justify-content-start mw-100">
|
||||||
{preview
|
{preview ? (
|
||||||
? (
|
<div className="h4 d-flex align-items-center pb-0 mb-0 flex-fill">
|
||||||
<div className="h4 d-flex align-items-center pb-0 mb-0 flex-fill">
|
<div className="flex-fill text-truncate" role="heading" aria-level="1">
|
||||||
<div className="flex-fill text-truncate" role="heading" aria-level="1">
|
{title}
|
||||||
{post.title}
|
|
||||||
</div>
|
|
||||||
{showAnsweredBadge
|
|
||||||
&& <Badge variant="success">{intl.formatMessage(messages.answered)}</Badge>}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
{showAnsweredBadge
|
||||||
: (
|
&& <Badge variant="success">{intl.formatMessage(messages.answered)}</Badge>}
|
||||||
<h5
|
</div>
|
||||||
className="mb-0 font-style text-primary-500"
|
) : (
|
||||||
style={{ lineHeight: '21px' }}
|
<h5
|
||||||
aria-level="1"
|
className="mb-0 font-style text-primary-500"
|
||||||
tabIndex="-1"
|
style={{ lineHeight: '21px' }}
|
||||||
accessKey="h"
|
aria-level="1"
|
||||||
>
|
tabIndex="-1"
|
||||||
{post.title}
|
accessKey="h"
|
||||||
</h5>
|
>
|
||||||
)}
|
{title}
|
||||||
|
</h5>
|
||||||
|
)}
|
||||||
<AuthorLabel
|
<AuthorLabel
|
||||||
author={post.author || intl.formatMessage(messages.anonymous)}
|
author={author || intl.formatMessage(messages.anonymous)}
|
||||||
authorLabel={post.authorLabel}
|
authorLabel={authorLabel}
|
||||||
labelColor={authorLabelColor && `text-${authorLabelColor}`}
|
labelColor={authorLabelColor && `text-${authorLabelColor}`}
|
||||||
linkToProfile
|
linkToProfile
|
||||||
postCreatedAt={post.createdAt}
|
postCreatedAt={createdAt}
|
||||||
postOrComment
|
postOrComment
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
PostHeader.propTypes = {
|
PostHeader.propTypes = {
|
||||||
intl: intlShape.isRequired,
|
|
||||||
post: postShape.isRequired,
|
|
||||||
preview: PropTypes.bool,
|
preview: PropTypes.bool,
|
||||||
|
hasEndorsed: PropTypes.bool.isRequired,
|
||||||
|
postType: PropTypes.string.isRequired,
|
||||||
|
authorLabel: PropTypes.string,
|
||||||
|
author: PropTypes.string.isRequired,
|
||||||
|
title: PropTypes.string.isRequired,
|
||||||
|
createdAt: PropTypes.string.isRequired,
|
||||||
|
abuseFlagged: PropTypes.bool,
|
||||||
|
lastEdit: PropTypes.shape({
|
||||||
|
reason: PropTypes.string,
|
||||||
|
}),
|
||||||
|
closed: PropTypes.bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
PostHeader.defaultProps = {
|
PostHeader.defaultProps = {
|
||||||
|
authorLabel: null,
|
||||||
preview: false,
|
preview: false,
|
||||||
|
abuseFlagged: false,
|
||||||
|
lastEdit: {},
|
||||||
|
closed: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default injectIntl(PostHeader);
|
export default React.memo(PostHeader);
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
/* eslint-disable react/no-unknown-property */
|
/* eslint-disable react/no-unknown-property */
|
||||||
import React, { useContext } from 'react';
|
import React, { useContext, useMemo } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
import { useSelector } from 'react-redux';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
@@ -14,37 +15,45 @@ import { AvatarOutlineAndLabelColors, Routes, ThreadType } from '../../../data/c
|
|||||||
import AuthorLabel from '../../common/AuthorLabel';
|
import AuthorLabel from '../../common/AuthorLabel';
|
||||||
import { DiscussionContext } from '../../common/context';
|
import { DiscussionContext } from '../../common/context';
|
||||||
import { discussionsPath, isPostPreviewAvailable } from '../../utils';
|
import { discussionsPath, isPostPreviewAvailable } from '../../utils';
|
||||||
|
import { selectThread } from '../data/selectors';
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
import { PostAvatar } from './PostHeader';
|
import { PostAvatar } from './PostHeader';
|
||||||
import PostSummaryFooter from './PostSummaryFooter';
|
import PostSummaryFooter from './PostSummaryFooter';
|
||||||
import { postShape } from './proptypes';
|
|
||||||
|
|
||||||
function PostLink({
|
const PostLink = ({
|
||||||
post,
|
|
||||||
isSelected,
|
|
||||||
showDivider,
|
|
||||||
idx,
|
idx,
|
||||||
}) {
|
postId,
|
||||||
|
showDivider,
|
||||||
|
}) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const {
|
const {
|
||||||
|
courseId,
|
||||||
|
postId: selectedPostId,
|
||||||
page,
|
page,
|
||||||
postId,
|
|
||||||
enableInContextSidebar,
|
enableInContextSidebar,
|
||||||
category,
|
category,
|
||||||
learnerUsername,
|
learnerUsername,
|
||||||
} = useContext(DiscussionContext);
|
} = useContext(DiscussionContext);
|
||||||
|
const {
|
||||||
|
topicId, hasEndorsed, type, author, authorLabel, abuseFlagged, abuseFlaggedCount, read, commentCount,
|
||||||
|
unreadCommentCount, id, pinned, previewBody, title, voted, voteCount, following, groupId, groupName, createdAt,
|
||||||
|
} = useSelector(selectThread(postId));
|
||||||
const linkUrl = discussionsPath(Routes.COMMENTS.PAGES[page], {
|
const linkUrl = discussionsPath(Routes.COMMENTS.PAGES[page], {
|
||||||
0: enableInContextSidebar ? 'in-context' : undefined,
|
0: enableInContextSidebar ? 'in-context' : undefined,
|
||||||
courseId: post.courseId,
|
courseId,
|
||||||
topicId: post.topicId,
|
topicId,
|
||||||
postId: post.id,
|
postId,
|
||||||
category,
|
category,
|
||||||
learnerUsername,
|
learnerUsername,
|
||||||
});
|
});
|
||||||
const showAnsweredBadge = post.hasEndorsed && post.type === ThreadType.QUESTION;
|
const showAnsweredBadge = hasEndorsed && type === ThreadType.QUESTION;
|
||||||
const authorLabelColor = AvatarOutlineAndLabelColors[post.authorLabel];
|
const authorLabelColor = AvatarOutlineAndLabelColors[authorLabel];
|
||||||
const canSeeReportedBadge = post.abuseFlagged || post.abuseFlaggedCount;
|
const canSeeReportedBadge = abuseFlagged || abuseFlaggedCount;
|
||||||
const read = post.read || (!post.read && post.commentCount !== post.unreadCommentCount);
|
const isPostRead = read || (!read && commentCount !== unreadCommentCount);
|
||||||
|
|
||||||
|
const checkIsSelected = useMemo(() => (
|
||||||
|
window.location.pathname.includes(postId)),
|
||||||
|
[window.location.pathname]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -55,19 +64,24 @@ function PostLink({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
to={linkUrl}
|
to={linkUrl}
|
||||||
onClick={() => isSelected(post.id)}
|
aria-current={checkIsSelected ? 'page' : undefined}
|
||||||
aria-current={isSelected(post.id) ? 'page' : undefined}
|
|
||||||
role="option"
|
role="option"
|
||||||
tabIndex={(isSelected(post.id) || idx === 0) ? 0 : -1}
|
tabIndex={(checkIsSelected || idx === 0) ? 0 : -1}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
classNames('d-flex flex-row pt-2 pb-2 px-4 border-primary-500 position-relative',
|
classNames('d-flex flex-row pt-2 pb-2 px-4 border-primary-500 position-relative',
|
||||||
{ 'bg-light-300': read },
|
{ 'bg-light-300': isPostRead },
|
||||||
{ 'post-summary-card-selected': post.id === postId })
|
{ 'post-summary-card-selected': id === selectedPostId })
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<PostAvatar post={post} authorLabel={post.authorLabel} fromPostLink read={read} />
|
<PostAvatar
|
||||||
|
postType={type}
|
||||||
|
author={author}
|
||||||
|
authorLabel={authorLabel}
|
||||||
|
fromPostLink
|
||||||
|
read={isPostRead}
|
||||||
|
/>
|
||||||
<div className="d-flex flex-column flex-fill" style={{ minWidth: 0 }}>
|
<div className="d-flex flex-column flex-fill" style={{ minWidth: 0 }}>
|
||||||
<div className="d-flex flex-column justify-content-start mw-100 flex-fill" style={{ marginBottom: '-3px' }}>
|
<div className="d-flex flex-column justify-content-start mw-100 flex-fill" style={{ marginBottom: '-3px' }}>
|
||||||
<div className="d-flex align-items-center pb-0 mb-0 flex-fill font-weight-500">
|
<div className="d-flex align-items-center pb-0 mb-0 flex-fill font-weight-500">
|
||||||
@@ -78,14 +92,14 @@ function PostLink({
|
|||||||
{ 'font-weight-bolder': !read })
|
{ 'font-weight-bolder': !read })
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{post.title}
|
{title}
|
||||||
</span>
|
</span>
|
||||||
<span class="align-bottom"> </span>
|
<span class="align-bottom"> </span>
|
||||||
<span
|
<span
|
||||||
class="text-gray-700 font-weight-normal font-size-14 font-style align-bottom"
|
class="text-gray-700 font-weight-normal font-size-14 font-style align-bottom"
|
||||||
>
|
>
|
||||||
{isPostPreviewAvailable(post.previewBody)
|
{isPostPreviewAvailable(previewBody)
|
||||||
? post.previewBody
|
? previewBody
|
||||||
: intl.formatMessage(messages.postWithoutPreview)}
|
: intl.formatMessage(messages.postWithoutPreview)}
|
||||||
</span>
|
</span>
|
||||||
</Truncate>
|
</Truncate>
|
||||||
@@ -94,7 +108,6 @@ function PostLink({
|
|||||||
<span className="sr-only">{' '}answered</span>
|
<span className="sr-only">{' '}answered</span>
|
||||||
</Icon>
|
</Icon>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{canSeeReportedBadge && (
|
{canSeeReportedBadge && (
|
||||||
<Badge
|
<Badge
|
||||||
variant="danger"
|
variant="danger"
|
||||||
@@ -105,8 +118,7 @@ function PostLink({
|
|||||||
<span className="sr-only">{' '}reported</span>
|
<span className="sr-only">{' '}reported</span>
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
{pinned && (
|
||||||
{post.pinned && (
|
|
||||||
<Icon
|
<Icon
|
||||||
src={PushPin}
|
src={PushPin}
|
||||||
className={`post-summary-icons-dimensions text-gray-700
|
className={`post-summary-icons-dimensions text-gray-700
|
||||||
@@ -116,29 +128,40 @@ function PostLink({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<AuthorLabel
|
<AuthorLabel
|
||||||
author={post.author || intl.formatMessage(messages.anonymous)}
|
author={author || intl.formatMessage(messages.anonymous)}
|
||||||
authorLabel={post.authorLabel}
|
authorLabel={authorLabel}
|
||||||
labelColor={authorLabelColor && `text-${authorLabelColor}`}
|
labelColor={authorLabelColor && `text-${authorLabelColor}`}
|
||||||
/>
|
/>
|
||||||
<PostSummaryFooter post={post} preview showNewCountLabel={read} />
|
<PostSummaryFooter
|
||||||
|
postId={id}
|
||||||
|
voted={voted}
|
||||||
|
voteCount={voteCount}
|
||||||
|
following={following}
|
||||||
|
commentCount={commentCount}
|
||||||
|
unreadCommentCount={unreadCommentCount}
|
||||||
|
groupId={groupId}
|
||||||
|
groupName={groupName}
|
||||||
|
createdAt={createdAt}
|
||||||
|
preview
|
||||||
|
showNewCountLabel={isPostRead}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!showDivider && post.pinned && <div className="pt-1 bg-light-500 border-top border-light-700" />}
|
{!showDivider && pinned && <div className="pt-1 bg-light-500 border-top border-light-700" />}
|
||||||
</Link>
|
</Link>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
PostLink.propTypes = {
|
PostLink.propTypes = {
|
||||||
post: postShape.isRequired,
|
|
||||||
isSelected: PropTypes.func.isRequired,
|
|
||||||
showDivider: PropTypes.bool,
|
|
||||||
idx: PropTypes.number,
|
idx: PropTypes.number,
|
||||||
|
postId: PropTypes.string.isRequired,
|
||||||
|
showDivider: PropTypes.bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
PostLink.defaultProps = {
|
PostLink.defaultProps = {
|
||||||
showDivider: true,
|
|
||||||
idx: -1,
|
idx: -1,
|
||||||
|
showDivider: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default PostLink;
|
export default React.memo(PostLink);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
|
|||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
import * as timeago from 'timeago.js';
|
import * as timeago from 'timeago.js';
|
||||||
|
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import {
|
import {
|
||||||
Badge, Icon, OverlayTrigger, Tooltip,
|
Badge, Icon, OverlayTrigger, Tooltip,
|
||||||
} from '@edx/paragon';
|
} from '@edx/paragon';
|
||||||
@@ -16,78 +16,86 @@ import { People, QuestionAnswer, QuestionAnswerOutline } from '../../../componen
|
|||||||
import timeLocale from '../../common/time-locale';
|
import timeLocale from '../../common/time-locale';
|
||||||
import { selectUserHasModerationPrivileges } from '../../data/selectors';
|
import { selectUserHasModerationPrivileges } from '../../data/selectors';
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
import { postShape } from './proptypes';
|
|
||||||
|
|
||||||
function PostSummaryFooter({
|
const PostSummaryFooter = ({
|
||||||
post,
|
postId,
|
||||||
intl,
|
voted,
|
||||||
|
voteCount,
|
||||||
|
following,
|
||||||
|
commentCount,
|
||||||
|
unreadCommentCount,
|
||||||
|
groupId,
|
||||||
|
groupName,
|
||||||
|
createdAt,
|
||||||
preview,
|
preview,
|
||||||
showNewCountLabel,
|
showNewCountLabel,
|
||||||
}) {
|
}) => {
|
||||||
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
|
|
||||||
timeago.register('time-locale', timeLocale);
|
timeago.register('time-locale', timeLocale);
|
||||||
|
const intl = useIntl();
|
||||||
|
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="d-flex align-items-center text-gray-700" style={{ height: '24px' }}>
|
<div className="d-flex align-items-center text-gray-700" style={{ height: '24px' }}>
|
||||||
<div className="d-flex align-items-center mr-4.5">
|
<div className="d-flex align-items-center mr-4.5">
|
||||||
<OverlayTrigger
|
<OverlayTrigger
|
||||||
overlay={(
|
overlay={(
|
||||||
<Tooltip id={`liked-${post.id}-tooltip`}>
|
<Tooltip id={`liked-${postId}-tooltip`}>
|
||||||
{intl.formatMessage(post.voted ? messages.likedPost : messages.postLikes)}
|
{intl.formatMessage(voted ? messages.likedPost : messages.postLikes)}
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon src={post.voted ? ThumbUpFilled : ThumbUpOutline} className="post-summary-like-dimensions mr-0.5">
|
<Icon src={voted ? ThumbUpFilled : ThumbUpOutline} className="post-summary-like-dimensions mr-0.5">
|
||||||
<span className="sr-only">{' '}{intl.formatMessage(post.voted ? messages.likedPost : messages.postLikes)}</span>
|
<span className="sr-only">{' '}{intl.formatMessage(voted ? messages.likedPost : messages.postLikes)}</span>
|
||||||
</Icon>
|
</Icon>
|
||||||
</OverlayTrigger>
|
</OverlayTrigger>
|
||||||
<div className="font-style">
|
<div className="font-style">
|
||||||
{(post.voteCount && post.voteCount > 0) ? post.voteCount : null}
|
{(voteCount && voteCount > 0) ? voteCount : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<OverlayTrigger
|
<OverlayTrigger
|
||||||
overlay={(
|
overlay={(
|
||||||
<Tooltip id={`follow-${post.id}-tooltip`}>
|
<Tooltip id={`follow-${postId}-tooltip`}>
|
||||||
{intl.formatMessage(post.following ? messages.followed : messages.notFollowed)}
|
{intl.formatMessage(following ? messages.followed : messages.notFollowed)}
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon src={post.following ? StarFilled : StarOutline} className="post-summary-icons-dimensions mr-0.5">
|
<Icon src={following ? StarFilled : StarOutline} className="post-summary-icons-dimensions mr-0.5">
|
||||||
<span className="sr-only">
|
<span className="sr-only">
|
||||||
{' '}{intl.formatMessage(post.following ? messages.srOnlyFollowDescription : messages.srOnlyUnFollowDescription)}
|
{' '}{intl.formatMessage(following ? messages.srOnlyFollowDescription : messages.srOnlyUnFollowDescription)}
|
||||||
</span>
|
</span>
|
||||||
</Icon>
|
</Icon>
|
||||||
</OverlayTrigger>
|
</OverlayTrigger>
|
||||||
|
|
||||||
{preview && post.commentCount > 1 && (
|
{preview && commentCount > 1 && (
|
||||||
<div className="d-flex align-items-center ml-4.5 text-gray-700 font-style font-size-12">
|
<div className="d-flex align-items-center ml-4.5 text-gray-700 font-style font-size-12">
|
||||||
<OverlayTrigger
|
<OverlayTrigger
|
||||||
overlay={(
|
overlay={(
|
||||||
<Tooltip id={`follow-${post.id}-tooltip`}>
|
<Tooltip id={`follow-${postId}-tooltip`}>
|
||||||
{intl.formatMessage(messages.activity)}
|
{intl.formatMessage(messages.activity)}
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
src={post.unreadCommentCount ? QuestionAnswer : QuestionAnswerOutline}
|
src={unreadCommentCount ? QuestionAnswer : QuestionAnswerOutline}
|
||||||
className="post-summary-comment-count-dimensions mr-0.5"
|
className="post-summary-comment-count-dimensions mr-0.5"
|
||||||
>
|
>
|
||||||
<span className="sr-only">{' '} {intl.formatMessage(messages.activity)}</span>
|
<span className="sr-only">{' '} {intl.formatMessage(messages.activity)}</span>
|
||||||
</Icon>
|
</Icon>
|
||||||
</OverlayTrigger>
|
</OverlayTrigger>
|
||||||
{post.commentCount}
|
{commentCount}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{showNewCountLabel && preview && post?.unreadCommentCount > 0 && post.commentCount > 1 && (
|
{showNewCountLabel && preview && unreadCommentCount > 0 && commentCount > 1 && (
|
||||||
<Badge variant="light" className="ml-2">
|
<Badge variant="light" className="ml-2">
|
||||||
{intl.formatMessage(messages.newLabel, { count: post.unreadCommentCount })}
|
{intl.formatMessage(messages.newLabel, { count: unreadCommentCount })}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
<div className="d-flex flex-fill justify-content-end align-items-center">
|
<div className="d-flex flex-fill justify-content-end align-items-center">
|
||||||
{post.groupId && userHasModerationPrivileges && (
|
{groupId && userHasModerationPrivileges && (
|
||||||
<OverlayTrigger
|
<OverlayTrigger
|
||||||
overlay={(
|
overlay={(
|
||||||
<Tooltip id={`visibility-${post.id}-tooltip`}>{post.groupName}</Tooltip>
|
<Tooltip id={`visibility-${postId}-tooltip`}>{groupName}</Tooltip>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span data-testid="cohort-icon" className="mr-2">
|
<span data-testid="cohort-icon" className="mr-2">
|
||||||
@@ -98,17 +106,24 @@ function PostSummaryFooter({
|
|||||||
</span>
|
</span>
|
||||||
</OverlayTrigger>
|
</OverlayTrigger>
|
||||||
)}
|
)}
|
||||||
<span title={post.createdAt} className="text-gray-700 post-summary-timestamp ml-0.5">
|
<span title={createdAt} className="text-gray-700 post-summary-timestamp ml-0.5">
|
||||||
{timeago.format(post.createdAt, 'time-locale')}
|
{timeago.format(createdAt, 'time-locale')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
PostSummaryFooter.propTypes = {
|
PostSummaryFooter.propTypes = {
|
||||||
intl: intlShape.isRequired,
|
postId: PropTypes.string.isRequired,
|
||||||
post: postShape.isRequired,
|
voted: PropTypes.bool.isRequired,
|
||||||
|
voteCount: PropTypes.number.isRequired,
|
||||||
|
following: PropTypes.bool.isRequired,
|
||||||
|
commentCount: PropTypes.number.isRequired,
|
||||||
|
unreadCommentCount: PropTypes.number.isRequired,
|
||||||
|
groupId: PropTypes.number,
|
||||||
|
groupName: PropTypes.string,
|
||||||
|
createdAt: PropTypes.string.isRequired,
|
||||||
preview: PropTypes.bool,
|
preview: PropTypes.bool,
|
||||||
showNewCountLabel: PropTypes.bool,
|
showNewCountLabel: PropTypes.bool,
|
||||||
};
|
};
|
||||||
@@ -116,6 +131,8 @@ PostSummaryFooter.propTypes = {
|
|||||||
PostSummaryFooter.defaultProps = {
|
PostSummaryFooter.defaultProps = {
|
||||||
preview: false,
|
preview: false,
|
||||||
showNewCountLabel: false,
|
showNewCountLabel: false,
|
||||||
|
groupId: null,
|
||||||
|
groupName: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default injectIntl(PostSummaryFooter);
|
export default React.memo(PostSummaryFooter);
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import React, { useContext, useEffect } from 'react';
|
import React, {
|
||||||
|
useCallback, useContext, useEffect, useMemo,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
import { useParams } from 'react-router';
|
import { useParams } from 'react-router';
|
||||||
@@ -16,49 +18,57 @@ import LegacyTopicGroup from './topic-group/LegacyTopicGroup';
|
|||||||
import Topic from './topic-group/topic/Topic';
|
import Topic from './topic-group/topic/Topic';
|
||||||
import countFilteredTopics from './utils';
|
import countFilteredTopics from './utils';
|
||||||
|
|
||||||
function CourseWideTopics() {
|
const CourseWideTopics = () => {
|
||||||
const { category } = useParams();
|
const { category } = useParams();
|
||||||
const filter = useSelector(selectTopicFilter);
|
const filter = useSelector(selectTopicFilter);
|
||||||
const nonCoursewareTopics = useSelector(selectNonCoursewareTopics);
|
const nonCoursewareTopics = useSelector(selectNonCoursewareTopics);
|
||||||
const filteredNonCoursewareTopics = nonCoursewareTopics.filter(item => (filter
|
|
||||||
? item.name.toLowerCase().includes(filter)
|
const filteredNonCoursewareTopics = useMemo(() => (
|
||||||
: true
|
nonCoursewareTopics.filter(item => (
|
||||||
));
|
filter ? item.name.toLowerCase().includes(filter) : true
|
||||||
|
))), [nonCoursewareTopics, filter]);
|
||||||
|
|
||||||
return (nonCoursewareTopics && category === undefined)
|
return (nonCoursewareTopics && category === undefined)
|
||||||
&& filteredNonCoursewareTopics.map((topic, index) => (
|
&& filteredNonCoursewareTopics.map((topic, index) => (
|
||||||
<Topic
|
<Topic
|
||||||
topic={topic}
|
topicId={topic.id}
|
||||||
key={topic.id}
|
key={topic.id}
|
||||||
index={index}
|
index={index}
|
||||||
showDivider={(filteredNonCoursewareTopics.length - 1) !== index}
|
showDivider={(filteredNonCoursewareTopics.length - 1) !== index}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
}
|
};
|
||||||
|
|
||||||
function LegacyCoursewareTopics() {
|
const LegacyCoursewareTopics = () => {
|
||||||
const { category } = useParams();
|
const { category } = useParams();
|
||||||
const categories = useSelector(selectCategories)
|
const categories = useSelector(selectCategories);
|
||||||
.filter(cat => (category ? cat === category : true));
|
|
||||||
return categories?.map(
|
const filteredCategories = useMemo(() => (
|
||||||
topicGroup => (
|
categories.filter(cat => (category ? cat === category : true))
|
||||||
|
), [categories, category]);
|
||||||
|
|
||||||
|
return filteredCategories?.map(
|
||||||
|
categoryId => (
|
||||||
<LegacyTopicGroup
|
<LegacyTopicGroup
|
||||||
id={topicGroup}
|
categoryId={categoryId}
|
||||||
category={topicGroup}
|
key={categoryId}
|
||||||
key={topicGroup}
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
function TopicsView() {
|
const TopicsView = () => {
|
||||||
|
const dispatch = useDispatch();
|
||||||
const provider = useSelector(selectDiscussionProvider);
|
const provider = useSelector(selectDiscussionProvider);
|
||||||
const topicFilter = useSelector(selectTopicFilter);
|
const topicFilter = useSelector(selectTopicFilter);
|
||||||
const topicsSelector = useSelector(({ topics }) => topics);
|
const topicsSelector = useSelector(({ topics }) => topics);
|
||||||
const filteredTopicsCount = useSelector(({ topics }) => topics.results.count);
|
const filteredTopicsCount = useSelector(({ topics }) => topics.results.count);
|
||||||
const loadingStatus = useSelector(({ topics }) => topics.status);
|
const loadingStatus = useSelector(({ topics }) => topics.status);
|
||||||
const { courseId } = useContext(DiscussionContext);
|
const { courseId } = useContext(DiscussionContext);
|
||||||
const dispatch = useDispatch();
|
|
||||||
|
const handleOnClear = useCallback(() => {
|
||||||
|
dispatch(setFilter(''));
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Don't load till the provider information is available
|
// Don't load till the provider information is available
|
||||||
@@ -79,7 +89,7 @@ function TopicsView() {
|
|||||||
text={topicFilter}
|
text={topicFilter}
|
||||||
count={filteredTopicsCount}
|
count={filteredTopicsCount}
|
||||||
loadingStatus={loadingStatus}
|
loadingStatus={loadingStatus}
|
||||||
onClear={() => dispatch(setFilter(''))}
|
onClear={handleOnClear}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="list-group list-group-flush flex-fill" role="list" onKeyDown={e => handleKeyDown(e)}>
|
<div className="list-group list-group-flush flex-fill" role="list" onKeyDown={e => handleKeyDown(e)}>
|
||||||
@@ -94,8 +104,6 @@ function TopicsView() {
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
TopicsView.propTypes = {};
|
|
||||||
|
|
||||||
export default TopicsView;
|
export default TopicsView;
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ export const selectTopicsInCategory = (categoryId) => state => (
|
|||||||
state.topics.topicsInCategory[categoryId]?.map(id => state.topics.topics[id]) || []
|
state.topics.topicsInCategory[categoryId]?.map(id => state.topics.topics[id]) || []
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const selectTopicsInCategoryIds = (categoryId) => state => (
|
||||||
|
state.topics.topicsInCategory[categoryId] || []
|
||||||
|
);
|
||||||
|
|
||||||
export const selectTopics = state => state.topics.topics;
|
export const selectTopics = state => state.topics.topics;
|
||||||
export const selectCoursewareTopics = createSelector(
|
export const selectCoursewareTopics = createSelector(
|
||||||
selectCategories,
|
selectCategories,
|
||||||
|
|||||||
@@ -3,27 +3,23 @@ import PropTypes from 'prop-types';
|
|||||||
|
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
|
|
||||||
import { selectTopicsInCategory } from '../data/selectors';
|
import { selectTopicsInCategoryIds } from '../data/selectors';
|
||||||
import TopicGroupBase from './TopicGroupBase';
|
import TopicGroupBase from './TopicGroupBase';
|
||||||
|
|
||||||
function LegacyTopicGroup({
|
const LegacyTopicGroup = ({ categoryId }) => {
|
||||||
id,
|
const topicsIds = useSelector(selectTopicsInCategoryIds(categoryId));
|
||||||
category,
|
|
||||||
}) {
|
|
||||||
const topics = useSelector(selectTopicsInCategory(category));
|
|
||||||
return (
|
return (
|
||||||
<TopicGroupBase groupId={id} groupTitle={category} topics={topics} />
|
<TopicGroupBase groupId={categoryId} groupTitle={categoryId} topicsIds={topicsIds} />
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
LegacyTopicGroup.propTypes = {
|
LegacyTopicGroup.propTypes = {
|
||||||
id: PropTypes.string,
|
categoryId: PropTypes.string,
|
||||||
category: PropTypes.string,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
LegacyTopicGroup.defaultProps = {
|
LegacyTopicGroup.defaultProps = {
|
||||||
id: null,
|
categoryId: null,
|
||||||
category: null,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default LegacyTopicGroup;
|
export default React.memo(LegacyTopicGroup);
|
||||||
|
|||||||
@@ -1,43 +1,63 @@
|
|||||||
import React, { useContext } from 'react';
|
import React, { useContext, useMemo } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
import { Routes } from '../../../data/constants';
|
import { Routes } from '../../../data/constants';
|
||||||
import { DiscussionContext } from '../../common/context';
|
import { DiscussionContext } from '../../common/context';
|
||||||
import { discussionsPath } from '../../utils';
|
import { discussionsPath } from '../../utils';
|
||||||
import { selectTopicFilter } from '../data/selectors';
|
import { selectTopicFilter, selectTopicsById } from '../data/selectors';
|
||||||
import messages from '../messages';
|
import messages from '../messages';
|
||||||
import Topic, { topicShape } from './topic/Topic';
|
import Topic from './topic/Topic';
|
||||||
|
|
||||||
function TopicGroupBase({
|
const TopicGroupBase = ({
|
||||||
groupId,
|
groupId,
|
||||||
groupTitle,
|
groupTitle,
|
||||||
linkToGroup,
|
linkToGroup,
|
||||||
topics,
|
topicsIds,
|
||||||
intl,
|
}) => {
|
||||||
}) {
|
const intl = useIntl();
|
||||||
const { courseId } = useContext(DiscussionContext);
|
const { courseId } = useContext(DiscussionContext);
|
||||||
const filter = useSelector(selectTopicFilter);
|
const filter = useSelector(selectTopicFilter);
|
||||||
|
const topics = useSelector(selectTopicsById(topicsIds));
|
||||||
const hasTopics = topics.length > 0;
|
const hasTopics = topics.length > 0;
|
||||||
const matchesFilter = filter
|
|
||||||
? groupTitle?.toLowerCase().includes(filter)
|
|
||||||
: true;
|
|
||||||
|
|
||||||
const filteredTopicElements = topics.filter(
|
const matchesFilter = useMemo(() => (
|
||||||
topic => (filter
|
filter ? groupTitle?.toLowerCase().includes(filter) : true
|
||||||
? (topic.name.toLowerCase().includes(filter) || matchesFilter)
|
), [filter, groupTitle]);
|
||||||
: true
|
|
||||||
),
|
const filteredTopicElements = useMemo(() => (
|
||||||
);
|
topics.filter(topic => (
|
||||||
|
filter ? (topic.name.toLowerCase().includes(filter) || matchesFilter) : true
|
||||||
|
))
|
||||||
|
), [topics, filter, matchesFilter]);
|
||||||
|
|
||||||
const hasFilteredSubtopics = (filteredTopicElements.length > 0);
|
const hasFilteredSubtopics = (filteredTopicElements.length > 0);
|
||||||
|
|
||||||
|
const renderFilteredTopics = useMemo(() => {
|
||||||
|
if (!hasFilteredSubtopics) {
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
filteredTopicElements.map((topic, index) => (
|
||||||
|
<Topic
|
||||||
|
topicId={topic.id}
|
||||||
|
key={topic.id}
|
||||||
|
index={index}
|
||||||
|
showDivider={(filteredTopicElements.length - 1) !== index}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
);
|
||||||
|
}, [filteredTopicElements]);
|
||||||
|
|
||||||
if (!hasTopics || (!matchesFilter && !hasFilteredSubtopics)) {
|
if (!hasTopics || (!matchesFilter && !hasFilteredSubtopics)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="discussion-topic-group d-flex flex-column text-primary-500"
|
className="discussion-topic-group d-flex flex-column text-primary-500"
|
||||||
@@ -45,43 +65,34 @@ function TopicGroupBase({
|
|||||||
data-testid="topic-group"
|
data-testid="topic-group"
|
||||||
>
|
>
|
||||||
<div className="pt-2.5 px-4 font-weight-bold">
|
<div className="pt-2.5 px-4 font-weight-bold">
|
||||||
{linkToGroup && groupId
|
{linkToGroup && groupId ? (
|
||||||
? (
|
<Link
|
||||||
<Link
|
className="text-decoration-none text-primary-500"
|
||||||
className="text-decoration-none text-primary-500"
|
to={discussionsPath(Routes.TOPICS.CATEGORY, {
|
||||||
to={discussionsPath(Routes.TOPICS.CATEGORY, {
|
courseId,
|
||||||
courseId,
|
category: groupId,
|
||||||
category: groupId,
|
})}
|
||||||
})}
|
>
|
||||||
>
|
{groupTitle}
|
||||||
{groupTitle}
|
</Link>
|
||||||
</Link>
|
) : (
|
||||||
) : (
|
groupTitle || intl.formatMessage(messages.unnamedTopicCategories)
|
||||||
groupTitle || intl.formatMessage(messages.unnamedTopicCategories)
|
)}
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{filteredTopicElements.map((topic, index) => (
|
{renderFilteredTopics}
|
||||||
<Topic
|
|
||||||
topic={topic}
|
|
||||||
key={topic.id}
|
|
||||||
index={index}
|
|
||||||
showDivider={(filteredTopicElements.length - 1) !== index}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
TopicGroupBase.propTypes = {
|
TopicGroupBase.propTypes = {
|
||||||
groupId: PropTypes.string.isRequired,
|
groupId: PropTypes.string.isRequired,
|
||||||
groupTitle: PropTypes.string.isRequired,
|
groupTitle: PropTypes.string.isRequired,
|
||||||
topics: PropTypes.arrayOf(topicShape).isRequired,
|
topicsIds: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||||
linkToGroup: PropTypes.bool,
|
linkToGroup: PropTypes.bool,
|
||||||
intl: intlShape.isRequired,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
TopicGroupBase.defaultProps = {
|
TopicGroupBase.defaultProps = {
|
||||||
linkToGroup: true,
|
linkToGroup: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default injectIntl(TopicGroupBase);
|
export default React.memo(TopicGroupBase);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/* eslint-disable no-unused-vars, react/forbid-prop-types */
|
/* eslint-disable no-unused-vars, react/forbid-prop-types */
|
||||||
import React from 'react';
|
import React, { useCallback } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
@@ -7,31 +7,31 @@ import { useSelector } from 'react-redux';
|
|||||||
import { useParams } from 'react-router';
|
import { useParams } from 'react-router';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import { Icon, OverlayTrigger, Tooltip } from '@edx/paragon';
|
import { Icon, OverlayTrigger, Tooltip } from '@edx/paragon';
|
||||||
import { HelpOutline, PostOutline, Report } from '@edx/paragon/icons';
|
import { HelpOutline, PostOutline, Report } from '@edx/paragon/icons';
|
||||||
|
|
||||||
import { Routes } from '../../../../data/constants';
|
import { Routes } from '../../../../data/constants';
|
||||||
import { selectUserHasModerationPrivileges, selectUserIsGroupTa } from '../../../data/selectors';
|
import { selectUserHasModerationPrivileges, selectUserIsGroupTa } from '../../../data/selectors';
|
||||||
import { discussionsPath } from '../../../utils';
|
import { discussionsPath } from '../../../utils';
|
||||||
|
import { selectTopic } from '../../data/selectors';
|
||||||
import messages from '../../messages';
|
import messages from '../../messages';
|
||||||
|
|
||||||
function Topic({
|
const Topic = ({ topicId, showDivider, index }) => {
|
||||||
topic,
|
const intl = useIntl();
|
||||||
showDivider,
|
|
||||||
index,
|
|
||||||
intl,
|
|
||||||
}) {
|
|
||||||
const { courseId } = useParams();
|
const { courseId } = useParams();
|
||||||
const topicUrl = discussionsPath(Routes.TOPICS.TOPIC, {
|
const topic = useSelector(selectTopic(topicId));
|
||||||
courseId,
|
const {
|
||||||
topicId: topic.id,
|
id, inactiveFlags, activeFlags, name, threadCounts,
|
||||||
});
|
} = topic;
|
||||||
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
|
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
|
||||||
const userIsGroupTa = useSelector(selectUserIsGroupTa);
|
const userIsGroupTa = useSelector(selectUserIsGroupTa);
|
||||||
const { inactiveFlags, activeFlags } = topic;
|
|
||||||
const canSeeReportedStats = (activeFlags || inactiveFlags) && (userHasModerationPrivileges || userIsGroupTa);
|
const canSeeReportedStats = (activeFlags || inactiveFlags) && (userHasModerationPrivileges || userIsGroupTa);
|
||||||
const isSelected = (id) => window.location.pathname.includes(id);
|
const topicUrl = discussionsPath(Routes.TOPICS.TOPIC, { courseId, topicId });
|
||||||
|
|
||||||
|
const isSelected = useCallback((selectedId) => (
|
||||||
|
window.location.pathname.includes(selectedId)
|
||||||
|
), []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
@@ -40,18 +40,18 @@ function Topic({
|
|||||||
'border-bottom border-light-400': showDivider,
|
'border-bottom border-light-400': showDivider,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
data-topic-id={topic.id}
|
data-topic-id={id}
|
||||||
to={topicUrl}
|
to={topicUrl}
|
||||||
onClick={() => isSelected(topic.id)}
|
onClick={() => isSelected(id)}
|
||||||
aria-current={isSelected(topic.id) ? 'page' : undefined}
|
aria-current={isSelected(id) ? 'page' : undefined}
|
||||||
role="option"
|
role="option"
|
||||||
tabIndex={(isSelected(topic.id) || index === 0) ? 0 : -1}
|
tabIndex={(isSelected(id) || index === 0) ? 0 : -1}
|
||||||
>
|
>
|
||||||
<div className="d-flex flex-row pt-2.5 pb-2 px-4">
|
<div className="d-flex flex-row pt-2.5 pb-2 px-4">
|
||||||
<div className="d-flex flex-column flex-fill" style={{ minWidth: 0 }}>
|
<div className="d-flex flex-column flex-fill" style={{ minWidth: 0 }}>
|
||||||
<div className="d-flex flex-column justify-content-start mw-100 flex-fill">
|
<div className="d-flex flex-column justify-content-start mw-100 flex-fill">
|
||||||
<div className="topic-name text-truncate">
|
<div className="topic-name text-truncate">
|
||||||
{topic.name || intl.formatMessage(messages.unnamedTopicSubCategories)}
|
{name || intl.formatMessage(messages.unnamedTopicSubCategories)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="d-flex align-items-center mt-2.5" style={{ marginBottom: '2px' }}>
|
<div className="d-flex align-items-center mt-2.5" style={{ marginBottom: '2px' }}>
|
||||||
@@ -61,7 +61,7 @@ function Topic({
|
|||||||
<Tooltip>
|
<Tooltip>
|
||||||
<div className="d-flex flex-column align-items-start">
|
<div className="d-flex flex-column align-items-start">
|
||||||
{intl.formatMessage(messages.discussions, {
|
{intl.formatMessage(messages.discussions, {
|
||||||
count: topic.threadCounts?.discussion || 0,
|
count: threadCounts?.discussion || 0,
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -69,7 +69,7 @@ function Topic({
|
|||||||
>
|
>
|
||||||
<div className="d-flex align-items-center mr-3.5">
|
<div className="d-flex align-items-center mr-3.5">
|
||||||
<Icon src={PostOutline} className="icon-size mr-2" />
|
<Icon src={PostOutline} className="icon-size mr-2" />
|
||||||
{topic.threadCounts?.discussion || 0}
|
{threadCounts?.discussion || 0}
|
||||||
</div>
|
</div>
|
||||||
</OverlayTrigger>
|
</OverlayTrigger>
|
||||||
<OverlayTrigger
|
<OverlayTrigger
|
||||||
@@ -78,7 +78,7 @@ function Topic({
|
|||||||
<Tooltip>
|
<Tooltip>
|
||||||
<div className="d-flex flex-column align-items-start">
|
<div className="d-flex flex-column align-items-start">
|
||||||
{intl.formatMessage(messages.questions, {
|
{intl.formatMessage(messages.questions, {
|
||||||
count: topic.threadCounts?.question || 0,
|
count: threadCounts?.question || 0,
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -86,7 +86,7 @@ function Topic({
|
|||||||
>
|
>
|
||||||
<div className="d-flex align-items-center mr-3.5">
|
<div className="d-flex align-items-center mr-3.5">
|
||||||
<Icon src={HelpOutline} className="icon-size mr-2" />
|
<Icon src={HelpOutline} className="icon-size mr-2" />
|
||||||
{topic.threadCounts?.question || 0}
|
{threadCounts?.question || 0}
|
||||||
</div>
|
</div>
|
||||||
</OverlayTrigger>
|
</OverlayTrigger>
|
||||||
{Boolean(canSeeReportedStats) && (
|
{Boolean(canSeeReportedStats) && (
|
||||||
@@ -121,7 +121,7 @@ function Topic({
|
|||||||
{!showDivider && <div className="divider pt-1 bg-light-500 border-top border-light-700" />}
|
{!showDivider && <div className="divider pt-1 bg-light-500 border-top border-light-700" />}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export const topicShape = PropTypes.shape({
|
export const topicShape = PropTypes.shape({
|
||||||
name: PropTypes.string,
|
name: PropTypes.string,
|
||||||
@@ -130,9 +130,9 @@ export const topicShape = PropTypes.shape({
|
|||||||
discussions: PropTypes.number,
|
discussions: PropTypes.number,
|
||||||
flags: PropTypes.number,
|
flags: PropTypes.number,
|
||||||
});
|
});
|
||||||
|
|
||||||
Topic.propTypes = {
|
Topic.propTypes = {
|
||||||
intl: intlShape.isRequired,
|
topicId: PropTypes.string.isRequired,
|
||||||
topic: topicShape.isRequired,
|
|
||||||
showDivider: PropTypes.bool,
|
showDivider: PropTypes.bool,
|
||||||
index: PropTypes.number,
|
index: PropTypes.number,
|
||||||
};
|
};
|
||||||
@@ -142,4 +142,4 @@ Topic.defaultProps = {
|
|||||||
index: -1,
|
index: -1,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default injectIntl(Topic);
|
export default React.memo(Topic);
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
|
|
||||||
import isEmpty from 'lodash/isEmpty';
|
import isEmpty from 'lodash/isEmpty';
|
||||||
import { useDispatch } from 'react-redux';
|
import { useDispatch } from 'react-redux';
|
||||||
|
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
|
||||||
import { ProductTour } from '@edx/paragon';
|
import { ProductTour } from '@edx/paragon';
|
||||||
|
|
||||||
import { useTourConfiguration } from '../data/hooks';
|
import { useTourConfiguration } from '../data/hooks';
|
||||||
import { fetchDiscussionTours } from './data/thunks';
|
import { fetchDiscussionTours } from './data/thunks';
|
||||||
|
|
||||||
function DiscussionsProductTour({ intl }) {
|
const DiscussionsProductTour = () => {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const config = useTourConfiguration(intl);
|
const config = useTourConfiguration();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch(fetchDiscussionTours());
|
dispatch(fetchDiscussionTours());
|
||||||
}, []);
|
}, []);
|
||||||
@@ -25,10 +25,6 @@ function DiscussionsProductTour({ intl }) {
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
DiscussionsProductTour.propTypes = {
|
|
||||||
intl: intlShape.isRequired,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default injectIntl(DiscussionsProductTour);
|
export default DiscussionsProductTour;
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
/* eslint-disable import/prefer-default-export */
|
import { useCallback, useContext, useMemo } from 'react';
|
||||||
|
|
||||||
import { getIn } from 'formik';
|
import { getIn } from 'formik';
|
||||||
import { uniqBy } from 'lodash';
|
import { uniqBy } from 'lodash';
|
||||||
|
import { useSelector } from 'react-redux';
|
||||||
import { generatePath, useRouteMatch } from 'react-router';
|
import { generatePath, useRouteMatch } from 'react-router';
|
||||||
|
|
||||||
import { getConfig } from '@edx/frontend-platform';
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
@@ -10,6 +12,8 @@ import {
|
|||||||
|
|
||||||
import { InsertLink } from '../components/icons';
|
import { InsertLink } from '../components/icons';
|
||||||
import { ContentActions, Routes, ThreadType } from '../data/constants';
|
import { ContentActions, Routes, ThreadType } from '../data/constants';
|
||||||
|
import { ContentSelectors } from './data/constants';
|
||||||
|
import { PostCommentsContext } from './post-comments/postCommentsContext';
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -175,20 +179,26 @@ export const ACTIONS_LIST = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function useActions(content) {
|
export function useActions(contentType, id) {
|
||||||
const checkConditions = (item, conditions) => (
|
const postType = useContext(PostCommentsContext);
|
||||||
|
const content = { ...useSelector(ContentSelectors[contentType](id)), postType };
|
||||||
|
|
||||||
|
const checkConditions = useCallback((item, conditions) => (
|
||||||
conditions
|
conditions
|
||||||
? Object.keys(conditions)
|
? Object.keys(conditions)
|
||||||
.map(key => item[key] === conditions[key])
|
.map(key => item[key] === conditions[key])
|
||||||
.every(condition => condition === true)
|
.every(condition => condition === true)
|
||||||
: true
|
: true
|
||||||
);
|
), []);
|
||||||
return ACTIONS_LIST.filter(
|
|
||||||
|
const actions = useMemo(() => ACTIONS_LIST.filter(
|
||||||
({
|
({
|
||||||
action,
|
action,
|
||||||
conditions = null,
|
conditions = null,
|
||||||
}) => checkPermissions(content, action) && checkConditions(content, conditions),
|
}) => checkPermissions(content, action) && checkConditions(content, conditions),
|
||||||
);
|
), [content]);
|
||||||
|
|
||||||
|
return actions;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const formikCompatibleHandler = (formikHandler, name) => (value) => formikHandler({
|
export const formikCompatibleHandler = (formikHandler, name) => (value) => formikHandler({
|
||||||
|
|||||||
@@ -178,18 +178,18 @@ $fa-font-path: "~font-awesome/fonts";
|
|||||||
background-color: unset !important;
|
background-color: unset !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.learner>a:hover {
|
.learner > a:hover {
|
||||||
background-color: #F2F0EF;
|
background-color: #F2F0EF;
|
||||||
}
|
}
|
||||||
|
|
||||||
.py-10px {
|
.py-10px {
|
||||||
padding-top: 10px;
|
padding-top: 10px !important;
|
||||||
padding-bottom: 10px;
|
padding-bottom: 10px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.py-8px {
|
.py-8px {
|
||||||
padding-top: 8px;
|
padding-top: 8px !important;
|
||||||
padding-bottom: 8px;
|
padding-bottom: 8px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pb-10px {
|
.pb-10px {
|
||||||
@@ -530,3 +530,10 @@ header {
|
|||||||
position: relative;
|
position: relative;
|
||||||
background-color: #fff;
|
background-color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.spinner-container {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user