Compare commits
27 Commits
inf-799
...
Ayesha/inf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67b0216a1d | ||
|
|
d0d80a2b17 | ||
|
|
5240d0d5a4 | ||
|
|
b171de291e | ||
|
|
24163d15c0 | ||
|
|
58b6b69cb0 | ||
|
|
025edf7b66 | ||
|
|
1e47d102a3 | ||
|
|
39da42ee3f | ||
|
|
15aee6a534 | ||
|
|
cd2d67e137 | ||
|
|
c4f861c24f | ||
|
|
78a85255e4 | ||
|
|
b21048e4e6 | ||
|
|
45dea79a87 | ||
|
|
530f2cec82 | ||
|
|
91cb347456 | ||
|
|
04745d6429 | ||
|
|
aad6702339 | ||
|
|
d39a196cdf | ||
|
|
7b000f1974 | ||
|
|
627390c4e3 | ||
|
|
1db94718c8 | ||
|
|
24d02350a8 | ||
|
|
f66cdda1b6 | ||
|
|
07b56e6070 | ||
|
|
be1a2ccaab |
2
Makefile
2
Makefile
@@ -5,8 +5,6 @@ transifex_langs = "ar,fr,es_419,zh_CN,tr_TR,pl,fr_CA,fr_FR,de_DE,it_IT"
|
||||
transifex_utils = ./node_modules/.bin/transifex-utils.js
|
||||
i18n = ./src/i18n
|
||||
transifex_input = $(i18n)/transifex_input.json
|
||||
tx_url1 = https://www.transifex.com/api/2/project/edx-platform/resource/$(transifex_resource)/translation/en/strings/
|
||||
tx_url2 = https://www.transifex.com/api/2/project/edx-platform/resource/$(transifex_resource)/source/
|
||||
|
||||
# This directory must match .babelrc .
|
||||
transifex_temp = ./temp/babel-plugin-react-intl
|
||||
|
||||
@@ -180,10 +180,7 @@
|
||||
var r = (window.lightningjs = t(e));
|
||||
(r.require = t), (r.modules = n);
|
||||
})({});
|
||||
window.usabilla_live = lightningjs.require(
|
||||
"usabilla_live",
|
||||
"//w.usabilla.com/9e6036348fa1.js"
|
||||
);
|
||||
|
||||
</script>
|
||||
<!-- end usabilla live embed code -->
|
||||
</body>
|
||||
|
||||
@@ -16,27 +16,32 @@ function HTMLLoader({
|
||||
htmlNode, componentId, cssClassName, testId, delay,
|
||||
}) {
|
||||
const sanitizedMath = DOMPurify.sanitize(htmlNode, { ...defaultSanitizeOptions });
|
||||
const previewRef = useRef();
|
||||
|
||||
const previewRef = useRef(null);
|
||||
const debouncedPostContent = useDebounce(htmlNode, delay);
|
||||
|
||||
useEffect(() => {
|
||||
let promise = Promise.resolve(); // Used to hold chain of typesetting calls
|
||||
|
||||
function typeset(code) {
|
||||
promise = promise.then(() => window.MathJax?.typesetPromise(code()))
|
||||
promise = promise.then(() => {
|
||||
if (typeof window?.MathJax !== 'undefined') { return window.MathJax?.typesetPromise(code()); }
|
||||
return null;
|
||||
})
|
||||
.catch((err) => logError(`Typeset failed: ${err.message}`));
|
||||
return promise;
|
||||
}
|
||||
|
||||
if (debouncedPostContent) {
|
||||
typeset(() => {
|
||||
previewRef.current.innerHTML = sanitizedMath;
|
||||
if (previewRef.current !== null && typeof window?.MathJax !== 'undefined') {
|
||||
previewRef.current.innerHTML = sanitizedMath;
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [debouncedPostContent]);
|
||||
|
||||
return (
|
||||
<div ref={previewRef} className={cssClassName} id={componentId} data-testid={testId} />
|
||||
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Close } from '@edx/paragon/icons';
|
||||
import messages from '../discussions/posts/post-editor/messages';
|
||||
import HTMLLoader from './HTMLLoader';
|
||||
|
||||
function PostPreviewPane({
|
||||
function PostPreviewPanel({
|
||||
htmlNode, intl, isPost, editExisting,
|
||||
}) {
|
||||
const [showPreviewPane, setShowPreviewPane] = useState(false);
|
||||
@@ -28,6 +28,7 @@ function PostPreviewPane({
|
||||
size="inline"
|
||||
className="float-right p-3"
|
||||
iconClassNames="icon-size"
|
||||
data-testid="hide-preview-button"
|
||||
/>
|
||||
<HTMLLoader
|
||||
htmlNode={htmlNode}
|
||||
@@ -46,6 +47,7 @@ function PostPreviewPane({
|
||||
onClick={() => setShowPreviewPane(true)}
|
||||
className={`text-primary-500 font-style p-0 ${editExisting && 'mb-4.5'}`}
|
||||
style={{ lineHeight: '26px' }}
|
||||
data-testid="show-preview-button"
|
||||
>
|
||||
{intl.formatMessage(messages.showPreviewButton)}
|
||||
</Button>
|
||||
@@ -55,16 +57,16 @@ function PostPreviewPane({
|
||||
);
|
||||
}
|
||||
|
||||
PostPreviewPane.propTypes = {
|
||||
PostPreviewPanel.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
htmlNode: PropTypes.node.isRequired,
|
||||
isPost: PropTypes.bool,
|
||||
editExisting: PropTypes.bool,
|
||||
};
|
||||
|
||||
PostPreviewPane.defaultProps = {
|
||||
PostPreviewPanel.defaultProps = {
|
||||
isPost: false,
|
||||
editExisting: false,
|
||||
};
|
||||
|
||||
export default injectIntl(PostPreviewPane);
|
||||
export default injectIntl(PostPreviewPanel);
|
||||
@@ -72,6 +72,7 @@ function Search({ intl }) {
|
||||
<Icon
|
||||
src={SearchIcon}
|
||||
onClick={() => onSubmit(searchValue)}
|
||||
data-testid="search-icon"
|
||||
/>
|
||||
</span>
|
||||
</SearchField.Advanced>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useContext, useState } from 'react';
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { useSelector } from 'react-redux';
|
||||
@@ -16,7 +16,6 @@ import messages from '../messages';
|
||||
import { commentShape } from '../post-comments/comments/comment/proptypes';
|
||||
import { postShape } from '../posts/post/proptypes';
|
||||
import { inBlackoutDateRange, useActions } from '../utils';
|
||||
import { DiscussionContext } from './context';
|
||||
|
||||
function ActionsDropdown({
|
||||
intl,
|
||||
@@ -26,41 +25,54 @@ function ActionsDropdown({
|
||||
iconSize,
|
||||
dropDownIconSize,
|
||||
}) {
|
||||
const buttonRef = useRef();
|
||||
const [isOpen, open, close] = useToggle(false);
|
||||
const [target, setTarget] = useState(null);
|
||||
const actions = useActions(commentOrPost);
|
||||
const { enableInContextSidebar } = useContext(DiscussionContext);
|
||||
const handleActions = (action) => {
|
||||
|
||||
const handleActions = useCallback((action) => {
|
||||
const actionFunction = actionHandlers[action];
|
||||
if (actionFunction) {
|
||||
actionFunction();
|
||||
} else {
|
||||
logError(`Unknown or unimplemented action ${action}`);
|
||||
}
|
||||
};
|
||||
}, [actionHandlers]);
|
||||
|
||||
const blackoutDateRange = useSelector(selectBlackoutDate);
|
||||
// Find and remove edit action if in blackout date range.
|
||||
if (inBlackoutDateRange(blackoutDateRange)) {
|
||||
actions.splice(actions.findIndex(action => action.id === 'edit'), 1);
|
||||
}
|
||||
|
||||
const onClickButton = useCallback(() => {
|
||||
setTarget(buttonRef.current);
|
||||
open();
|
||||
}, [open]);
|
||||
|
||||
const onCloseModal = useCallback(() => {
|
||||
close();
|
||||
setTarget(null);
|
||||
}, [close]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
onClick={open}
|
||||
onClick={onClickButton}
|
||||
alt={intl.formatMessage(messages.actionsAlt)}
|
||||
src={MoreHoriz}
|
||||
iconAs={Icon}
|
||||
disabled={disabled}
|
||||
size={iconSize}
|
||||
ref={setTarget}
|
||||
ref={buttonRef}
|
||||
iconClassNames={dropDownIconSize ? 'dropdown-icon-dimentions' : ''}
|
||||
/>
|
||||
<div className="actions-dropdown">
|
||||
<ModalPopup
|
||||
onClose={close}
|
||||
onClose={onCloseModal}
|
||||
positionRef={target}
|
||||
isOpen={isOpen}
|
||||
placement={enableInContextSidebar ? 'left' : 'auto-start'}
|
||||
placement="bottom-end"
|
||||
>
|
||||
<div
|
||||
className="bg-white p-1 shadow d-flex flex-column"
|
||||
|
||||
@@ -44,34 +44,31 @@ function AuthorLabel({
|
||||
|
||||
const isRetiredUser = author ? author.startsWith('retired__user') : false;
|
||||
const showTextPrimary = !authorLabelMessage && !isRetiredUser && !alert;
|
||||
|
||||
const className = classNames('d-flex align-items-center', { 'mb-0.5': !postOrComment }, labelColor);
|
||||
|
||||
const showUserNameAsLink = useShowLearnersTab()
|
||||
&& linkToProfile && author && author !== intl.formatMessage(messages.anonymous);
|
||||
|
||||
const authorName = (
|
||||
<span
|
||||
className={classNames('mr-1.5 font-size-14 font-style font-weight-500', {
|
||||
'text-gray-700': isRetiredUser,
|
||||
'text-primary-500': !authorLabelMessage && !isRetiredUser,
|
||||
})}
|
||||
role="heading"
|
||||
aria-level="2"
|
||||
>
|
||||
{isRetiredUser ? '[Deactivated]' : author}
|
||||
</span>
|
||||
);
|
||||
const labelContents = (
|
||||
<div className={className}>
|
||||
{!alert && (
|
||||
<span
|
||||
className={classNames('mr-1.5 font-size-14 font-style font-weight-500', {
|
||||
'text-gray-700': isRetiredUser,
|
||||
'text-primary-500': !authorLabelMessage && !isRetiredUser,
|
||||
})}
|
||||
role="heading"
|
||||
aria-level="2"
|
||||
>
|
||||
{isRetiredUser ? '[Deactivated]' : author}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<>
|
||||
<OverlayTrigger
|
||||
overlay={(
|
||||
<Tooltip id={`endorsed-by-${author}-tooltip`}>
|
||||
{author}
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
trigger={['hover', 'focus']}
|
||||
>
|
||||
<div className={classNames('d-flex flex-row align-items-center', {
|
||||
@@ -86,20 +83,19 @@ function AuthorLabel({
|
||||
src={icon}
|
||||
data-testid="author-icon"
|
||||
/>
|
||||
|
||||
{authorLabelMessage && (
|
||||
<span
|
||||
className={classNames('mr-1.5 font-size-14 font-style font-weight-500', {
|
||||
'text-primary-500': showTextPrimary,
|
||||
'text-gray-700': isRetiredUser,
|
||||
})}
|
||||
style={{ marginLeft: '2px' }}
|
||||
>
|
||||
{authorLabelMessage}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</OverlayTrigger>
|
||||
{authorLabelMessage && (
|
||||
<span
|
||||
className={classNames('mr-1.5 font-size-14 font-style font-weight-500', {
|
||||
'text-primary-500': showTextPrimary,
|
||||
'text-gray-700': isRetiredUser,
|
||||
})}
|
||||
style={{ marginLeft: '2px' }}
|
||||
>
|
||||
{authorLabelMessage}
|
||||
</span>
|
||||
)}
|
||||
{postCreatedAt && (
|
||||
<span
|
||||
title={postCreatedAt}
|
||||
@@ -112,23 +108,25 @@ function AuthorLabel({
|
||||
{timeago.format(postCreatedAt, 'time-locale')}
|
||||
</span>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return showUserNameAsLink
|
||||
? (
|
||||
<Link
|
||||
data-testid="learner-posts-link"
|
||||
id="learner-posts-link"
|
||||
to={discussionsPath(Routes.LEARNERS.POSTS, { learnerUsername: author, courseId })(location)}
|
||||
className="text-decoration-none"
|
||||
style={{ width: 'fit-content' }}
|
||||
>
|
||||
<div className={className}>
|
||||
<Link
|
||||
data-testid="learner-posts-link"
|
||||
id="learner-posts-link"
|
||||
to={discussionsPath(Routes.LEARNERS.POSTS, { learnerUsername: author, courseId })(location)}
|
||||
className="text-decoration-none"
|
||||
style={{ width: 'fit-content' }}
|
||||
>
|
||||
{!alert && authorName}
|
||||
</Link>
|
||||
{labelContents}
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
: <>{labelContents}</>;
|
||||
: <div className={className}>{authorName}{labelContents}</div>;
|
||||
}
|
||||
|
||||
AuthorLabel.propTypes = {
|
||||
|
||||
@@ -93,12 +93,13 @@ describe('Author label', () => {
|
||||
async () => {
|
||||
renderComponent(author, authorLabel, linkToProfile, labelColor);
|
||||
const authorElement = container.querySelector('[role=heading]');
|
||||
const labelElement = authorElement.parentNode.lastChild;
|
||||
const labelParentNode = authorElement.parentNode.parentNode;
|
||||
const labelElement = labelParentNode.lastChild.lastChild;
|
||||
const label = ['TA', 'Staff'].includes(labelElement.textContent) && labelElement.textContent;
|
||||
|
||||
if (linkToProfile) {
|
||||
expect(authorElement.parentNode).toHaveClass(labelColor);
|
||||
expect(authorElement.parentNode.lastChild).toHaveTextContent(label);
|
||||
expect(labelParentNode).toHaveClass(labelColor);
|
||||
expect(labelElement).toHaveTextContent(label);
|
||||
} else {
|
||||
expect(authorElement.parentNode.lastChild).not.toHaveTextContent(label, { exact: true });
|
||||
expect(authorElement.parentNode).not.toHaveClass(labelColor, { exact: true });
|
||||
|
||||
@@ -3,8 +3,10 @@ import PropTypes from 'prop-types';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { injectIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Button, Icon, IconButton } from '@edx/paragon';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Button, Icon, IconButton, OverlayTrigger, Tooltip,
|
||||
} from '@edx/paragon';
|
||||
|
||||
import {
|
||||
StarFilled, StarOutline, ThumbUpFilled, ThumbUpOutline,
|
||||
@@ -16,6 +18,7 @@ import ActionsDropdown from './ActionsDropdown';
|
||||
import { DiscussionContext } from './context';
|
||||
|
||||
function HoverCard({
|
||||
intl,
|
||||
commentOrPost,
|
||||
actionHandlers,
|
||||
handleResponseCommentButton,
|
||||
@@ -49,17 +52,26 @@ function HoverCard({
|
||||
)}
|
||||
{endorseIcons && (
|
||||
<div className="hover-button">
|
||||
<IconButton
|
||||
src={endorseIcons.icon}
|
||||
iconAs={Icon}
|
||||
onClick={() => {
|
||||
const actionFunction = actionHandlers[endorseIcons.action];
|
||||
actionFunction();
|
||||
}}
|
||||
className={['endorse', 'unendorse'].includes(endorseIcons.id) ? 'text-dark-500' : 'text-success-500'}
|
||||
size="sm"
|
||||
alt="Endorse"
|
||||
/>
|
||||
<OverlayTrigger
|
||||
overlay={(
|
||||
<Tooltip id="endorsed-icon-tooltip">
|
||||
{intl.formatMessage(endorseIcons.label)}
|
||||
</Tooltip>
|
||||
)}
|
||||
trigger={['hover', 'focus']}
|
||||
>
|
||||
<IconButton
|
||||
src={endorseIcons.icon}
|
||||
iconAs={Icon}
|
||||
onClick={() => {
|
||||
const actionFunction = actionHandlers[endorseIcons.action];
|
||||
actionFunction();
|
||||
}}
|
||||
className={['endorse', 'unendorse'].includes(endorseIcons.id) ? 'text-dark-500' : 'text-success-500'}
|
||||
size="sm"
|
||||
alt="Endorse"
|
||||
/>
|
||||
</OverlayTrigger>
|
||||
</div>
|
||||
)}
|
||||
<div className="hover-button">
|
||||
@@ -98,6 +110,7 @@ function HoverCard({
|
||||
}
|
||||
|
||||
HoverCard.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
commentOrPost: PropTypes.oneOfType([commentShape, postShape]).isRequired,
|
||||
actionHandlers: PropTypes.objectOf(PropTypes.func).isRequired,
|
||||
handleResponseCommentButton: PropTypes.func.isRequired,
|
||||
|
||||
@@ -27,7 +27,8 @@ const threadsApiUrl = getThreadsApiUrl();
|
||||
const discussionPostId = 'thread-1';
|
||||
const questionPostId = 'thread-2';
|
||||
const courseId = 'course-v1:edX+TestX+Test_Course';
|
||||
const reverseOrder = false;
|
||||
const reverseOrder = true;
|
||||
const enableInContextSidebar = false;
|
||||
let store;
|
||||
let axiosMock;
|
||||
let container;
|
||||
@@ -45,6 +46,7 @@ function mockAxiosReturnPagedComments() {
|
||||
requested_fields: 'profile_image',
|
||||
endorsed,
|
||||
reverse_order: reverseOrder,
|
||||
enable_in_context_sidebar: enableInContextSidebar,
|
||||
},
|
||||
})
|
||||
.reply(200, Factory.build('commentsResult', { can_delete: true }, {
|
||||
|
||||
@@ -8,14 +8,13 @@ ensureConfig([
|
||||
], 'Posts API service');
|
||||
|
||||
export const getCourseConfigApiUrl = () => `${getConfig().LMS_BASE_URL}/api/discussion/v1/courses/`;
|
||||
|
||||
export const getDiscussionsConfigUrl = (courseId) => `${getCourseConfigApiUrl()}${courseId}/`;
|
||||
/**
|
||||
* Get discussions course config
|
||||
* @param {string} courseId
|
||||
*/
|
||||
export async function getDiscussionsConfig(courseId) {
|
||||
const url = `${getCourseConfigApiUrl()}${courseId}/`;
|
||||
const { data } = await getAuthenticatedHttpClient().get(url);
|
||||
const { data } = await getAuthenticatedHttpClient().get(getDiscussionsConfigUrl(courseId));
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -24,7 +23,7 @@ export async function getDiscussionsConfig(courseId) {
|
||||
* @param {string} courseId
|
||||
*/
|
||||
export async function getDiscussionsSettings(courseId) {
|
||||
const url = `${getCourseConfigApiUrl()}${courseId}/settings`;
|
||||
const url = `${getDiscussionsConfigUrl(courseId)}settings`;
|
||||
const { data } = await getAuthenticatedHttpClient().get(url);
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -128,9 +128,11 @@ export function useContainerSize(refContainer) {
|
||||
|
||||
const resizeObserver = useRef(new ResizeObserver(() => {
|
||||
/* istanbul ignore if: ResizeObserver isn't available in the testing env */
|
||||
if (refContainer?.current) {
|
||||
setHeight(refContainer?.current?.clientHeight);
|
||||
}
|
||||
window.requestAnimationFrame(() => {
|
||||
if (refContainer?.current) {
|
||||
setHeight(refContainer?.current?.clientHeight);
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
@@ -198,7 +200,7 @@ export const useUserCanAddThreadInBlackoutDate = () => {
|
||||
const isInBlackoutDateRange = inBlackoutDateRange(blackoutDateRange);
|
||||
|
||||
return (!(isInBlackoutDateRange)
|
||||
|| (isUserAdmin || userHasModerationPrivilages || isUserGroupTA || isCourseAdmin || isCourseStaff));
|
||||
|| (isUserAdmin || userHasModerationPrivilages || isUserGroupTA || isCourseAdmin || isCourseStaff));
|
||||
};
|
||||
|
||||
function camelToConstant(string) {
|
||||
@@ -217,7 +219,7 @@ export const useTourConfiguration = (intl) => {
|
||||
advanceButtonText: intl.formatMessage(messages.advanceButtonText),
|
||||
dismissButtonText: intl.formatMessage(messages.dismissButtonText),
|
||||
endButtonText: intl.formatMessage(messages.endButtonText),
|
||||
enabled: tour && Boolean(tour.showTour && !enableInContextSidebar),
|
||||
enabled: tour && Boolean(tour.enabled && tour.showTour && !enableInContextSidebar),
|
||||
onDismiss: () => dispatch(updateTourShowStatus(tour.id)),
|
||||
onEnd: () => dispatch(updateTourShowStatus(tour.id)),
|
||||
checkpoints: tourCheckpoints(intl)[camelToConstant(tour.tourName)],
|
||||
|
||||
@@ -29,6 +29,7 @@ import { postMessageToParent } from '../utils';
|
||||
import BlackoutInformationBanner from './BlackoutInformationBanner';
|
||||
import DiscussionContent from './DiscussionContent';
|
||||
import DiscussionSidebar from './DiscussionSidebar';
|
||||
import useFeedbackWrapper from './FeedbackWrapper';
|
||||
import InformationBanner from './InformationBanner';
|
||||
|
||||
export default function DiscussionsHome() {
|
||||
@@ -52,7 +53,7 @@ export default function DiscussionsHome() {
|
||||
|
||||
useCourseDiscussionData(courseId);
|
||||
useRedirectToThread(courseId, enableInContextSidebar);
|
||||
|
||||
useFeedbackWrapper();
|
||||
/* Display the content area if we are currently viewing/editing a post or creating one.
|
||||
If the window is larger than a particular size, show the sidebar for navigating between posts/topics.
|
||||
However, for smaller screens or embeds, onlyshow the sidebar if the content area isn't displayed. */
|
||||
|
||||
@@ -6,25 +6,37 @@ import { act } from 'react-dom/test-utils';
|
||||
import { IntlProvider } from 'react-intl';
|
||||
import { Context as ResponsiveContext } from 'react-responsive';
|
||||
import { MemoryRouter } from 'react-router';
|
||||
import { Factory } from 'rosie';
|
||||
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
|
||||
import { getApiBaseUrl } from '../../data/constants';
|
||||
import { initializeStore } from '../../store';
|
||||
import { executeThunk } from '../../test-utils';
|
||||
import { getCourseConfigApiUrl } from '../data/api';
|
||||
import { getCourseConfigApiUrl, getDiscussionsConfigUrl } from '../data/api';
|
||||
import { fetchCourseConfig } from '../data/thunks';
|
||||
import { getCourseTopicsApiUrl } from '../in-context-topics/data/api';
|
||||
import { fetchCourseTopicsV3 } from '../in-context-topics/data/thunks';
|
||||
import navigationBarMessages from '../navigation/navigation-bar/messages';
|
||||
import { getThreadsApiUrl } from '../posts/data/api';
|
||||
import { fetchThreads } from '../posts/data/thunks';
|
||||
import { fetchCourseTopics } from '../topics/data/thunks';
|
||||
import DiscussionsHome from './DiscussionsHome';
|
||||
|
||||
import '../posts/data/__factories__/threads.factory';
|
||||
import '../in-context-topics/data/__factories__/inContextTopics.factory';
|
||||
import '../topics/data/__factories__/topics.factory';
|
||||
|
||||
const courseConfigApiUrl = getCourseConfigApiUrl();
|
||||
let axiosMock;
|
||||
let store;
|
||||
const courseId = 'course-v1:edX+DemoX+Demo_Course';
|
||||
let container;
|
||||
|
||||
function renderComponent(location = `/${courseId}/`) {
|
||||
render(
|
||||
const wrapper = render(
|
||||
<IntlProvider locale="en">
|
||||
<ResponsiveContext.Provider value={{ width: 1280 }}>
|
||||
<AppProvider store={store}>
|
||||
@@ -35,6 +47,7 @@ function renderComponent(location = `/${courseId}/`) {
|
||||
</ResponsiveContext.Provider>
|
||||
</IntlProvider>,
|
||||
);
|
||||
container = wrapper.container;
|
||||
}
|
||||
|
||||
describe('DiscussionsHome', () => {
|
||||
@@ -51,6 +64,16 @@ describe('DiscussionsHome', () => {
|
||||
store = initializeStore();
|
||||
});
|
||||
|
||||
async function setUpV1TopicsMockResponse() {
|
||||
axiosMock
|
||||
.onGet(`${getApiBaseUrl()}/api/discussion/v1/course_topics/${courseId}`)
|
||||
.reply(200, {
|
||||
courseware_topics: Factory.buildList('category', 2),
|
||||
non_courseware_topics: Factory.buildList('topic', 3, {}, { topicPrefix: 'ncw' }),
|
||||
});
|
||||
await executeThunk(fetchCourseTopics(courseId), store.dispatch, store.getState);
|
||||
}
|
||||
|
||||
test('clicking "All Topics" button renders topics view', async () => {
|
||||
renderComponent();
|
||||
|
||||
@@ -105,4 +128,123 @@ describe('DiscussionsHome', () => {
|
||||
expect(document.getElementById('courseTabsNavigation')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('contentinfo')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ searchByEndPoint: 'category/unit-1' },
|
||||
{ searchByEndPoint: 'topics/topic-1' },
|
||||
])('should display add a post message for inContext empty topics %s', async ({ searchByEndPoint }) => {
|
||||
axiosMock.onGet(getDiscussionsConfigUrl(courseId)).reply(200, {
|
||||
enableInContext: true, provider: 'openedx', hasModerationPrivileges: true,
|
||||
});
|
||||
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
|
||||
await renderComponent(`/${courseId}/${searchByEndPoint}`);
|
||||
|
||||
expect(screen.queryByText('Add a post')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ searchByEndPoint: 'category/section-topic-1', result: 'Add a post' },
|
||||
{ searchByEndPoint: 'topics/topic-1', result: 'No post selected' },
|
||||
])(`should display No post selected message on posts pages when user has yet to select a post to display
|
||||
for incontext topics %s`, async ({ searchByEndPoint, result }) => {
|
||||
axiosMock.onGet(getDiscussionsConfigUrl(courseId)).reply(200, {
|
||||
enableInContext: true, provider: 'openedx', hasModerationPrivileges: true,
|
||||
});
|
||||
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
|
||||
axiosMock.onGet(getThreadsApiUrl())
|
||||
.reply(() => {
|
||||
const threadAttrs = { previewBody: 'thread preview body' };
|
||||
return [200, Factory.build('threadsResult', {}, {
|
||||
topicId: 'noncourseware-topic-1',
|
||||
threadAttrs,
|
||||
count: 3,
|
||||
})];
|
||||
});
|
||||
await executeThunk(fetchThreads(courseId), store.dispatch, store.getState);
|
||||
await renderComponent(`/${courseId}/${searchByEndPoint}`);
|
||||
|
||||
expect(screen.queryByText(result)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ searchByEndPoint: 'category/section-topic-1' },
|
||||
{ searchByEndPoint: 'topics' },
|
||||
])('should display No Topic selected message on inContext topic pages when user has yet to select a topic %s',
|
||||
async ({ searchByEndPoint }) => {
|
||||
axiosMock.onGet(getDiscussionsConfigUrl(courseId)).reply(200, {
|
||||
enableInContext: true, provider: 'openedx', hasModerationPrivileges: true,
|
||||
});
|
||||
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
|
||||
axiosMock.onGet(`${getCourseTopicsApiUrl()}${courseId}`)
|
||||
.reply(200, (Factory.buildList('topic', 1, null, {
|
||||
topicPrefix: 'noncourseware-topic',
|
||||
enabledInContext: true,
|
||||
topicNamePrefix: 'general-topic',
|
||||
usageKey: '',
|
||||
courseware: false,
|
||||
discussionCount: 1,
|
||||
questionCount: 1,
|
||||
}).concat(Factory.buildList('section', 2, null, { topicPrefix: 'courseware' })))
|
||||
.concat(Factory.buildList('archived-topics', 2, null)));
|
||||
await executeThunk(fetchCourseTopicsV3(courseId), store.dispatch, store.getState);
|
||||
await renderComponent(`/${courseId}/${searchByEndPoint}`);
|
||||
|
||||
expect(screen.queryByText('No topic selected')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display empty page message for empty learners list', async () => {
|
||||
axiosMock.onGet(getDiscussionsConfigUrl(courseId)).reply(200, {
|
||||
learners_tab_enabled: true,
|
||||
});
|
||||
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
|
||||
await renderComponent(`/${courseId}/learners`);
|
||||
|
||||
expect(screen.queryByText('Nothing here yet')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display post editor form when click on add a post button for posts', async () => {
|
||||
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
|
||||
await renderComponent(`/${courseId}/my-posts`);
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.queryByText('Add a post'));
|
||||
});
|
||||
|
||||
await waitFor(() => expect(container.querySelector('.post-form')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('should display post editor form when click on add a post button in legacy topics view', async () => {
|
||||
axiosMock.onGet(getDiscussionsConfigUrl(courseId)).reply(200, {
|
||||
enable_in_context: false,
|
||||
});
|
||||
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
|
||||
await renderComponent(`/${courseId}/topics`);
|
||||
|
||||
expect(screen.queryByText('Nothing here yet')).toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.queryByText('Add a post'));
|
||||
});
|
||||
|
||||
await waitFor(() => expect(container.querySelector('.post-form')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('should display Add a post button for legacy topics view', async () => {
|
||||
await renderComponent(`/${courseId}/topics/topic-1`);
|
||||
|
||||
expect(screen.queryByText('Add a post')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display No post selected for legacy topics view', async () => {
|
||||
await setUpV1TopicsMockResponse();
|
||||
await renderComponent(`/${courseId}/topics/category-1-topic-1`);
|
||||
|
||||
expect(screen.queryByText('No post selected')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display No topic selected for legacy topics view', async () => {
|
||||
await setUpV1TopicsMockResponse();
|
||||
await renderComponent(`/${courseId}/topics`);
|
||||
|
||||
expect(screen.queryByText('No topic selected')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
37
src/discussions/discussions-home/FeedbackWrapper.jsx
Normal file
37
src/discussions/discussions-home/FeedbackWrapper.jsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { logError } from '@edx/frontend-platform/logging';
|
||||
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import {
|
||||
selectconfigLoadingStatus,
|
||||
selectIsCourseAdmin,
|
||||
selectIsCourseStaff,
|
||||
selectUserIsGroupTa,
|
||||
selectUserIsStaff,
|
||||
} from '../data/selectors';
|
||||
|
||||
export default function useFeedbackWrapper() {
|
||||
const isStaff = useSelector(selectUserIsStaff);
|
||||
const isUserGroupTA = useSelector(selectUserIsGroupTa);
|
||||
const isCourseAdmin = useSelector(selectIsCourseAdmin);
|
||||
const isCourseStaff = useSelector(selectIsCourseStaff);
|
||||
const configStatus = useSelector(selectconfigLoadingStatus);
|
||||
|
||||
useEffect(() => {
|
||||
if (configStatus === RequestStatus.SUCCESSFUL) {
|
||||
let url = '//w.usabilla.com/9e6036348fa1.js';
|
||||
if (isStaff || isUserGroupTA || isCourseAdmin || isCourseStaff) {
|
||||
url = '//w.usabilla.com/767740a06856.js';
|
||||
}
|
||||
try {
|
||||
// eslint-disable-next-line no-undef
|
||||
window.usabilla_live = lightningjs.require('usabilla_live', url);
|
||||
} catch (err) {
|
||||
logError(err);
|
||||
}
|
||||
}
|
||||
}, [configStatus]);
|
||||
}
|
||||
@@ -100,9 +100,9 @@ describe('InContext Topic Posts View', () => {
|
||||
lastLocation = undefined;
|
||||
});
|
||||
|
||||
async function setupTopicsMockResponse() {
|
||||
async function setupTopicsMockResponse(topicCount = 1, sectionCount = 2, archivedCount = 2) {
|
||||
axiosMock.onGet(`${topicsApiUrl}${courseId}`)
|
||||
.reply(200, (Factory.buildList('topic', 1, null, {
|
||||
.reply(200, (Factory.buildList('topic', topicCount, null, {
|
||||
topicPrefix: 'noncourseware-topic',
|
||||
enabledInContext: true,
|
||||
topicNamePrefix: 'general-topic',
|
||||
@@ -111,8 +111,8 @@ describe('InContext Topic Posts View', () => {
|
||||
discussionCount: 1,
|
||||
questionCount: 1,
|
||||
})
|
||||
.concat(Factory.buildList('section', 2, null, { topicPrefix: 'courseware' })))
|
||||
.concat(Factory.buildList('archived-topics', 2, null)));
|
||||
.concat(Factory.buildList('section', sectionCount, null, { topicPrefix: 'courseware' })))
|
||||
.concat(Factory.buildList('archived-topics', archivedCount, null)));
|
||||
await executeThunk(fetchCourseTopicsV3(courseId), store.dispatch, store.getState);
|
||||
|
||||
const state = store.getState();
|
||||
@@ -252,4 +252,28 @@ describe('InContext Topic Posts View', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should display Nothing here yet and No topic exists message when topics and selectedSubsectionUnits are empty',
|
||||
async () => {
|
||||
await setupTopicsMockResponse(0, 0, 0);
|
||||
await renderComponent({ topicId: 'test-topic', category: 'test-category' });
|
||||
|
||||
await waitFor(() => expect(within(container).queryByText('Nothing here yet')).toBeInTheDocument());
|
||||
expect(within(container).queryByText('No topic exists')).toBeInTheDocument();
|
||||
expect(within(container).queryByText('Unnamed Topic')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display all topics when search by an empty search string', async () => {
|
||||
await setupTopicsMockResponse();
|
||||
await renderComponent();
|
||||
|
||||
const searchField = await within(container).getByPlaceholderText('Search topics');
|
||||
fireEvent.change(searchField, { target: { value: '' } });
|
||||
|
||||
await act(async () => fireEvent.click(within(container).getByTestId('search-icon')));
|
||||
await waitFor(() => {
|
||||
expect(within(container).queryByText('Clear results')).not.toBeInTheDocument();
|
||||
expect(container.querySelectorAll('.discussion-topic')).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,10 +8,10 @@ import messages from '../messages';
|
||||
function NoResults({ intl }) {
|
||||
const topics = useSelector(selectTopics);
|
||||
|
||||
let title = messages.nothingHere;
|
||||
const helpMessage = '';
|
||||
const title = messages.nothingHere;
|
||||
let helpMessage = '';
|
||||
if (topics.length === 0) {
|
||||
title = messages.noTopicExists;
|
||||
helpMessage = messages.noTopicExists;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -8,12 +8,16 @@ import { initializeStore } from '../../../store';
|
||||
import { executeThunk } from '../../../test-utils';
|
||||
import { getCourseTopicsApiUrl } from './api';
|
||||
import {
|
||||
selectArchivedTopic,
|
||||
selectArchivedTopics,
|
||||
selectCourseWareThreadsCount,
|
||||
selectCoursewareTopics,
|
||||
selectLoadingStatus,
|
||||
selectNonCoursewareIds,
|
||||
selectNonCoursewareTopics,
|
||||
selectSubsection,
|
||||
selectTopics,
|
||||
selectTotalTopicsThreadsCount,
|
||||
selectUnits,
|
||||
} from './selectors';
|
||||
import { fetchCourseTopicsV3 } from './thunks';
|
||||
@@ -58,90 +62,100 @@ describe('In Context Topics Selector test cases', () => {
|
||||
questionCount: 1,
|
||||
}).concat(Factory.buildList('section', 2, null, { topicPrefix: 'courseware' })))
|
||||
.concat(Factory.buildList('archived-topics', 2, null)));
|
||||
|
||||
await executeThunk(fetchCourseTopicsV3(courseId), store.dispatch, store.getState);
|
||||
|
||||
const state = store.getState();
|
||||
return state;
|
||||
}
|
||||
|
||||
test('should return topics list', async () => {
|
||||
setupMockData().then((state) => {
|
||||
const topics = selectTopics(state);
|
||||
it('should return topics list', async () => {
|
||||
const state = await setupMockData();
|
||||
const topics = selectTopics(state);
|
||||
const topicsIds = topics.map(topic => topic.id);
|
||||
|
||||
expect(topics).not.toBeUndefined();
|
||||
topics.forEach(data => {
|
||||
const topicFunc = jest.fn((topic) => {
|
||||
if (topic.id.includes('noncourseware-topic')) { return true; }
|
||||
if (topic.id.includes('courseware-topic')) { return true; }
|
||||
if (topic.id.includes('archived')) { return true; }
|
||||
return false;
|
||||
});
|
||||
topicFunc(data);
|
||||
expect(topicFunc).toHaveReturnedWith(true);
|
||||
});
|
||||
expect(topicsIds[0].includes('noncourseware-topic')).toBeTruthy();
|
||||
expect(topicsIds[1].includes('courseware-topic')).toBeTruthy();
|
||||
expect(topicsIds[3].includes('archived')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should return courseware topics list', async () => {
|
||||
const state = await setupMockData();
|
||||
const coursewareTopics = selectCoursewareTopics(state);
|
||||
|
||||
expect(coursewareTopics).not.toBeUndefined();
|
||||
coursewareTopics.forEach((topic, index) => {
|
||||
expect(topic?.id).toEqual(`courseware-topic-${index + 1}-v3`);
|
||||
});
|
||||
});
|
||||
|
||||
test('should return courseware topics list', async () => {
|
||||
setupMockData().then((state) => {
|
||||
const coursewareTopics = selectCoursewareTopics(state);
|
||||
it('should return noncourseware topics list', async () => {
|
||||
const state = await setupMockData();
|
||||
const nonCoursewareTopics = selectNonCoursewareTopics(state);
|
||||
|
||||
expect(coursewareTopics).not.toBeUndefined();
|
||||
coursewareTopics.forEach((topic, index) => {
|
||||
expect(topic?.id).toEqual(`courseware-topic-${index + 1}-v3`);
|
||||
});
|
||||
expect(nonCoursewareTopics).not.toBeUndefined();
|
||||
nonCoursewareTopics.forEach((topic, index) => {
|
||||
expect(topic?.id).toEqual(`noncourseware-topic-${index + 1}`);
|
||||
});
|
||||
});
|
||||
|
||||
test('should return noncourseware topics list', async () => {
|
||||
setupMockData().then((state) => {
|
||||
const nonCoursewareTopics = selectNonCoursewareTopics(state);
|
||||
it('should return noncourseware ids list', async () => {
|
||||
const state = await setupMockData();
|
||||
const nonCoursewareIds = selectNonCoursewareIds(state);
|
||||
|
||||
expect(nonCoursewareTopics).not.toBeUndefined();
|
||||
nonCoursewareTopics.forEach((topic, index) => {
|
||||
expect(topic?.id).toEqual(`noncourseware-topic-${index + 1}`);
|
||||
});
|
||||
expect(nonCoursewareIds).not.toBeUndefined();
|
||||
nonCoursewareIds.forEach((id, index) => {
|
||||
expect(id).toEqual(`noncourseware-topic-${index + 1}`);
|
||||
});
|
||||
});
|
||||
|
||||
test('should return noncourseware ids list', async () => {
|
||||
setupMockData().then((state) => {
|
||||
const nonCoursewareIds = selectNonCoursewareIds(state);
|
||||
it('should return units list', async () => {
|
||||
const state = await setupMockData();
|
||||
const units = selectUnits(state);
|
||||
|
||||
expect(nonCoursewareIds).not.toBeUndefined();
|
||||
nonCoursewareIds.forEach((id, index) => {
|
||||
expect(id).toEqual(`noncourseware-topic-${index + 1}`);
|
||||
});
|
||||
expect(units).not.toBeUndefined();
|
||||
units.forEach(unit => {
|
||||
expect(unit?.usageKey).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
test('should return units list', async () => {
|
||||
setupMockData().then((state) => {
|
||||
const units = selectUnits(state);
|
||||
it('should return archived topics list', async () => {
|
||||
const state = await setupMockData();
|
||||
const archivedTopics = selectArchivedTopics(state);
|
||||
|
||||
expect(units).not.toBeUndefined();
|
||||
units.forEach(unit => {
|
||||
expect(unit?.usageKey).not.toBeNull();
|
||||
});
|
||||
expect(archivedTopics).not.toBeUndefined();
|
||||
archivedTopics.forEach((topic, index) => {
|
||||
expect(topic.id).toEqual(`archived-${index + 1}`);
|
||||
});
|
||||
});
|
||||
|
||||
test('should return archived topics list', async () => {
|
||||
setupMockData().then((state) => {
|
||||
const archivedTopics = selectArchivedTopics(state);
|
||||
it('should return loading status successful', async () => {
|
||||
const state = await setupMockData();
|
||||
|
||||
expect(archivedTopics).not.toBeUndefined();
|
||||
archivedTopics.forEach((topic, index) => {
|
||||
expect(topic.id).toEqual(`archived-${index + 1}`);
|
||||
});
|
||||
});
|
||||
expect(selectLoadingStatus(state)).toEqual('successful');
|
||||
});
|
||||
|
||||
test('should return loading status successful', async () => {
|
||||
setupMockData().then((state) => {
|
||||
const status = selectLoadingStatus(state);
|
||||
it('should return total topics threads count successful.', async () => {
|
||||
const state = await setupMockData();
|
||||
|
||||
expect(status).toEqual('successful');
|
||||
});
|
||||
expect(selectTotalTopicsThreadsCount(state)).toEqual(18);
|
||||
});
|
||||
|
||||
it('should return courseware threads counts successful.', async () => {
|
||||
const state = await setupMockData();
|
||||
|
||||
expect(selectCourseWareThreadsCount('section-topic-1')(state)).toEqual(8);
|
||||
});
|
||||
|
||||
it('should return selected subsection.', async () => {
|
||||
const state = await setupMockData();
|
||||
|
||||
expect(selectSubsection('section-topic-1')(state)).not.toBeNull();
|
||||
});
|
||||
|
||||
it('should return selected archived topic.', async () => {
|
||||
const state = await setupMockData();
|
||||
|
||||
expect(selectArchivedTopic('archived-1')(state)).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -106,7 +106,7 @@ function LearnerPostsView({ intl }) {
|
||||
</div>
|
||||
) : (
|
||||
nextPage && loadingStatus === RequestStatus.SUCCESSFUL && (
|
||||
<Button onClick={() => loadMorePosts(nextPage)} variant="primary" size="md">
|
||||
<Button onClick={() => loadMorePosts(nextPage)} variant="primary" size="md" data-testid="load-more-posts">
|
||||
{intl.formatMessage(messages.loadMore)}
|
||||
</Button>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React from 'react';
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import {
|
||||
fireEvent, render, screen, waitFor,
|
||||
} from '@testing-library/react';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { IntlProvider } from 'react-intl';
|
||||
@@ -13,23 +15,26 @@ import { AppProvider } from '@edx/frontend-platform/react';
|
||||
|
||||
import { initializeStore } from '../../store';
|
||||
import { executeThunk } from '../../test-utils';
|
||||
import { getCohortsApiUrl } from '../cohorts/data/api';
|
||||
import { fetchCourseCohorts } from '../cohorts/data/thunks';
|
||||
import { DiscussionContext } from '../common/context';
|
||||
import { getCourseConfigApiUrl } from '../data/api';
|
||||
import { fetchCourseConfig } from '../data/thunks';
|
||||
import { getCoursesApiUrl } from './data/api';
|
||||
import { learnerPostsApiUrl } from './data/api';
|
||||
import { fetchUserPosts } from './data/thunks';
|
||||
import LearnerPostsView from './LearnerPostsView';
|
||||
import { setUpPrivilages } from './test-utils';
|
||||
|
||||
import '../cohorts/data/__factories__/cohorts.factory';
|
||||
import './data/__factories__';
|
||||
|
||||
let store;
|
||||
let axiosMock;
|
||||
const coursesApiUrl = getCoursesApiUrl();
|
||||
const courseConfigApiUrl = getCourseConfigApiUrl();
|
||||
const courseId = 'course-v1:edX+TestX+Test_Course';
|
||||
const username = 'abc123';
|
||||
let container;
|
||||
let lastLocation;
|
||||
|
||||
function renderComponent(path = `/${courseId}/learners/${username}/posts`) {
|
||||
return render(
|
||||
async function renderComponent() {
|
||||
const wrapper = render(
|
||||
<IntlProvider locale="en">
|
||||
<AppProvider store={store}>
|
||||
<DiscussionContext.Provider
|
||||
@@ -38,18 +43,25 @@ function renderComponent(path = `/${courseId}/learners/${username}/posts`) {
|
||||
courseId,
|
||||
}}
|
||||
>
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<Route path={path}>
|
||||
<MemoryRouter initialEntries={[`/${courseId}/learners/${username}/posts`]}>
|
||||
<Route path="/:courseId/learners/:learnerUsername/posts">
|
||||
<LearnerPostsView />
|
||||
</Route>
|
||||
<Route
|
||||
render={({ location }) => {
|
||||
lastLocation = location;
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
</DiscussionContext.Provider>
|
||||
</AppProvider>
|
||||
</IntlProvider>,
|
||||
);
|
||||
container = wrapper.container;
|
||||
}
|
||||
|
||||
describe('LearnerPostsView', () => {
|
||||
describe('Learner Posts View', () => {
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
@@ -62,31 +74,143 @@ describe('LearnerPostsView', () => {
|
||||
|
||||
store = initializeStore();
|
||||
Factory.resetAll();
|
||||
const learnerPosts = Factory.build('learnerPosts', {}, {
|
||||
abuseFlaggedCount: 1,
|
||||
});
|
||||
const apiUrl = `${coursesApiUrl}${courseId}/learner/`;
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
axiosMock.onGet(apiUrl, { username, count_flagged: true })
|
||||
.reply(() => [200, learnerPosts]);
|
||||
axiosMock.onGet(learnerPostsApiUrl(courseId), { username, count_flagged: true })
|
||||
.reply(() => [200, Factory.build('learnerPosts', {}, {
|
||||
abuseFlaggedCount: 1,
|
||||
})]);
|
||||
await executeThunk(fetchUserPosts(courseId), store.dispatch, store.getState);
|
||||
});
|
||||
|
||||
describe('Basic', () => {
|
||||
test('Reported icon is visible to moderator for post with reported comment', async () => {
|
||||
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`).reply(200, {
|
||||
has_moderation_privileges: true,
|
||||
test('Reported icon is visible to moderator for post with reported comment', async () => {
|
||||
await setUpPrivilages(axiosMock, store, true);
|
||||
await waitFor(() => { renderComponent(); });
|
||||
|
||||
expect(container.querySelector('[data-testid="reported-post"]')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Reported icon is not visible to learner for post with reported comment', async () => {
|
||||
await renderComponent();
|
||||
expect(screen.queryByTestId('reported-post')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Learner title bar should display a title bar, a learner name, and a back button', async () => {
|
||||
await renderComponent();
|
||||
|
||||
const titleBar = container.querySelector('.discussion-posts:first-child');
|
||||
const learnerName = screen.queryByText('Activity for Abc123');
|
||||
const backButton = screen.getByLabelText('Back');
|
||||
|
||||
expect(titleBar).toBeInTheDocument();
|
||||
expect(learnerName).toBeInTheDocument();
|
||||
expect(backButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Learner title bar should redirect to the learners list when clicking on the back button',
|
||||
async () => {
|
||||
await renderComponent();
|
||||
|
||||
const backButton = screen.getByLabelText('Back');
|
||||
|
||||
await act(() => fireEvent.click(backButton));
|
||||
await waitFor(() => {
|
||||
expect(lastLocation.pathname.endsWith('/learners')).toBeTruthy();
|
||||
});
|
||||
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/settings`).reply(200, {});
|
||||
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
|
||||
await act(async () => {
|
||||
renderComponent();
|
||||
});
|
||||
expect(screen.queryAllByTestId('reported-post')[0]).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Reported icon is not visible to learner for post with reported comment', async () => {
|
||||
await renderComponent();
|
||||
expect(screen.queryByTestId('reported-post')).not.toBeInTheDocument();
|
||||
it('should display a post-filter bar and All posts sorted by recent activity text.', async () => {
|
||||
await renderComponent();
|
||||
|
||||
const filterBar = container.querySelector('.filter-bar');
|
||||
const recentActivity = screen.getByText('All posts sorted by recent activity');
|
||||
|
||||
expect(filterBar).toBeInTheDocument();
|
||||
expect(recentActivity).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it(`should display a list of the interactive posts of a selected learner and the posts count
|
||||
should be equal to the API response count.`, async () => {
|
||||
await waitFor(() => {
|
||||
renderComponent();
|
||||
});
|
||||
const posts = await container.querySelectorAll('.discussion-post');
|
||||
|
||||
expect(posts).toHaveLength(2);
|
||||
expect(posts).toHaveLength(Object.values(store.getState().threads.threadsById).length);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ searchBy: 'type-all', result: 2 },
|
||||
{ searchBy: 'type-discussions', result: 2 },
|
||||
{ searchBy: 'type-questions', result: 2 },
|
||||
{ searchBy: 'status-unread', result: 2 },
|
||||
{ searchBy: 'sort-comments', result: 2 },
|
||||
{ searchBy: 'sort-votes', result: 2 },
|
||||
{ searchBy: 'sort-activity', result: 2 },
|
||||
])('successfully display learners by %s.', async ({ searchBy, result }) => {
|
||||
await setUpPrivilages(axiosMock, store, true);
|
||||
await renderComponent();
|
||||
|
||||
const filterBar = container.querySelector('.collapsible-trigger');
|
||||
await act(async () => {
|
||||
fireEvent.click(filterBar);
|
||||
});
|
||||
|
||||
await waitFor(async () => {
|
||||
const activity = container.querySelector(`[for='${searchBy}']`);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(activity);
|
||||
});
|
||||
await waitFor(() => {
|
||||
const learners = container.querySelectorAll('.discussion-post');
|
||||
|
||||
expect(learners).toHaveLength(result);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ searchBy: 'type-all', result: 2 },
|
||||
{ searchBy: 'cohort-1', result: 2 },
|
||||
])('successfully display learners by %s.', async ({ searchBy, result }) => {
|
||||
await setUpPrivilages(axiosMock, store, true);
|
||||
axiosMock.onGet(getCohortsApiUrl(courseId))
|
||||
.reply(200, Factory.buildList('cohort', 3));
|
||||
|
||||
await executeThunk(fetchCourseCohorts(courseId), store.dispatch, store.getState);
|
||||
await renderComponent();
|
||||
|
||||
const filterBar = await container.querySelector('.collapsible-trigger');
|
||||
await act(async () => {
|
||||
fireEvent.click(filterBar);
|
||||
});
|
||||
|
||||
const cohort = await container.querySelector(`[for='${searchBy}']`);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(cohort);
|
||||
});
|
||||
await waitFor(() => {
|
||||
const learners = container.querySelectorAll('.discussion-post');
|
||||
|
||||
expect(learners).toHaveLength(result);
|
||||
});
|
||||
});
|
||||
|
||||
it('should display load more posts button and display more posts by clicking on button.', async () => {
|
||||
await renderComponent();
|
||||
await waitFor(() => container.querySelector('[data-testid="load-more-posts"]'));
|
||||
|
||||
const loadMoreButton = container.querySelector('[data-testid="load-more-posts"]');
|
||||
|
||||
expect(loadMoreButton).toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(loadMoreButton);
|
||||
});
|
||||
|
||||
expect(loadMoreButton).not.toBeInTheDocument();
|
||||
expect(container.querySelectorAll('.discussion-post')).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -89,7 +89,7 @@ function LearnersView({ intl }) {
|
||||
</div>
|
||||
) : (
|
||||
nextPage && loadingStatus === RequestStatus.SUCCESSFUL && (
|
||||
<Button onClick={() => loadPage()} variant="primary" size="md">
|
||||
<Button onClick={() => loadPage()} variant="primary" size="md" data-testid="load-more-learners">
|
||||
{intl.formatMessage(messages.loadMore)}
|
||||
</Button>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React from 'react';
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import {
|
||||
fireEvent, render, screen, waitFor, within,
|
||||
} from '@testing-library/react';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { IntlProvider } from 'react-intl';
|
||||
@@ -11,11 +13,13 @@ import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
|
||||
import { PostActionsBar } from '../../components';
|
||||
import { initializeStore } from '../../store';
|
||||
import { executeThunk } from '../../test-utils';
|
||||
import { getCourseConfigApiUrl } from '../data/api';
|
||||
import { DiscussionContext } from '../common/context';
|
||||
import { getDiscussionsConfigUrl } from '../data/api';
|
||||
import { fetchCourseConfig } from '../data/thunks';
|
||||
import { getCoursesApiUrl, getUserProfileApiUrl } from './data/api';
|
||||
import { getUserProfileApiUrl, learnersApiUrl } from './data/api';
|
||||
import { fetchLearners } from './data/thunks';
|
||||
import LearnersView from './LearnersView';
|
||||
|
||||
@@ -23,71 +27,268 @@ import './data/__factories__';
|
||||
|
||||
let store;
|
||||
let axiosMock;
|
||||
const coursesApiUrl = getCoursesApiUrl();
|
||||
const courseConfigApiUrl = getCourseConfigApiUrl();
|
||||
const userProfileApiUrl = getUserProfileApiUrl();
|
||||
const courseId = 'course-v1:edX+TestX+Test_Course';
|
||||
let container;
|
||||
|
||||
function renderComponent() {
|
||||
return render(
|
||||
const wrapper = render(
|
||||
<IntlProvider locale="en">
|
||||
<AppProvider store={store}>
|
||||
<MemoryRouter initialEntries={[`/${courseId}/`]}>
|
||||
<Route path="/:courseId/">
|
||||
<LearnersView />
|
||||
</Route>
|
||||
</MemoryRouter>
|
||||
<DiscussionContext.Provider value={{
|
||||
page: 'learners',
|
||||
learnerUsername: 'learner-1',
|
||||
}}
|
||||
>
|
||||
<MemoryRouter initialEntries={[`/${courseId}/`]}>
|
||||
<Route path="/:courseId/">
|
||||
<PostActionsBar />
|
||||
<LearnersView />
|
||||
</Route>
|
||||
</MemoryRouter>
|
||||
</DiscussionContext.Provider>
|
||||
</AppProvider>
|
||||
</IntlProvider>,
|
||||
);
|
||||
container = wrapper.container;
|
||||
}
|
||||
|
||||
describe('LearnersView', () => {
|
||||
const learnerCount = 3;
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
username: 'test_user',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
learnersTabEnabled: false,
|
||||
},
|
||||
});
|
||||
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
store = initializeStore();
|
||||
Factory.resetAll();
|
||||
const learnersData = Factory.build('learnersResult', {}, {
|
||||
count: learnerCount,
|
||||
pageSize: 6,
|
||||
});
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
axiosMock.onGet(`${coursesApiUrl}${courseId}/activity_stats/`)
|
||||
.reply(() => [200, learnersData]);
|
||||
const learnersProfile = Factory.build('learnersProfile', {}, {
|
||||
username: ['leaner-1', 'leaner-2', 'leaner-3'],
|
||||
});
|
||||
axiosMock.onGet(`${userProfileApiUrl}?username=leaner-1,leaner-2,leaner-3`)
|
||||
.reply(() => [200, learnersProfile.profiles]);
|
||||
await executeThunk(fetchLearners(courseId), store.dispatch, store.getState);
|
||||
});
|
||||
|
||||
describe('Basic', () => {
|
||||
test('Learners tab is disabled by default', async () => {
|
||||
await act(async () => {
|
||||
await renderComponent();
|
||||
});
|
||||
expect(screen.queryByText(/Last active/i)).toBeFalsy();
|
||||
async function setUpLearnerMockResponse(
|
||||
count = 3,
|
||||
pageSize = 6,
|
||||
page = 1,
|
||||
username = ['learner-1', 'learner-2', 'learner-3'],
|
||||
searchText,
|
||||
activeFlags,
|
||||
inactiveFlags,
|
||||
) {
|
||||
Factory.resetAll();
|
||||
const learnersData = Factory.build('learnersResult', {}, {
|
||||
count,
|
||||
pageSize,
|
||||
page,
|
||||
activeFlags,
|
||||
inactiveFlags,
|
||||
});
|
||||
test('Learners tab is enabled', async () => {
|
||||
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`).reply(200, {
|
||||
learners_tab_enabled: true,
|
||||
user_is_privileged: true,
|
||||
});
|
||||
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/settings`).reply(200, {});
|
||||
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
|
||||
axiosMock.onGet(learnersApiUrl(courseId))
|
||||
.reply(() => [200, learnersData]);
|
||||
|
||||
axiosMock.onGet(`${getUserProfileApiUrl()}?username=${username.join()}`)
|
||||
.reply(() => [200, Factory.build('learnersProfile', {}, {
|
||||
username,
|
||||
}).profiles]);
|
||||
await executeThunk(fetchLearners(courseId, { usernameSearch: searchText }), store.dispatch, store.getState);
|
||||
}
|
||||
|
||||
async function assignPrivilages(hasModerationPrivileges = false) {
|
||||
axiosMock.onGet(getDiscussionsConfigUrl(courseId)).reply(200, {
|
||||
learners_tab_enabled: true,
|
||||
user_is_privileged: true,
|
||||
hasModerationPrivileges,
|
||||
});
|
||||
|
||||
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
|
||||
}
|
||||
|
||||
test('Learners tab is disabled by default', async () => {
|
||||
await setUpLearnerMockResponse();
|
||||
await renderComponent();
|
||||
|
||||
expect(screen.queryByText('learner-1')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Learners tab is enabled', async () => {
|
||||
await setUpLearnerMockResponse();
|
||||
await assignPrivilages();
|
||||
await waitFor(() => {
|
||||
renderComponent();
|
||||
});
|
||||
|
||||
expect(screen.queryByText('learner-1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Most activity should be selected by default for the non-moderator role.', async () => {
|
||||
await setUpLearnerMockResponse();
|
||||
await renderComponent();
|
||||
|
||||
const filterBar = container.querySelector('.collapsible-trigger');
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(filterBar);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const mostActivity = screen.getByTestId('activity selected');
|
||||
|
||||
expect(mostActivity).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ searchBy: 'sort-recency', result: 3 },
|
||||
{ searchBy: 'sort-activity', result: 3 },
|
||||
{ searchBy: 'sort-reported', result: 3 },
|
||||
])('successfully display learners by %s.', async ({ searchBy, result }) => {
|
||||
await setUpLearnerMockResponse();
|
||||
await assignPrivilages(true);
|
||||
await renderComponent();
|
||||
|
||||
const filterBar = container.querySelector('.collapsible-trigger');
|
||||
await act(async () => {
|
||||
fireEvent.click(filterBar);
|
||||
});
|
||||
|
||||
await waitFor(async () => {
|
||||
const activity = container.querySelector(`#${searchBy}`);
|
||||
|
||||
await act(async () => {
|
||||
await renderComponent();
|
||||
fireEvent.click(activity);
|
||||
});
|
||||
await waitFor(() => {
|
||||
const learners = container.querySelectorAll('.discussion-post');
|
||||
|
||||
expect(learners).toHaveLength(result);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should display a learner\'s list.', async () => {
|
||||
await setUpLearnerMockResponse();
|
||||
await assignPrivilages();
|
||||
await waitFor(() => {
|
||||
renderComponent();
|
||||
});
|
||||
|
||||
const learners = await container.querySelectorAll('.discussion-post');
|
||||
const firstLearner = learners.item(0);
|
||||
const learnerAvatar = firstLearner.querySelector('[alt=learner-1]');
|
||||
const learnerTitle = within(firstLearner).queryByText('learner-1');
|
||||
const stats = firstLearner.querySelectorAll('.icon-size');
|
||||
|
||||
expect(learners).toHaveLength(3);
|
||||
expect(learnerAvatar).toBeInTheDocument();
|
||||
expect(learnerTitle).toBeInTheDocument();
|
||||
expect(stats).toHaveLength(2);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
searchText: 'hello world',
|
||||
output: 'Showing 0 results for',
|
||||
learnersCount: 0,
|
||||
username: [],
|
||||
},
|
||||
{
|
||||
searchText: 'learner',
|
||||
output: 'Showing 2 results for',
|
||||
learnersCount: 2,
|
||||
username:
|
||||
['learner-1', 'learner-2'],
|
||||
},
|
||||
])('should have a search bar with a clear button and \'$output\' results found text.',
|
||||
async ({
|
||||
searchText, output, learnersCount, username,
|
||||
}) => {
|
||||
await setUpLearnerMockResponse();
|
||||
await assignPrivilages();
|
||||
await renderComponent();
|
||||
|
||||
const searchField = within(container).getByPlaceholderText('Search learners');
|
||||
const searchButton = within(container).getByTestId('search-icon');
|
||||
|
||||
await fireEvent.change(searchField, { target: { value: searchText } });
|
||||
await act(async () => {
|
||||
fireEvent.click(searchButton);
|
||||
setUpLearnerMockResponse(learnersCount, learnersCount, 1, username, searchText, 1);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const clearButton = within(container).queryByText('Clear results');
|
||||
const searchMessage = within(container).queryByText(`${output} "${searchText}"`);
|
||||
const leaners = container.querySelectorAll('.discussion-post') ?? [];
|
||||
|
||||
expect(searchMessage).toBeInTheDocument();
|
||||
expect(clearButton).toBeInTheDocument();
|
||||
expect(leaners).toHaveLength(learnersCount);
|
||||
});
|
||||
});
|
||||
|
||||
test('When click on the clear button it should move to a list of all learners.', async () => {
|
||||
await setUpLearnerMockResponse();
|
||||
await assignPrivilages(true);
|
||||
await renderComponent();
|
||||
|
||||
const searchField = within(container).getByPlaceholderText('Search learners');
|
||||
const searchButton = within(container).getByTestId('search-icon');
|
||||
let clearButton;
|
||||
|
||||
await fireEvent.change(searchField, { target: { value: 'learner' } });
|
||||
await act(async () => {
|
||||
fireEvent.click(searchButton);
|
||||
setUpLearnerMockResponse(2, 2, 1, ['learner-1', 'learner-2'], 'learner');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
clearButton = within(container).queryByText('Clear results');
|
||||
});
|
||||
await act(async () => fireEvent.click(clearButton));
|
||||
await waitFor(() => {
|
||||
setUpLearnerMockResponse();
|
||||
});
|
||||
|
||||
const learners = container.querySelectorAll('.discussion-post');
|
||||
|
||||
expect(learners).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should display reported and previously reported message by passing activeFlags or inactiveFlags',
|
||||
async () => {
|
||||
await setUpLearnerMockResponse(2, 2, 1, ['learner-1', 'learner-2'], '', 1, 1);
|
||||
await assignPrivilages(true);
|
||||
await renderComponent();
|
||||
await waitFor(() => container.querySelector('.text-danger'));
|
||||
|
||||
const reportedIcon = container.querySelector('.text-danger');
|
||||
|
||||
await act(async () => fireEvent.mouseEnter(reportedIcon));
|
||||
|
||||
const reported = await screen.getByText('2 reported');
|
||||
const previouslyReported = screen.getByText('1 previously reported');
|
||||
|
||||
expect(reportedIcon).toBeInTheDocument();
|
||||
expect(reported).toBeInTheDocument();
|
||||
expect(previouslyReported).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display load more button and display more learners by clicking on button.', async () => {
|
||||
await setUpLearnerMockResponse();
|
||||
await assignPrivilages(true);
|
||||
await renderComponent();
|
||||
|
||||
await waitFor(() => container.querySelector('[data-testid="load-more-learners"]'));
|
||||
|
||||
const loadMoreButton = container.querySelector('[data-testid="load-more-learners"]');
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(loadMoreButton);
|
||||
});
|
||||
|
||||
expect(loadMoreButton).not.toBeInTheDocument();
|
||||
expect(container.querySelectorAll('.discussion-post')).toHaveLength(6);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,9 +2,11 @@ import { Factory } from 'rosie';
|
||||
|
||||
Factory.define('learner')
|
||||
.sequence('id')
|
||||
.attr('username', ['id'], (id) => `leaner-${id}`)
|
||||
.attr('username', ['id'], (id) => `learner-${id}`)
|
||||
.option('activeFlags', null, null)
|
||||
.attr('active_flags', ['activeFlags'], (activeFlags) => activeFlags)
|
||||
.option('inactiveFlags', null, null)
|
||||
.attr('inactive_flags', ['inactiveFlags'], (inactiveFlags) => inactiveFlags)
|
||||
.attrs({
|
||||
threads: 1,
|
||||
replies: 0,
|
||||
@@ -13,24 +15,19 @@ Factory.define('learner')
|
||||
});
|
||||
|
||||
Factory.define('learnersResult')
|
||||
.option('count', null, 3)
|
||||
.option('page', null, 1)
|
||||
.option('pageSize', null, 5)
|
||||
.option('count', null)
|
||||
.option('page', null)
|
||||
.option('pageSize', null)
|
||||
.option('courseId', null, 'course-v1:Test+TestX+Test_Course')
|
||||
.option('activeFlags', null, 0)
|
||||
.option('inactiveFlags', null, 0)
|
||||
.attr(
|
||||
'pagination',
|
||||
['courseId', 'count', 'page', 'pageSize'],
|
||||
(courseId, count, page, pageSize) => {
|
||||
const numPages = Math.ceil(count / pageSize);
|
||||
const next = page < numPages
|
||||
? `http://test.site/api/discussion/v1/courses/course-v1:edX+DemoX+Demo_Course/activity_stats?page=${page + 1
|
||||
}`
|
||||
: null;
|
||||
const prev = page > 1
|
||||
? `http://test.site/api/discussion/v1/courses/course-v1:edX+DemoX+Demo_Course/activity_stats?page=${page - 1
|
||||
}`
|
||||
: null;
|
||||
const numPages = Math.ceil(pageSize / count);
|
||||
const next = page < numPages ? page + 1 : null;
|
||||
const prev = page > 1 ? page - 1 : null;
|
||||
return {
|
||||
next,
|
||||
prev,
|
||||
@@ -41,31 +38,31 @@ Factory.define('learnersResult')
|
||||
)
|
||||
.attr(
|
||||
'results',
|
||||
['count', 'pageSize', 'page', 'courseId', 'activeFlags'],
|
||||
(count, pageSize, page, courseId, activeFlags) => {
|
||||
['count', 'pageSize', 'page', 'courseId', 'activeFlags', 'inactiveFlags'],
|
||||
(count, pageSize, page, courseId, activeFlags, inactiveFlags) => {
|
||||
const attrs = { course_id: courseId };
|
||||
Object.keys(attrs).forEach((key) => (attrs[key] === undefined ? delete attrs[key] : {}));
|
||||
const len = pageSize * page <= count ? pageSize : count % pageSize;
|
||||
let learners = [];
|
||||
|
||||
if (activeFlags && activeFlags <= len) {
|
||||
if (activeFlags <= len) {
|
||||
learners = Factory.buildList('learner', len - activeFlags, attrs);
|
||||
learners = learners.concat(
|
||||
Factory.buildList(
|
||||
'learner',
|
||||
activeFlags,
|
||||
{ ...attrs, active_flags: Math.floor(Math.random() * 10) + 1 },
|
||||
{ ...attrs, active_flags: activeFlags + 1, inactive_flags: inactiveFlags },
|
||||
),
|
||||
);
|
||||
} else {
|
||||
learners = Factory.buildList('learner', len, attrs);
|
||||
learners = Factory.buildList('learner', len, attrs, activeFlags, inactiveFlags);
|
||||
}
|
||||
return learners;
|
||||
},
|
||||
);
|
||||
|
||||
Factory.define('learnersProfile')
|
||||
.option('usernames', null, ['leaner-1', 'leaner-2', 'leaner-3'])
|
||||
.option('usernames', null, ['learner-1', 'learner-2', 'learner-3'])
|
||||
.attr('profiles', ['usernames'], (usernames) => {
|
||||
const profiles = usernames.map((user) => ({
|
||||
account_privacy: 'private',
|
||||
@@ -141,8 +138,8 @@ Factory.define('learnerPosts')
|
||||
},
|
||||
)
|
||||
.attr('pagination', [], () => ({
|
||||
next: null,
|
||||
next: 1,
|
||||
prev: null,
|
||||
count: 2,
|
||||
num_pages: 1,
|
||||
num_pages: 2,
|
||||
}));
|
||||
|
||||
@@ -10,6 +10,8 @@ ensureConfig([
|
||||
|
||||
export const getCoursesApiUrl = () => `${getConfig().LMS_BASE_URL}/api/discussion/v1/courses/`;
|
||||
export const getUserProfileApiUrl = () => `${getConfig().LMS_BASE_URL}/api/user/v1/accounts`;
|
||||
export const learnerPostsApiUrl = (courseId) => `${getCoursesApiUrl()}${courseId}/learner/`;
|
||||
export const learnersApiUrl = (courseId) => `${getCoursesApiUrl()}${courseId}/activity_stats/`;
|
||||
|
||||
/**
|
||||
* Fetches all the learners in the given course.
|
||||
@@ -18,8 +20,7 @@ export const getUserProfileApiUrl = () => `${getConfig().LMS_BASE_URL}/api/user/
|
||||
* @returns {Promise<{}>}
|
||||
*/
|
||||
export async function getLearners(courseId, params) {
|
||||
const url = `${getCoursesApiUrl()}${courseId}/activity_stats/`;
|
||||
const { data } = await getAuthenticatedHttpClient().get(url, { params });
|
||||
const { data } = await getAuthenticatedHttpClient().get(learnersApiUrl(courseId), { params });
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -65,8 +66,6 @@ export async function getUserPosts(courseId, {
|
||||
countFlagged,
|
||||
cohort,
|
||||
} = {}) {
|
||||
const learnerPostsApiUrl = `${getCoursesApiUrl()}${courseId}/learner/`;
|
||||
|
||||
const params = snakeCaseObject({
|
||||
page,
|
||||
pageSize,
|
||||
@@ -81,6 +80,6 @@ export async function getUserPosts(courseId, {
|
||||
});
|
||||
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(learnerPostsApiUrl, { params });
|
||||
.get(learnerPostsApiUrl(courseId), { params });
|
||||
return data;
|
||||
}
|
||||
|
||||
79
src/discussions/learners/data/api.test.jsx
Normal file
79
src/discussions/learners/data/api.test.jsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { Factory } from 'rosie';
|
||||
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { initializeMockApp } from '@edx/frontend-platform/testing';
|
||||
|
||||
import { setupLearnerMockResponse, setupPostsMockResponse } from '../test-utils';
|
||||
|
||||
import './__factories__';
|
||||
|
||||
const courseId2 = 'course-v1:edX+TestX+Test_Course2';
|
||||
let axiosMock;
|
||||
|
||||
describe('Learner api test cases', () => {
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
Factory.resetAll();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
axiosMock.reset();
|
||||
});
|
||||
|
||||
it('Successfully get and store API response for the learner\'s list and learners posts in redux',
|
||||
async () => {
|
||||
const learners = await setupLearnerMockResponse();
|
||||
const threads = await setupPostsMockResponse();
|
||||
|
||||
expect(learners.status).toEqual('successful');
|
||||
expect(Object.values(learners.learnerProfiles)).toHaveLength(3);
|
||||
expect(threads.status).toEqual('successful');
|
||||
expect(Object.values(threads.threadsById)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ status: 'statusUnread', search: 'Title', cohort: 'post' },
|
||||
{ status: 'statusUnanswered', search: 'Title', cohort: 'post' },
|
||||
{ status: 'statusReported', search: 'Title', cohort: 'post' },
|
||||
{ status: 'statusUnresponded', search: 'Title', cohort: 'post' },
|
||||
])('Successfully fetch user posts based on %s filters',
|
||||
async ({ status, search, cohort }) => {
|
||||
const threads = await setupPostsMockResponse({ filters: { status, search, cohort } });
|
||||
|
||||
expect(threads.status).toEqual('successful');
|
||||
expect(Object.values(threads.threadsById)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('Failed to fetch learners', async () => {
|
||||
const learners = await setupLearnerMockResponse({ learnerCourseId: courseId2 });
|
||||
|
||||
expect(learners.status).toEqual('failed');
|
||||
});
|
||||
|
||||
it('Denied to fetch learners', async () => {
|
||||
const learners = await setupLearnerMockResponse({ statusCode: 403 });
|
||||
|
||||
expect(learners.status).toEqual('denied');
|
||||
});
|
||||
|
||||
it('Failed to fetch learnerPosts', async () => {
|
||||
const threads = await setupPostsMockResponse({ learnerCourseId: courseId2 });
|
||||
|
||||
expect(threads.status).toEqual('failed');
|
||||
});
|
||||
|
||||
it('Denied to fetch learnerPosts', async () => {
|
||||
const threads = await setupPostsMockResponse({ statusCode: 403 });
|
||||
|
||||
expect(threads.status).toEqual('denied');
|
||||
});
|
||||
});
|
||||
120
src/discussions/learners/data/redux.test.jsx
Normal file
120
src/discussions/learners/data/redux.test.jsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { Factory } from 'rosie';
|
||||
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { initializeMockApp } from '@edx/frontend-platform/testing';
|
||||
|
||||
import { initializeStore } from '../../../store';
|
||||
import { executeThunk } from '../../../test-utils';
|
||||
import { setupLearnerMockResponse } from '../test-utils';
|
||||
import { setPostFilter, setSortedBy, setUsernameSearch } from './slices';
|
||||
import { fetchLearners } from './thunks';
|
||||
|
||||
import './__factories__';
|
||||
|
||||
let axiosMock;
|
||||
let store;
|
||||
|
||||
describe('Learner redux test cases', () => {
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
Factory.resetAll();
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
axiosMock.reset();
|
||||
});
|
||||
|
||||
test('Successfully load initial states in redux', async () => {
|
||||
executeThunk(
|
||||
fetchLearners('course-v1:edX+DemoX+Demo_Course', { usernameSearch: 'learner-1' }),
|
||||
store.dispatch, store.getState,
|
||||
);
|
||||
const { learners } = store.getState();
|
||||
|
||||
expect(learners.status).toEqual('in-progress');
|
||||
expect(learners.learnerProfiles).toEqual({});
|
||||
expect(learners.pages).toHaveLength(0);
|
||||
expect(learners.nextPage).toBeNull();
|
||||
expect(learners.totalPages).toBeNull();
|
||||
expect(learners.totalLearners).toBeNull();
|
||||
expect(learners.sortedBy).toEqual('activity');
|
||||
expect(learners.usernameSearch).toBeNull();
|
||||
expect(learners.postFilter.postType).toEqual('all');
|
||||
expect(learners.postFilter.status).toEqual('statusAll');
|
||||
expect(learners.postFilter.orderBy).toEqual('lastActivityAt');
|
||||
expect(learners.postFilter.cohort).toEqual('');
|
||||
});
|
||||
|
||||
test('Successfully store a learner posts stats data as pages object in redux',
|
||||
async () => {
|
||||
const learners = await setupLearnerMockResponse();
|
||||
const page = learners.pages[0];
|
||||
const statsObject = page[0];
|
||||
|
||||
expect(page).toHaveLength(3);
|
||||
expect(statsObject.responses).toEqual(3);
|
||||
expect(statsObject.threads).toEqual(1);
|
||||
expect(statsObject.replies).toEqual(0);
|
||||
});
|
||||
|
||||
test('Successfully store the nextPage, totalPages, totalLearners, and sortedBy data in redux',
|
||||
async () => {
|
||||
const learners = await setupLearnerMockResponse();
|
||||
|
||||
expect(learners.nextPage).toEqual(2);
|
||||
expect(learners.totalPages).toEqual(2);
|
||||
expect(learners.totalLearners).toEqual(3);
|
||||
expect(learners.sortedBy).toEqual('activity');
|
||||
});
|
||||
|
||||
test('Successfully updated the learner\'s sort data in redux', async () => {
|
||||
const learners = await setupLearnerMockResponse();
|
||||
|
||||
expect(learners.sortedBy).toEqual('activity');
|
||||
expect(learners.pages[0]).toHaveLength(3);
|
||||
|
||||
await store.dispatch(setSortedBy('recency'));
|
||||
const updatedLearners = store.getState().learners;
|
||||
|
||||
expect(updatedLearners.sortedBy).toEqual('recency');
|
||||
expect(updatedLearners.pages).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('Successfully updated the post-filter data in redux', async () => {
|
||||
const learners = await setupLearnerMockResponse();
|
||||
const filter = {
|
||||
...learners.postFilter,
|
||||
postType: 'discussion',
|
||||
};
|
||||
|
||||
expect(learners.postFilter.postType).toEqual('all');
|
||||
|
||||
await store.dispatch(setPostFilter(filter));
|
||||
const updatedLearners = store.getState().learners;
|
||||
|
||||
expect(updatedLearners.postFilter.postType).toEqual('discussion');
|
||||
expect(updatedLearners.pages).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('Successfully update the learner\'s search query in redux when searching for a learner',
|
||||
async () => {
|
||||
const learners = await setupLearnerMockResponse();
|
||||
|
||||
expect(learners.usernameSearch).toBeNull();
|
||||
|
||||
await store.dispatch(setUsernameSearch('learner-2'));
|
||||
const updatedLearners = store.getState().learners;
|
||||
|
||||
expect(updatedLearners.usernameSearch).toEqual('learner-2');
|
||||
});
|
||||
});
|
||||
82
src/discussions/learners/data/selector.test.jsx
Normal file
82
src/discussions/learners/data/selector.test.jsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { Factory } from 'rosie';
|
||||
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { initializeMockApp } from '@edx/frontend-platform/testing';
|
||||
|
||||
import { initializeStore } from '../../../store';
|
||||
import { executeThunk } from '../../../test-utils';
|
||||
import { getUserProfileApiUrl, learnersApiUrl } from './api';
|
||||
import {
|
||||
learnersLoadingStatus,
|
||||
selectLearnerNextPage,
|
||||
selectLearnerSorting,
|
||||
selectUsernameSearch,
|
||||
} from './selectors';
|
||||
import { fetchLearners } from './thunks';
|
||||
|
||||
import './__factories__';
|
||||
|
||||
const courseId = 'course-v1:edX+DemoX+Demo_Course';
|
||||
const userProfileApiUrl = getUserProfileApiUrl();
|
||||
|
||||
let axiosMock;
|
||||
let store;
|
||||
const username = 'abc123';
|
||||
const learnerCount = 3;
|
||||
let state;
|
||||
|
||||
describe('Learner selectors test cases', () => {
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username,
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
Factory.resetAll();
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
|
||||
axiosMock.onGet(learnersApiUrl(courseId))
|
||||
.reply(() => [200, Factory.build('learnersResult', {}, {
|
||||
count: learnerCount,
|
||||
pageSize: 6,
|
||||
page: 1,
|
||||
})]);
|
||||
|
||||
axiosMock.onGet(`${userProfileApiUrl}?username=learner-1,learner-2,learner-3`)
|
||||
.reply(() => [200, Factory.build('learnersProfile', {}, {
|
||||
username: ['learner-1', 'learner-2', 'learner-3'],
|
||||
}).profiles]);
|
||||
|
||||
await executeThunk(fetchLearners(courseId), store.dispatch, store.getState);
|
||||
state = store.getState();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
axiosMock.reset();
|
||||
});
|
||||
|
||||
test('learnersLoadingStatus should return learners list loading status.', async () => {
|
||||
const status = learnersLoadingStatus()(state);
|
||||
expect(status).toEqual('successful');
|
||||
});
|
||||
|
||||
test('selectUsernameSearch should return a learner search query.', async () => {
|
||||
const userNameSearch = selectUsernameSearch()(state);
|
||||
expect(userNameSearch).toBeNull();
|
||||
});
|
||||
|
||||
test('selectLearnerSorting should return learner sortedBy.', async () => {
|
||||
const learnerSorting = selectLearnerSorting()(state);
|
||||
expect(learnerSorting).toEqual('activity');
|
||||
});
|
||||
|
||||
test('selectLearnerNextPage should return learners next page.', async () => {
|
||||
const learnerNextPage = selectLearnerNextPage()(state);
|
||||
expect(learnerNextPage).toEqual(2);
|
||||
});
|
||||
});
|
||||
@@ -18,12 +18,3 @@ export const selectLearnerNextPage = () => state => state.learners.nextPage;
|
||||
export const selectLearnerAvatar = author => state => (
|
||||
state.learners.learnerProfiles[author]?.profileImage?.imageUrlLarge
|
||||
);
|
||||
|
||||
export const selectLearnerLastLogin = author => state => (
|
||||
state.learners.learnerProfiles[author]?.lastLogin
|
||||
);
|
||||
|
||||
export const selectLearner = (username) => createSelector(
|
||||
[selectAllLearners],
|
||||
learners => learners.find(l => l.username === username) || {},
|
||||
);
|
||||
|
||||
@@ -24,9 +24,11 @@ const ActionItem = ({
|
||||
<label
|
||||
htmlFor={id}
|
||||
className="focus border-bottom-0 d-flex align-items-center w-100 py-2 m-0 font-weight-500 filter-menu"
|
||||
data-testid={value === selected ? 'selected' : null}
|
||||
data-testid={value === selected ? `${value} selected` : null}
|
||||
style={{ cursor: 'pointer' }}
|
||||
aria-checked={value === selected}
|
||||
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
|
||||
tabIndex={value === selected ? '0' : '-1'}
|
||||
>
|
||||
<Icon src={Check} className={classNames('text-success mr-2', { invisible: value !== selected })} />
|
||||
<Form.Radio id={id} className="sr-only sr-only-focusable" value={value} tabIndex="0">
|
||||
|
||||
63
src/discussions/learners/test-utils.js
Normal file
63
src/discussions/learners/test-utils.js
Normal file
@@ -0,0 +1,63 @@
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { Factory } from 'rosie';
|
||||
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
import { initializeStore } from '../../store';
|
||||
import { executeThunk } from '../../test-utils';
|
||||
import { getDiscussionsConfigUrl } from '../data/api';
|
||||
import { fetchCourseConfig } from '../data/thunks';
|
||||
import { getUserProfileApiUrl, learnerPostsApiUrl, learnersApiUrl } from './data/api';
|
||||
import { fetchLearners, fetchUserPosts } from './data/thunks';
|
||||
|
||||
const courseId = 'course-v1:edX+DemoX+Demo_Course';
|
||||
|
||||
export async function setupLearnerMockResponse({
|
||||
learnerCourseId = courseId,
|
||||
statusCode = 200,
|
||||
learnerCount = 3,
|
||||
} = {}) {
|
||||
const store = initializeStore();
|
||||
const axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
|
||||
axiosMock.onGet(learnersApiUrl(learnerCourseId))
|
||||
.reply(() => [statusCode, Factory.build('learnersResult', {}, {
|
||||
count: learnerCount,
|
||||
pageSize: 6,
|
||||
page: 1,
|
||||
})]);
|
||||
|
||||
axiosMock.onGet(`${getUserProfileApiUrl()}?username=learner-1,learner-2,learner-3`)
|
||||
.reply(() => [statusCode, Factory.build('learnersProfile', {}, {
|
||||
username: ['learner-1', 'learner-2', 'learner-3'],
|
||||
}).profiles]);
|
||||
|
||||
await executeThunk(fetchLearners(courseId), store.dispatch, store.getState);
|
||||
return store.getState().learners;
|
||||
}
|
||||
|
||||
export async function setupPostsMockResponse({
|
||||
learnerCourseId = courseId,
|
||||
statusCode = 200,
|
||||
username = 'abc123',
|
||||
filters = { status: 'all' },
|
||||
} = {}) {
|
||||
const store = initializeStore();
|
||||
const axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
|
||||
axiosMock.onGet(learnerPostsApiUrl(learnerCourseId), { username, count_flagged: true })
|
||||
.reply(() => [statusCode, Factory.build('learnerPosts', {}, {
|
||||
abuseFlaggedCount: 1,
|
||||
})]);
|
||||
|
||||
await executeThunk(fetchUserPosts(courseId, { filters }), store.dispatch, store.getState);
|
||||
return store.getState().threads;
|
||||
}
|
||||
|
||||
export async function setUpPrivilages(axiosMock, store, hasModerationPrivileges) {
|
||||
axiosMock.onGet(getDiscussionsConfigUrl(courseId)).reply(200, {
|
||||
hasModerationPrivileges,
|
||||
});
|
||||
|
||||
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
|
||||
}
|
||||
@@ -38,13 +38,12 @@ function NavigationBar({ intl }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Nav variant="pills" className="py-2">
|
||||
<Nav variant="button-group" className="py-2">
|
||||
{navLinks.map(link => (
|
||||
<Nav.Item key={link.route}>
|
||||
<Nav.Link
|
||||
as={NavLink}
|
||||
to={discussionsPath(link.route, { courseId })}
|
||||
className="border"
|
||||
isActive={link.isActive}
|
||||
>
|
||||
{intl.formatMessage(link.labelMessage)}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useParams } from 'react-router';
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
|
||||
@@ -10,9 +9,7 @@ import {
|
||||
} from '@edx/paragon';
|
||||
import { ArrowBack } from '@edx/paragon/icons';
|
||||
|
||||
import {
|
||||
EndorsementStatus, PostsPages, RequestStatus, ThreadType,
|
||||
} from '../../data/constants';
|
||||
import { EndorsementStatus, PostsPages, ThreadType } from '../../data/constants';
|
||||
import { useDispatchWithState } from '../../data/hooks';
|
||||
import { DiscussionContext } from '../common/context';
|
||||
import { useIsOnDesktop } from '../data/hooks';
|
||||
@@ -24,14 +21,12 @@ import { ResponseEditor } from './comments/comment';
|
||||
import CommentsSort from './comments/CommentsSort';
|
||||
import CommentsView from './comments/CommentsView';
|
||||
import { useCommentsCount, usePost } from './data/hooks';
|
||||
import { selectCommentsStatus } from './data/selectors';
|
||||
import messages from './messages';
|
||||
|
||||
function PostCommentsView({ intl }) {
|
||||
const [isLoading, submitDispatch] = useDispatchWithState();
|
||||
const { postId } = useParams();
|
||||
const thread = usePost(postId);
|
||||
const commentsStatus = useSelector(selectCommentsStatus);
|
||||
const commentsCount = useCommentsCount(postId);
|
||||
const history = useHistory();
|
||||
const location = useLocation();
|
||||
@@ -40,7 +35,6 @@ function PostCommentsView({ intl }) {
|
||||
const {
|
||||
courseId, learnerUsername, category, topicId, page, enableInContextSidebar,
|
||||
} = useContext(DiscussionContext);
|
||||
const enableCommentsSort = false;
|
||||
|
||||
useEffect(() => {
|
||||
if (!thread) { submitDispatch(fetchThread(postId, courseId, true)); }
|
||||
@@ -99,7 +93,7 @@ function PostCommentsView({ intl }) {
|
||||
)
|
||||
)}
|
||||
<div
|
||||
className="discussion-comments d-flex flex-column card border-0 post-card-margin post-card-padding"
|
||||
className="discussion-comments d-flex flex-column card border-0 post-card-margin post-card-padding on-focus"
|
||||
>
|
||||
<Post post={thread} handleAddResponseButton={() => setAddingResponse(true)} />
|
||||
{!thread.closed && (
|
||||
@@ -110,7 +104,7 @@ function PostCommentsView({ intl }) {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{!!commentsCount && commentsStatus === RequestStatus.SUCCESSFUL && enableCommentsSort && <CommentsSort />}
|
||||
{!!commentsCount && <CommentsSort />}
|
||||
{thread.type === ThreadType.DISCUSSION && (
|
||||
<CommentsView
|
||||
postId={postId}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { camelCaseObject, initializeMockApp } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
|
||||
import { getApiBaseUrl } from '../../data/constants';
|
||||
import { initializeStore } from '../../store';
|
||||
import { executeThunk } from '../../test-utils';
|
||||
import { DiscussionContext } from '../common/context';
|
||||
@@ -17,11 +18,18 @@ import { getCourseConfigApiUrl } from '../data/api';
|
||||
import { fetchCourseConfig } from '../data/thunks';
|
||||
import DiscussionContent from '../discussions-home/DiscussionContent';
|
||||
import { getThreadsApiUrl } from '../posts/data/api';
|
||||
import { fetchThreads } from '../posts/data/thunks';
|
||||
import { fetchThread, fetchThreads } from '../posts/data/thunks';
|
||||
import { fetchCourseTopics } from '../topics/data/thunks';
|
||||
import { getDiscussionTourUrl } from '../tours/data/api';
|
||||
import { selectTours } from '../tours/data/selectors';
|
||||
import { fetchDiscussionTours } from '../tours/data/thunks';
|
||||
import discussionTourFactory from '../tours/data/tours.factory';
|
||||
import { getCommentsApiUrl } from './data/api';
|
||||
import { removeComment } from './data/thunks';
|
||||
|
||||
import '../posts/data/__factories__';
|
||||
import './data/__factories__';
|
||||
import '../topics/data/__factories__';
|
||||
|
||||
const courseConfigApiUrl = getCourseConfigApiUrl();
|
||||
const commentsApiUrl = getCommentsApiUrl();
|
||||
@@ -30,10 +38,14 @@ const discussionPostId = 'thread-1';
|
||||
const questionPostId = 'thread-2';
|
||||
const closedPostId = 'thread-2';
|
||||
const courseId = 'course-v1:edX+TestX+Test_Course';
|
||||
const reverseOrder = false;
|
||||
const topicsApiUrl = `${getApiBaseUrl()}/api/discussion/v1/course_topics/${courseId}`;
|
||||
const reverseOrder = true;
|
||||
const enableInContextSidebar = false;
|
||||
let store;
|
||||
let axiosMock;
|
||||
let testLocation;
|
||||
let container;
|
||||
let unmount;
|
||||
|
||||
function mockAxiosReturnPagedComments() {
|
||||
[null, false, true].forEach(endorsed => {
|
||||
@@ -48,6 +60,7 @@ function mockAxiosReturnPagedComments() {
|
||||
requested_fields: 'profile_image',
|
||||
endorsed,
|
||||
reverse_order: reverseOrder,
|
||||
enable_in_context_sidebar: enableInContextSidebar,
|
||||
},
|
||||
})
|
||||
.reply(200, Factory.build('commentsResult', { can_delete: true }, {
|
||||
@@ -69,6 +82,7 @@ function mockAxiosReturnPagedCommentsResponses() {
|
||||
page: undefined,
|
||||
page_size: undefined,
|
||||
requested_fields: 'profile_image',
|
||||
reverse_order: true,
|
||||
};
|
||||
|
||||
for (let page = 1; page <= 2; page++) {
|
||||
@@ -83,6 +97,12 @@ function mockAxiosReturnPagedCommentsResponses() {
|
||||
}
|
||||
}
|
||||
|
||||
async function getThreadAPIResponse(attr = null) {
|
||||
axiosMock.onGet(`${threadsApiUrl}${discussionPostId}/`)
|
||||
.reply(200, Factory.build('thread', attr));
|
||||
await executeThunk(fetchThread(discussionPostId), store.dispatch, store.getState);
|
||||
}
|
||||
|
||||
function renderComponent(postId) {
|
||||
const wrapper = render(
|
||||
<IntlProvider locale="en">
|
||||
@@ -104,9 +124,49 @@ function renderComponent(postId) {
|
||||
</AppProvider>
|
||||
</IntlProvider>,
|
||||
);
|
||||
return wrapper;
|
||||
container = wrapper.container;
|
||||
unmount = wrapper.unmount;
|
||||
}
|
||||
|
||||
describe('PostView', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
|
||||
store = initializeStore();
|
||||
Factory.resetAll();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
|
||||
axiosMock.onGet(topicsApiUrl)
|
||||
.reply(200, {
|
||||
non_courseware_topics: Factory.buildList('topic', 1, {}, { topicPrefix: 'non-courseware-' }),
|
||||
courseware_topics: Factory.buildList('category', 1, {}, { name: 'courseware' }),
|
||||
});
|
||||
executeThunk(fetchCourseTopics(courseId), store.dispatch, store.getState);
|
||||
});
|
||||
|
||||
it('should show Topic Info for non-courseware topics', async () => {
|
||||
await getThreadAPIResponse({ id: 'thread-1', topic_id: 'non-courseware-topic-1' });
|
||||
renderComponent(discussionPostId);
|
||||
expect(await screen.findByText('Related to')).toBeInTheDocument();
|
||||
expect(await screen.findByText('non-courseware-topic 1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show Topic Info for courseware topics with category', async () => {
|
||||
await getThreadAPIResponse({ id: 'thread-2', topic_id: 'courseware-topic-2' });
|
||||
|
||||
renderComponent('thread-2');
|
||||
expect(await screen.findByText('Related to')).toBeInTheDocument();
|
||||
expect(await screen.findByText('category-1 / courseware-topic 2')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ThreadView', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
@@ -163,6 +223,36 @@ describe('ThreadView', () => {
|
||||
expect(JSON.parse(axiosMock.history.patch[axiosMock.history.patch.length - 1].data)).toMatchObject(data);
|
||||
}
|
||||
|
||||
it('should display post content', async () => {
|
||||
renderComponent(discussionPostId);
|
||||
const post = screen.getByTestId('post-thread-1');
|
||||
expect(within(post).queryByTestId(discussionPostId)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display comment content', async () => {
|
||||
renderComponent(discussionPostId);
|
||||
const comment = await waitFor(() => screen.findByTestId('comment-comment-1'));
|
||||
expect(within(comment).queryByTestId('comment-1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show post footer', async () => {
|
||||
Factory.resetAll();
|
||||
await getThreadAPIResponse({
|
||||
vote_count: 0, following: false, closed: false, group_id: null,
|
||||
});
|
||||
renderComponent(discussionPostId);
|
||||
expect(screen.queryByTestId('post-footer')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show post footer', async () => {
|
||||
Factory.resetAll();
|
||||
await getThreadAPIResponse({
|
||||
vote_count: 4, following: true, closed: false, group_id: null,
|
||||
});
|
||||
renderComponent(discussionPostId);
|
||||
expect(screen.queryByTestId('post-footer')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show and hide the editor', async () => {
|
||||
renderComponent(discussionPostId);
|
||||
const post = screen.getByTestId('post-thread-1');
|
||||
@@ -681,19 +771,101 @@ describe('ThreadView', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('for comments replies', () => {
|
||||
describe('For comments replies', () => {
|
||||
it('shows action dropdown for replies', async () => {
|
||||
renderComponent(discussionPostId);
|
||||
|
||||
const reply = await waitFor(() => screen.findByTestId('reply-comment-7'));
|
||||
expect(within(reply).getByRole('button', { name: /actions menu/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display reply content', async () => {
|
||||
renderComponent(discussionPostId);
|
||||
|
||||
const reply = await waitFor(() => screen.findByTestId('reply-comment-7'));
|
||||
expect(within(reply).queryByTestId('comment-7')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows delete confirmation modal', async () => {
|
||||
renderComponent(discussionPostId);
|
||||
|
||||
const reply = await waitFor(() => screen.findByTestId('reply-comment-7'));
|
||||
await act(async () => {
|
||||
fireEvent.click(
|
||||
within(reply).getByRole('button', { name: /actions menu/i }),
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.queryByRole('button', { name: /Delete/i }));
|
||||
});
|
||||
await act(async () => { fireEvent.click(within(reply).getByRole('button', { name: /actions menu/i })); });
|
||||
await act(async () => { fireEvent.click(screen.queryByRole('button', { name: /Delete/i })); });
|
||||
|
||||
expect(screen.queryByRole('dialog', { name: /Delete/i, exact: false })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('for comments sort', () => {
|
||||
const getCommentSortDropdown = async () => {
|
||||
renderComponent(discussionPostId);
|
||||
|
||||
await waitFor(() => screen.findByTestId('comment-comment-1'));
|
||||
await act(async () => { fireEvent.click(screen.getByRole('button', { name: /Newest first/i })); });
|
||||
return waitFor(() => screen.findByTestId('comment-sort-dropdown-modal-popup'));
|
||||
};
|
||||
|
||||
it('should show sort dropdown if there are endorse or unendorsed comments', async () => {
|
||||
renderComponent(discussionPostId);
|
||||
|
||||
const comment = await waitFor(() => screen.findByTestId('comment-comment-1'));
|
||||
const sortWrapper = container.querySelector('.comments-sort');
|
||||
const sortDropDown = within(sortWrapper).getByRole('button', { name: /Newest first/i });
|
||||
|
||||
expect(comment).toBeInTheDocument();
|
||||
expect(sortDropDown).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show sort dropdown if there is no response', async () => {
|
||||
const commentId = 'comment-1';
|
||||
renderComponent(discussionPostId);
|
||||
|
||||
await waitFor(() => screen.findByTestId('comment-comment-1'));
|
||||
axiosMock.onDelete(`${commentsApiUrl}${commentId}/`).reply(201);
|
||||
await executeThunk(removeComment(commentId, discussionPostId), store.dispatch, store.getState);
|
||||
|
||||
expect(await waitFor(() => screen.findByText('No responses', { exact: true }))).toBeInTheDocument();
|
||||
expect(container.querySelector('.comments-sort')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have only two options', async () => {
|
||||
const dropdown = await getCommentSortDropdown();
|
||||
|
||||
expect(dropdown).toBeInTheDocument();
|
||||
expect(await within(dropdown).getAllByRole('button')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should be selected Newest first and auto focus', async () => {
|
||||
const dropdown = await getCommentSortDropdown();
|
||||
|
||||
expect(within(dropdown).getByRole('button', { name: /Newest first/i })).toBeInTheDocument();
|
||||
expect(within(dropdown).getByRole('button', { name: /Newest first/i })).toHaveFocus();
|
||||
expect(within(dropdown).getByRole('button', { name: /Oldest first/i })).not.toHaveFocus();
|
||||
});
|
||||
|
||||
test('successfully handles sort state update', async () => {
|
||||
const dropdown = await getCommentSortDropdown();
|
||||
|
||||
expect(store.getState().comments.sortOrder).toBeTruthy();
|
||||
await act(async () => { fireEvent.click(within(dropdown).getByRole('button', { name: /Oldest first/i })); });
|
||||
|
||||
expect(store.getState().comments.sortOrder).toBeFalsy();
|
||||
});
|
||||
|
||||
test('successfully handles tour state update', async () => {
|
||||
const tourName = 'response_sort';
|
||||
await axiosMock.onGet(getDiscussionTourUrl(), {}).reply(200, [discussionTourFactory.build({ tourName })]);
|
||||
await executeThunk(fetchDiscussionTours(), store.dispatch, store.getState);
|
||||
|
||||
renderComponent(discussionPostId);
|
||||
|
||||
await waitFor(() => screen.findByTestId('comment-comment-1'));
|
||||
const responseSortTour = () => selectTours(store.getState()).find(item => item.tourName === 'response_sort');
|
||||
|
||||
expect(responseSortTour().enabled).toBeTruthy();
|
||||
await unmount();
|
||||
expect(responseSortTour().enabled).toBeFalsy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
@@ -6,31 +6,43 @@ import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Button, Dropdown, ModalPopup, useToggle,
|
||||
} from '@edx/paragon';
|
||||
import {
|
||||
ExpandLess, ExpandMore,
|
||||
} from '@edx/paragon/icons';
|
||||
import { ExpandLess, ExpandMore } from '@edx/paragon/icons';
|
||||
|
||||
import { updateUserDiscussionsTourByName } from '../../tours/data';
|
||||
import { selectCommentSortOrder } from '../data/selectors';
|
||||
import { setCommentSortOrder } from '../data/slices';
|
||||
import messages from '../messages';
|
||||
|
||||
function CommentSortDropdown({
|
||||
intl,
|
||||
}) {
|
||||
function CommentSortDropdown({ intl }) {
|
||||
const dispatch = useDispatch();
|
||||
const sortedOrder = useSelector(selectCommentSortOrder);
|
||||
const [isOpen, open, close] = useToggle(false);
|
||||
const [target, setTarget] = useState(null);
|
||||
|
||||
const handleActions = (reverseOrder) => {
|
||||
close();
|
||||
dispatch(setCommentSortOrder(reverseOrder));
|
||||
};
|
||||
|
||||
const enableCommentsSortTour = useCallback((enabled) => {
|
||||
const data = {
|
||||
enabled,
|
||||
tourName: 'response_sort',
|
||||
};
|
||||
dispatch(updateUserDiscussionsTourByName(data));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
enableCommentsSortTour(true);
|
||||
return () => {
|
||||
enableCommentsSortTour(false);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="comments-sort d-flex justify-content-end mx-4 mt-2">
|
||||
<Button
|
||||
id="comment-sort"
|
||||
alt={intl.formatMessage(messages.actionsAlt)}
|
||||
ref={setTarget}
|
||||
variant="tertiary"
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import React, {
|
||||
useCallback,
|
||||
useContext, useEffect, useMemo, useState,
|
||||
} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import classNames from 'classnames';
|
||||
@@ -16,7 +19,12 @@ import { useUserCanAddThreadInBlackoutDate } from '../../../data/hooks';
|
||||
import { fetchThread } from '../../../posts/data/thunks';
|
||||
import LikeButton from '../../../posts/post/LikeButton';
|
||||
import { useActions } from '../../../utils';
|
||||
import { selectCommentCurrentPage, selectCommentHasMorePages, selectCommentResponses } from '../../data/selectors';
|
||||
import {
|
||||
selectCommentCurrentPage,
|
||||
selectCommentHasMorePages,
|
||||
selectCommentResponses,
|
||||
selectCommentSortOrder,
|
||||
} from '../../data/selectors';
|
||||
import { editComment, fetchCommentResponses, removeComment } from '../../data/thunks';
|
||||
import messages from '../../messages';
|
||||
import CommentEditor from './CommentEditor';
|
||||
@@ -43,28 +51,32 @@ function Comment({
|
||||
const hasMorePages = useSelector(selectCommentHasMorePages(comment.id));
|
||||
const currentPage = useSelector(selectCommentCurrentPage(comment.id));
|
||||
const userCanAddThreadInBlackoutDate = useUserCanAddThreadInBlackoutDate();
|
||||
const {
|
||||
courseId,
|
||||
} = useContext(DiscussionContext);
|
||||
const { courseId } = useContext(DiscussionContext);
|
||||
const sortedOrder = useSelector(selectCommentSortOrder);
|
||||
|
||||
useEffect(() => {
|
||||
// If the comment has a parent comment, it won't have any children, so don't fetch them.
|
||||
if (hasChildren && !currentPage && showFullThread) {
|
||||
dispatch(fetchCommentResponses(comment.id, { page: 1 }));
|
||||
if (hasChildren && showFullThread) {
|
||||
dispatch(fetchCommentResponses(comment.id, {
|
||||
page: 1,
|
||||
reverseOrder: sortedOrder,
|
||||
}));
|
||||
}
|
||||
}, [comment.id]);
|
||||
}, [comment.id, sortedOrder]);
|
||||
|
||||
const actions = useActions({
|
||||
...comment,
|
||||
postType,
|
||||
});
|
||||
const endorseIcons = actions.find(({ action }) => action === EndorsementStatus.ENDORSED);
|
||||
|
||||
const handleAbusedFlag = () => {
|
||||
const handleAbusedFlag = useCallback(() => {
|
||||
if (comment.abuseFlagged) {
|
||||
dispatch(editComment(comment.id, { flagged: !comment.abuseFlagged }));
|
||||
} else {
|
||||
showReportConfirmation();
|
||||
}
|
||||
};
|
||||
}, [comment.abuseFlagged, comment.id, dispatch, showReportConfirmation]);
|
||||
|
||||
const handleDeleteConfirmation = () => {
|
||||
dispatch(removeComment(comment.id));
|
||||
@@ -76,7 +88,7 @@ function Comment({
|
||||
hideReportConfirmation();
|
||||
};
|
||||
|
||||
const actionHandlers = {
|
||||
const actionHandlers = useMemo(() => ({
|
||||
[ContentActions.EDIT_CONTENT]: () => setEditing(true),
|
||||
[ContentActions.ENDORSE]: async () => {
|
||||
await dispatch(editComment(comment.id, { endorsed: !comment.endorsed }, ContentActions.ENDORSE));
|
||||
@@ -84,10 +96,13 @@ function Comment({
|
||||
},
|
||||
[ContentActions.DELETE]: showDeleteConfirmation,
|
||||
[ContentActions.REPORT]: () => handleAbusedFlag(),
|
||||
};
|
||||
}), [showDeleteConfirmation, dispatch, comment.id, comment.endorsed, comment.threadId, courseId, handleAbusedFlag]);
|
||||
|
||||
const handleLoadMoreComments = () => (
|
||||
dispatch(fetchCommentResponses(comment.id, { page: currentPage + 1 }))
|
||||
dispatch(fetchCommentResponses(comment.id, {
|
||||
page: currentPage + 1,
|
||||
reverseOrder: sortedOrder,
|
||||
}))
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -95,7 +110,7 @@ function Comment({
|
||||
{/* eslint-disable jsx-a11y/no-noninteractive-tabindex */}
|
||||
<div
|
||||
tabIndex="0"
|
||||
className="d-flex flex-column card on-focus"
|
||||
className="d-flex flex-column card on-focus border-0"
|
||||
data-testid={`comment-${comment.id}`}
|
||||
role="listitem"
|
||||
>
|
||||
@@ -119,7 +134,7 @@ function Comment({
|
||||
/>
|
||||
)}
|
||||
<EndorsedAlertBanner postType={postType} content={comment} />
|
||||
<div className="d-flex flex-column post-card-comment px-4 pt-3.5 pb-10px" aria-level={5}>
|
||||
<div className="d-flex flex-column post-card-comment px-4 pt-3.5 pb-10px" tabIndex="0">
|
||||
<HoverCard
|
||||
commentOrPost={comment}
|
||||
actionHandlers={actionHandlers}
|
||||
|
||||
@@ -11,8 +11,9 @@ import { Button, Form, StatefulButton } from '@edx/paragon';
|
||||
|
||||
import { TinyMCEEditor } from '../../../../components';
|
||||
import FormikErrorFeedback from '../../../../components/FormikErrorFeedback';
|
||||
import PostPreviewPane from '../../../../components/PostPreviewPane';
|
||||
import PostPreviewPanel from '../../../../components/PostPreviewPanel';
|
||||
import { useDispatchWithState } from '../../../../data/hooks';
|
||||
import { DiscussionContext } from '../../../common/context';
|
||||
import {
|
||||
selectModerationSettings,
|
||||
selectUserHasModerationPrivileges,
|
||||
@@ -32,6 +33,7 @@ function CommentEditor({
|
||||
}) {
|
||||
const editorRef = useRef(null);
|
||||
const { authenticatedUser } = useContext(AppContext);
|
||||
const { enableInContextSidebar } = useContext(DiscussionContext);
|
||||
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
|
||||
const userIsGroupTa = useSelector(selectUserIsGroupTa);
|
||||
const userIsStaff = useSelector(selectUserIsStaff);
|
||||
@@ -71,7 +73,7 @@ function CommentEditor({
|
||||
};
|
||||
await dispatch(editComment(comment.id, payload));
|
||||
} else {
|
||||
await dispatch(addComment(values.comment, comment.threadId, comment.parentId));
|
||||
await dispatch(addComment(values.comment, comment.threadId, comment.parentId, enableInContextSidebar));
|
||||
}
|
||||
/* istanbul ignore if: TinyMCE is mocked so this cannot be easily tested */
|
||||
if (editorRef.current) {
|
||||
@@ -148,7 +150,7 @@ function CommentEditor({
|
||||
{intl.formatMessage(messages.commentError)}
|
||||
</Form.Control.Feedback>
|
||||
)}
|
||||
<PostPreviewPane htmlNode={values.comment} />
|
||||
<PostPreviewPanel htmlNode={values.comment} />
|
||||
<div className="d-flex py-2 justify-content-end">
|
||||
<Button
|
||||
variant="outline-primary"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
@@ -31,13 +31,13 @@ function Reply({
|
||||
const [isDeleting, showDeleteConfirmation, hideDeleteConfirmation] = useToggle(false);
|
||||
const [isReporting, showReportConfirmation, hideReportConfirmation] = useToggle(false);
|
||||
|
||||
const handleAbusedFlag = () => {
|
||||
const handleAbusedFlag = useCallback(() => {
|
||||
if (reply.abuseFlagged) {
|
||||
dispatch(editComment(reply.id, { flagged: !reply.abuseFlagged }));
|
||||
} else {
|
||||
showReportConfirmation();
|
||||
}
|
||||
};
|
||||
}, [dispatch, reply.abuseFlagged, reply.id, showReportConfirmation]);
|
||||
|
||||
const handleDeleteConfirmation = () => {
|
||||
dispatch(removeComment(reply.id));
|
||||
@@ -49,7 +49,7 @@ function Reply({
|
||||
hideReportConfirmation();
|
||||
};
|
||||
|
||||
const actionHandlers = {
|
||||
const actionHandlers = useMemo(() => ({
|
||||
[ContentActions.EDIT_CONTENT]: () => setEditing(true),
|
||||
[ContentActions.ENDORSE]: () => dispatch(editComment(
|
||||
reply.id,
|
||||
@@ -58,7 +58,8 @@ function Reply({
|
||||
)),
|
||||
[ContentActions.DELETE]: showDeleteConfirmation,
|
||||
[ContentActions.REPORT]: () => handleAbusedFlag(),
|
||||
};
|
||||
}), [dispatch, handleAbusedFlag, reply.endorsed, reply.id, showDeleteConfirmation]);
|
||||
|
||||
const authorAvatars = useSelector(selectAuthorAvatars(reply.author));
|
||||
const colorClass = AvatarOutlineAndLabelColors[reply.authorLabel];
|
||||
const hasAnyAlert = useAlertBannerVisible(reply);
|
||||
|
||||
@@ -16,6 +16,8 @@ export const getCommentsApiUrl = () => `${getConfig().LMS_BASE_URL}/api/discussi
|
||||
* @param {EndorsementStatus} endorsed
|
||||
* @param {number=} page
|
||||
* @param {number=} pageSize
|
||||
* @param reverseOrder
|
||||
* @param enableInContextSidebar
|
||||
* @returns {Promise<{}>}
|
||||
*/
|
||||
export async function getThreadComments(
|
||||
@@ -24,6 +26,7 @@ export async function getThreadComments(
|
||||
page,
|
||||
pageSize,
|
||||
reverseOrder,
|
||||
enableInContextSidebar = false,
|
||||
} = {},
|
||||
) {
|
||||
const params = snakeCaseObject({
|
||||
@@ -33,6 +36,7 @@ export async function getThreadComments(
|
||||
pageSize,
|
||||
reverseOrder,
|
||||
requestedFields: 'profile_image',
|
||||
enableInContextSidebar,
|
||||
});
|
||||
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
@@ -51,6 +55,7 @@ export async function getCommentResponses(
|
||||
commentId, {
|
||||
page,
|
||||
pageSize,
|
||||
reverseOrder,
|
||||
} = {},
|
||||
) {
|
||||
const url = `${getCommentsApiUrl()}${commentId}/`;
|
||||
@@ -58,6 +63,7 @@ export async function getCommentResponses(
|
||||
page,
|
||||
pageSize,
|
||||
requestedFields: 'profile_image',
|
||||
reverseOrder,
|
||||
});
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(url, { params });
|
||||
@@ -69,11 +75,14 @@ export async function getCommentResponses(
|
||||
* @param {string} comment Raw comment data to post.
|
||||
* @param {string} threadId Thread ID for thread in which to post comment.
|
||||
* @param {string=} parentId ID for a comments parent.
|
||||
* @param {boolean} enableInContextSidebar
|
||||
* @returns {Promise<{}>}
|
||||
*/
|
||||
export async function postComment(comment, threadId, parentId = null) {
|
||||
export async function postComment(comment, threadId, parentId = null, enableInContextSidebar = false) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(getCommentsApiUrl(), snakeCaseObject({ threadId, raw_body: comment, parentId }));
|
||||
.post(getCommentsApiUrl(), snakeCaseObject({
|
||||
threadId, raw_body: comment, parentId, enableInContextSidebar,
|
||||
}));
|
||||
return data;
|
||||
}
|
||||
|
||||
|
||||
167
src/discussions/post-comments/data/api.test.js
Normal file
167
src/discussions/post-comments/data/api.test.js
Normal file
@@ -0,0 +1,167 @@
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { Factory } from 'rosie';
|
||||
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { initializeMockApp } from '@edx/frontend-platform/testing';
|
||||
|
||||
import { initializeStore } from '../../../store';
|
||||
import { executeThunk } from '../../../test-utils';
|
||||
import { getCommentsApiUrl } from './api';
|
||||
import {
|
||||
addComment, editComment, fetchCommentResponses, fetchThreadComments, removeComment,
|
||||
} from './thunks';
|
||||
|
||||
import './__factories__';
|
||||
|
||||
const threadId = 'test-thread';
|
||||
const commentId = 'comment-1';
|
||||
const content = 'Test comment';
|
||||
const newComment = 'Edited comment';
|
||||
const commentsApiUrl = getCommentsApiUrl();
|
||||
let axiosMock = null;
|
||||
let store;
|
||||
|
||||
describe('Post comments view api tests', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
store = initializeStore();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
axiosMock.reset();
|
||||
});
|
||||
|
||||
test('successfully get thread comments', async () => {
|
||||
axiosMock.onGet(commentsApiUrl).reply(200, Factory.build('commentsResult'));
|
||||
await executeThunk(fetchThreadComments(threadId, { endorsed: 'discussion' }), store.dispatch, store.getState);
|
||||
|
||||
expect(Object.keys(store.getState().comments.commentsById)).toEqual(['comment-1', 'comment-2', 'comment-3']);
|
||||
});
|
||||
|
||||
it('failed to fetch thread comments', async () => {
|
||||
axiosMock.onGet(commentsApiUrl).reply(404);
|
||||
await executeThunk(fetchThreadComments(threadId, { endorsed: 'discussion' }), store.dispatch, store.getState);
|
||||
|
||||
expect(store.getState().comments.status).toEqual('failed');
|
||||
});
|
||||
|
||||
it('denied to fetch thread comments', async () => {
|
||||
axiosMock.onGet(commentsApiUrl).reply(403, {});
|
||||
await executeThunk(fetchThreadComments(threadId, { endorsed: 'discussion' }), store.dispatch, store.getState);
|
||||
|
||||
expect(store.getState().comments.status).toEqual('denied');
|
||||
});
|
||||
|
||||
test('successfully fetched comment responses', async () => {
|
||||
axiosMock.onGet(`${commentsApiUrl}${commentId}/`)
|
||||
.reply(200, Factory.build('commentsResult', null, { parentId: commentId }));
|
||||
await executeThunk(fetchCommentResponses(commentId), store.dispatch, store.getState);
|
||||
|
||||
expect(store.getState().comments.commentsInComments)
|
||||
.toEqual({ 'comment-1': ['comment-4', 'comment-5', 'comment-6'] });
|
||||
});
|
||||
|
||||
it('failed to fetch comment responses', async () => {
|
||||
axiosMock.onGet(`${commentsApiUrl}${commentId}/`).reply(404);
|
||||
await executeThunk(fetchCommentResponses(commentId), store.dispatch, store.getState);
|
||||
|
||||
expect(store.getState().comments.status).toEqual('failed');
|
||||
});
|
||||
|
||||
it('denied to fetch comment responses', async () => {
|
||||
axiosMock.onGet(`${commentsApiUrl}${commentId}/`).reply(403, {});
|
||||
await executeThunk(fetchCommentResponses(commentId), store.dispatch, store.getState);
|
||||
|
||||
expect(store.getState().comments.status).toEqual('denied');
|
||||
});
|
||||
|
||||
test('successfully added comment', async () => {
|
||||
axiosMock.onGet(commentsApiUrl).reply(200, Factory.build('commentsResult'));
|
||||
await executeThunk(fetchThreadComments(threadId), store.dispatch, store.getState);
|
||||
|
||||
axiosMock.onPost(commentsApiUrl)
|
||||
.reply(200, Factory.build('comment', {
|
||||
thread_id: threadId,
|
||||
raw_body: content,
|
||||
rendered_body: content,
|
||||
}));
|
||||
await executeThunk(addComment(content, threadId, null), store.dispatch, store.getState);
|
||||
|
||||
expect(store.getState().comments.postStatus).toEqual('successful');
|
||||
});
|
||||
|
||||
it('failed to add comment', async () => {
|
||||
axiosMock.onPost(commentsApiUrl).reply(404);
|
||||
await executeThunk(addComment(content, threadId, null), store.dispatch, store.getState);
|
||||
|
||||
expect(store.getState().comments.postStatus).toEqual('failed');
|
||||
});
|
||||
|
||||
it('denied to add comment', async () => {
|
||||
axiosMock.onPost(commentsApiUrl).reply(403, {});
|
||||
await executeThunk(addComment(content, threadId, null), store.dispatch, store.getState);
|
||||
|
||||
expect(store.getState().comments.postStatus).toEqual('denied');
|
||||
});
|
||||
|
||||
test('comment updated successfully', async () => {
|
||||
axiosMock.onGet(commentsApiUrl).reply(200, Factory.build('commentsResult'));
|
||||
await executeThunk(fetchThreadComments(threadId), store.dispatch, store.getState);
|
||||
|
||||
axiosMock.onPatch(`${commentsApiUrl}${commentId}/`)
|
||||
.reply(200, Factory.build('comment', {
|
||||
id: commentId,
|
||||
raw_body: newComment,
|
||||
rendered_body: newComment,
|
||||
}));
|
||||
await executeThunk(editComment(commentId, newComment), store.dispatch, store.getState);
|
||||
|
||||
expect(store.getState().comments.status).toEqual('successful');
|
||||
});
|
||||
|
||||
it('failed to update comment', async () => {
|
||||
axiosMock.onPatch(`${commentsApiUrl}${commentId}/`).reply(404);
|
||||
await executeThunk(editComment(commentId, newComment), store.dispatch, store.getState);
|
||||
|
||||
expect(store.getState().comments.postStatus).toEqual('failed');
|
||||
});
|
||||
|
||||
it('denied to update comment', async () => {
|
||||
axiosMock.onPatch(`${commentsApiUrl}${commentId}/`).reply(403, {});
|
||||
await executeThunk(editComment(commentId, newComment), store.dispatch, store.getState);
|
||||
|
||||
expect(store.getState().comments.postStatus).toEqual('denied');
|
||||
});
|
||||
|
||||
test('comment removed successfully', async () => {
|
||||
axiosMock.onGet(commentsApiUrl).reply(200, Factory.build('commentsResult'));
|
||||
await executeThunk(fetchThreadComments(threadId), store.dispatch, store.getState);
|
||||
|
||||
axiosMock.onDelete(`${commentsApiUrl}${commentId}/`).reply(201);
|
||||
await executeThunk(removeComment(commentId, threadId), store.dispatch, store.getState);
|
||||
|
||||
expect(store.getState().comments.status).toEqual('successful');
|
||||
});
|
||||
|
||||
it('failed to remove comment', async () => {
|
||||
axiosMock.onDelete(`${commentsApiUrl}${commentId}/`).reply(404);
|
||||
await executeThunk(removeComment(commentId, threadId), store.dispatch, store.getState);
|
||||
|
||||
expect(store.getState().comments.postStatus).toEqual('failed');
|
||||
});
|
||||
|
||||
it('denied to remove comment', async () => {
|
||||
axiosMock.onDelete(`${commentsApiUrl}${commentId}/`).reply(403, {});
|
||||
await executeThunk(removeComment(commentId, threadId), store.dispatch, store.getState);
|
||||
|
||||
expect(store.getState().comments.postStatus).toEqual('denied');
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useContext, useEffect } from 'react';
|
||||
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
@@ -6,6 +6,7 @@ import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
|
||||
import { EndorsementStatus } from '../../../data/constants';
|
||||
import { useDispatchWithState } from '../../../data/hooks';
|
||||
import { DiscussionContext } from '../../common/context';
|
||||
import { selectThread } from '../../posts/data/selectors';
|
||||
import { markThreadAsRead } from '../../posts/data/thunks';
|
||||
import {
|
||||
@@ -42,6 +43,7 @@ export function usePostComments(postId, endorsed = null) {
|
||||
const reverseOrder = useSelector(selectCommentSortOrder);
|
||||
const hasMorePages = useSelector(selectThreadHasMorePages(postId, endorsed));
|
||||
const currentPage = useSelector(selectThreadCurrentPage(postId, endorsed));
|
||||
const { enableInContextSidebar } = useContext(DiscussionContext);
|
||||
|
||||
const handleLoadMoreResponses = async () => {
|
||||
const params = {
|
||||
@@ -58,6 +60,7 @@ export function usePostComments(postId, endorsed = null) {
|
||||
endorsed,
|
||||
page: 1,
|
||||
reverseOrder,
|
||||
enableInContextSidebar,
|
||||
}));
|
||||
}, [postId, reverseOrder]);
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ const commentsSlice = createSlice({
|
||||
postStatus: RequestStatus.SUCCESSFUL,
|
||||
pagination: {},
|
||||
responsesPagination: {},
|
||||
sortOrder: false,
|
||||
sortOrder: true,
|
||||
},
|
||||
reducers: {
|
||||
fetchCommentsRequest: (state) => {
|
||||
@@ -75,12 +75,16 @@ const commentsSlice = createSlice({
|
||||
},
|
||||
fetchCommentResponsesSuccess: (state, { payload }) => {
|
||||
state.status = RequestStatus.SUCCESSFUL;
|
||||
state.commentsInComments[payload.commentId] = [
|
||||
...new Set([
|
||||
...(state.commentsInComments[payload.commentId] || []),
|
||||
...(payload.commentsInComments[payload.commentId] || []),
|
||||
]),
|
||||
];
|
||||
if (payload.page === 1) {
|
||||
state.commentsInComments[payload.commentId] = payload.commentsInComments[payload.commentId] || [];
|
||||
} else {
|
||||
state.commentsInComments[payload.commentId] = [
|
||||
...new Set([
|
||||
...(state.commentsInComments[payload.commentId] || []),
|
||||
...(payload.commentsInComments[payload.commentId] || []),
|
||||
]),
|
||||
];
|
||||
}
|
||||
state.commentsById = { ...state.commentsById, ...payload.commentsById };
|
||||
state.responsesPagination[payload.commentId] = {
|
||||
currentPage: payload.page,
|
||||
|
||||
@@ -80,12 +80,15 @@ export function fetchThreadComments(
|
||||
page = 1,
|
||||
reverseOrder,
|
||||
endorsed = EndorsementStatus.DISCUSSION,
|
||||
enableInContextSidebar,
|
||||
} = {},
|
||||
) {
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
dispatch(fetchCommentsRequest());
|
||||
const data = await getThreadComments(threadId, { page, reverseOrder, endorsed });
|
||||
const data = await getThreadComments(threadId, {
|
||||
page, reverseOrder, endorsed, enableInContextSidebar,
|
||||
});
|
||||
dispatch(fetchCommentsSuccess({
|
||||
...normaliseComments(camelCaseObject(data)),
|
||||
endorsed,
|
||||
@@ -103,11 +106,11 @@ export function fetchThreadComments(
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchCommentResponses(commentId, { page = 1 } = {}) {
|
||||
export function fetchCommentResponses(commentId, { page = 1, reverseOrder = true } = {}) {
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
dispatch(fetchCommentResponsesRequest({ commentId }));
|
||||
const data = await getCommentResponses(commentId, { page });
|
||||
const data = await getCommentResponses(commentId, { page, reverseOrder });
|
||||
dispatch(fetchCommentResponsesSuccess({
|
||||
...normaliseComments(camelCaseObject(data)),
|
||||
page,
|
||||
@@ -144,7 +147,7 @@ export function editComment(commentId, comment, action = null) {
|
||||
};
|
||||
}
|
||||
|
||||
export function addComment(comment, threadId, parentId = null) {
|
||||
export function addComment(comment, threadId, parentId = null, enableInContextSidebar = false) {
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
dispatch(postCommentRequest({
|
||||
@@ -152,7 +155,7 @@ export function addComment(comment, threadId, parentId = null) {
|
||||
threadId,
|
||||
parentId,
|
||||
}));
|
||||
const data = await postComment(comment, threadId, parentId);
|
||||
const data = await postComment(comment, threadId, parentId, enableInContextSidebar);
|
||||
dispatch(postCommentSuccess(camelCaseObject(data)));
|
||||
} catch (error) {
|
||||
if (getHttpErrorStatus(error) === 403) {
|
||||
|
||||
@@ -160,9 +160,9 @@ describe('PostsView', () => {
|
||||
test('displays a list of posts in a topic', async () => {
|
||||
setupStore();
|
||||
await act(async () => {
|
||||
await renderComponent({ topicId: 'some-topic-1' });
|
||||
await renderComponent({ topicId: 'test-topic-1' });
|
||||
});
|
||||
expect(screen.getAllByText(/this is thread-\d+ in topic some-topic-1/i)).toHaveLength(Math.ceil(threadCount / 3));
|
||||
expect(screen.getAllByText(/this is thread-\d+ in topic test-topic-1/i)).toHaveLength(Math.ceil(threadCount / 3));
|
||||
});
|
||||
|
||||
test.each([true, false])(
|
||||
@@ -173,10 +173,10 @@ describe('PostsView', () => {
|
||||
blocks: {
|
||||
'test-usage-key': {
|
||||
type: 'vertical',
|
||||
topics: ['some-topic-2', 'some-topic-0'],
|
||||
topics: ['test-topic-2', 'test-topic-0'],
|
||||
parent: 'test-seq-key',
|
||||
},
|
||||
'test-seq-key': { type: 'sequential', topics: ['some-topic-0', 'some-topic-1', 'some-topic-2'] },
|
||||
'test-seq-key': { type: 'sequential', topics: ['test-topic-0', 'test-topic-1', 'test-topic-2'] },
|
||||
},
|
||||
},
|
||||
config: { groupAtSubsection: grouping, hasModerationPrivileges: true, provider: 'openedx' },
|
||||
@@ -185,12 +185,12 @@ describe('PostsView', () => {
|
||||
await renderComponent({ category: 'test-usage-key', enableInContextSidebar: true, p: true });
|
||||
});
|
||||
const topicThreadCount = Math.ceil(threadCount / 3);
|
||||
expect(screen.queryAllByText(/this is thread-\d+ in topic some-topic-2/i))
|
||||
expect(screen.queryAllByText(/this is thread-\d+ in topic test-topic-2/i))
|
||||
.toHaveLength(topicThreadCount);
|
||||
expect(screen.queryAllByText(/this is thread-\d+ in topic some-topic-0/i))
|
||||
expect(screen.queryAllByText(/this is thread-\d+ in topic test-topic-0/i))
|
||||
.toHaveLength(topicThreadCount);
|
||||
// When grouping is enabled, topic 1 will be shown, but not otherwise.
|
||||
expect(screen.queryAllByText(/this is thread-\d+ in topic some-topic-1/i))
|
||||
expect(screen.queryAllByText(/this is thread-\d+ in topic test-topic-1/i))
|
||||
.toHaveLength(grouping ? topicThreadCount : 2);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -7,7 +7,7 @@ Factory.define('thread')
|
||||
.sequence('rendered_body', (idx) => `Some contents for <b>thread number ${idx}</b>.`)
|
||||
.sequence('type', (idx) => (idx % 2 === 1 ? 'discussion' : 'question'))
|
||||
.sequence('pinned', idx => (idx < 3))
|
||||
.sequence('topic_id', idx => `some-topic-${(idx % 3)}`)
|
||||
.sequence('topic_id', idx => `test-topic-${(idx % 3)}`)
|
||||
.sequence('closed', idx => Boolean(idx % 3 === 2)) // Mark every 3rd post closed
|
||||
.attr('comment_list_url', ['id'], (threadId) => `http://test.site/api/discussion/v1/comments/?thread_id=${threadId}`)
|
||||
.attrs({
|
||||
|
||||
@@ -87,6 +87,7 @@ export async function getThread(threadId, courseId) {
|
||||
* @param {boolean} following Follow the thread after creating
|
||||
* @param {boolean} anonymous Should the thread be anonymous to all users
|
||||
* @param {boolean} anonymousToPeers Should the thread be anonymous to peers
|
||||
* @param {boolean} enableInContextSidebar
|
||||
* @returns {Promise<{}>}
|
||||
*/
|
||||
export async function postThread(
|
||||
@@ -101,6 +102,7 @@ export async function postThread(
|
||||
anonymous,
|
||||
anonymousToPeers,
|
||||
} = {},
|
||||
enableInContextSidebar = false,
|
||||
) {
|
||||
const postData = snakeCaseObject({
|
||||
courseId,
|
||||
@@ -112,8 +114,8 @@ export async function postThread(
|
||||
anonymous,
|
||||
anonymousToPeers,
|
||||
groupId: cohort,
|
||||
enableInContextSidebar,
|
||||
});
|
||||
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(getThreadsApiUrl(), postData);
|
||||
return data;
|
||||
|
||||
@@ -102,7 +102,7 @@ describe('Threads/Posts data layer tests', () => {
|
||||
expect(store.getState().threads.threadsById['thread-1'])
|
||||
.toHaveProperty('topicId');
|
||||
expect(store.getState().threads.threadsById['thread-1'].topicId)
|
||||
.toEqual('some-topic-1');
|
||||
.toEqual('test-topic-1');
|
||||
});
|
||||
|
||||
test('successfully handles thread creation', async () => {
|
||||
|
||||
@@ -204,6 +204,7 @@ export function createNewThread({
|
||||
anonymous,
|
||||
anonymousToPeers,
|
||||
cohort,
|
||||
enableInContextSidebar,
|
||||
}) {
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
@@ -223,7 +224,7 @@ export function createNewThread({
|
||||
following,
|
||||
anonymous,
|
||||
anonymousToPeers,
|
||||
});
|
||||
}, enableInContextSidebar);
|
||||
dispatch(postThreadSuccess(camelCaseObject(data)));
|
||||
} catch (error) {
|
||||
if (getHttpErrorStatus(error) === 403) {
|
||||
|
||||
@@ -19,7 +19,7 @@ import { Help, Post } from '@edx/paragon/icons';
|
||||
|
||||
import { TinyMCEEditor } from '../../../components';
|
||||
import FormikErrorFeedback from '../../../components/FormikErrorFeedback';
|
||||
import PostPreviewPane from '../../../components/PostPreviewPane';
|
||||
import PostPreviewPanel from '../../../components/PostPreviewPanel';
|
||||
import { useDispatchWithState } from '../../../data/hooks';
|
||||
import { selectCourseCohorts } from '../../cohorts/data/selectors';
|
||||
import { fetchCourseCohorts } from '../../cohorts/data/thunks';
|
||||
@@ -187,6 +187,7 @@ function PostEditor({
|
||||
anonymous: allowAnonymous ? values.anonymous : undefined,
|
||||
anonymousToPeers: allowAnonymousToPeers ? values.anonymousToPeers : undefined,
|
||||
cohort,
|
||||
enableInContextSidebar,
|
||||
}));
|
||||
}
|
||||
/* istanbul ignore if: TinyMCE is mocked so this cannot be easily tested */
|
||||
@@ -440,7 +441,7 @@ function PostEditor({
|
||||
<FormikErrorFeedback name="comment" />
|
||||
</div>
|
||||
|
||||
<PostPreviewPane 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 && (
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React from 'react';
|
||||
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import {
|
||||
fireEvent, render, screen, waitFor,
|
||||
} from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
@@ -32,10 +34,11 @@ const topicsApiUrl = `${getApiBaseUrl()}/api/discussion/v1/course_topics/${cours
|
||||
const threadsApiUrl = getThreadsApiUrl();
|
||||
let store;
|
||||
let axiosMock;
|
||||
let container;
|
||||
|
||||
async function renderComponent(editExisting = false, location = `/${courseId}/posts/`) {
|
||||
const path = editExisting ? Routes.POSTS.EDIT_POST : Routes.POSTS.NEW_POSTS;
|
||||
await render(
|
||||
const wrapper = await render(
|
||||
<IntlProvider locale="en">
|
||||
<AppProvider store={store}>
|
||||
<DiscussionContext.Provider
|
||||
@@ -50,6 +53,7 @@ async function renderComponent(editExisting = false, location = `/${courseId}/po
|
||||
</AppProvider>
|
||||
</IntlProvider>,
|
||||
);
|
||||
container = wrapper.container;
|
||||
}
|
||||
|
||||
describe('PostEditor', () => {
|
||||
@@ -370,5 +374,30 @@ describe('PostEditor', () => {
|
||||
name: /reason \d/i,
|
||||
})).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should show Preview Panel', async () => {
|
||||
await renderComponent(true, `/${courseId}/posts/${threadId}/edit`);
|
||||
|
||||
await act(() => fireEvent.click(container.querySelector('[data-testid="show-preview-button"]')));
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector('[data-testid="hide-preview-button"]')).toBeInTheDocument();
|
||||
expect(container.querySelector('[data-testid="show-preview-button"]')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should hide Preview Panel', async () => {
|
||||
await renderComponent(true, `/${courseId}/posts/${threadId}/edit`);
|
||||
|
||||
await act(() => fireEvent.click(container.querySelector('[data-testid="show-preview-button"]')));
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector('[data-testid="hide-preview-button"]')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await act(() => fireEvent.click(container.querySelector('[data-testid="hide-preview-button"]')));
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector('[data-testid="show-preview-button"]')).toBeInTheDocument();
|
||||
expect(container.querySelector('[data-testid="hide-preview-button"]')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,6 +41,8 @@ export const ActionItem = ({
|
||||
data-testid={value === selected ? 'selected' : null}
|
||||
style={{ cursor: 'pointer' }}
|
||||
aria-checked={value === selected}
|
||||
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
|
||||
tabIndex={value === selected ? '0' : '-1'}
|
||||
>
|
||||
<Icon src={Check} className={classNames('text-success mr-2', { invisible: value !== selected })} />
|
||||
<Form.Radio id={id} className="sr-only sr-only-focusable" value={value} tabIndex="0">
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React, { useContext } from 'react';
|
||||
import React, { useCallback, useContext, useMemo } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Hyperlink, useToggle } from '@edx/paragon';
|
||||
|
||||
@@ -14,7 +15,7 @@ import { selectorForUnitSubsection, selectTopicContext } from '../../../data/sel
|
||||
import { AlertBanner, Confirmation } from '../../common';
|
||||
import { DiscussionContext } from '../../common/context';
|
||||
import HoverCard from '../../common/HoverCard';
|
||||
import { selectModerationSettings } from '../../data/selectors';
|
||||
import { selectModerationSettings, selectUserHasModerationPrivileges } from '../../data/selectors';
|
||||
import { selectTopic } from '../../topics/data/selectors';
|
||||
import { removeThread, updateExistingThread } from '../data/thunks';
|
||||
import ClosePostReasonModal from './ClosePostReasonModal';
|
||||
@@ -25,7 +26,6 @@ import { postShape } from './proptypes';
|
||||
|
||||
function Post({
|
||||
post,
|
||||
preview,
|
||||
intl,
|
||||
handleAddResponseButton,
|
||||
}) {
|
||||
@@ -41,13 +41,17 @@ function Post({
|
||||
const [isDeleting, showDeleteConfirmation, hideDeleteConfirmation] = useToggle(false);
|
||||
const [isReporting, showReportConfirmation, hideReportConfirmation] = useToggle(false);
|
||||
const [isClosing, showClosePostModal, hideClosePostModal] = useToggle(false);
|
||||
const handleAbusedFlag = () => {
|
||||
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
|
||||
const displayPostFooter = post.following || post.voteCount || post.closed
|
||||
|| (post.groupId && userHasModerationPrivileges);
|
||||
|
||||
const handleAbusedFlag = useCallback(() => {
|
||||
if (post.abuseFlagged) {
|
||||
dispatch(updateExistingThread(post.id, { flagged: !post.abuseFlagged }));
|
||||
} else {
|
||||
showReportConfirmation();
|
||||
}
|
||||
};
|
||||
}, [dispatch, post.abuseFlagged, post.id, showReportConfirmation]);
|
||||
|
||||
const handleDeleteConfirmation = async () => {
|
||||
await dispatch(removeThread(post.id));
|
||||
@@ -63,7 +67,7 @@ function Post({
|
||||
hideReportConfirmation();
|
||||
};
|
||||
|
||||
const actionHandlers = {
|
||||
const actionHandlers = useMemo(() => ({
|
||||
[ContentActions.EDIT_CONTENT]: () => history.push({
|
||||
...location,
|
||||
pathname: `${location.pathname}/edit`,
|
||||
@@ -81,17 +85,34 @@ function Post({
|
||||
[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,
|
||||
history,
|
||||
location,
|
||||
post.closed,
|
||||
post.id,
|
||||
post.pinned,
|
||||
reasonCodesEnabled,
|
||||
dispatch,
|
||||
showClosePostModal,
|
||||
courseId,
|
||||
handleAbusedFlag,
|
||||
]);
|
||||
|
||||
const getTopicCategoryName = topicData => (
|
||||
topicData.usageKey ? getTopicSubsection(topicData.usageKey)?.displayName : topicData.categoryId
|
||||
);
|
||||
|
||||
const getTopicInfo = topicData => (
|
||||
getTopicCategoryName(topicData) ? `${getTopicCategoryName(topicData)} / ${topicData.name}` : `${topicData.name}`
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="d-flex flex-column w-100 mw-100 post-card-comment"
|
||||
aria-level={5}
|
||||
data-testid={`post-${post.id}`}
|
||||
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
|
||||
tabIndex="0"
|
||||
>
|
||||
<Confirmation
|
||||
isOpen={isDeleting}
|
||||
@@ -126,15 +147,15 @@ function Post({
|
||||
<div className="d-flex mt-14px text-break font-style text-primary-500">
|
||||
<HTMLLoader htmlNode={post.renderedBody} componentId="post" cssClassName="html-loader" testId={post.id} />
|
||||
</div>
|
||||
{topicContext && (
|
||||
{(topicContext || topic) && (
|
||||
<div
|
||||
className={classNames('mt-14px mb-1 font-style font-size-12',
|
||||
{ 'w-100': enableInContextSidebar })}
|
||||
className={classNames('mt-14px font-style font-size-12',
|
||||
{ 'w-100': enableInContextSidebar, 'mb-1': !displayPostFooter })}
|
||||
style={{ lineHeight: '20px' }}
|
||||
>
|
||||
<span className="text-gray-500" style={{ lineHeight: '20px' }}>{intl.formatMessage(messages.relatedTo)}{' '}</span>
|
||||
<Hyperlink
|
||||
destination={topicContext.unitLink}
|
||||
destination={topicContext ? topicContext.unitLink : `${getConfig().BASE_URL}/${courseId}/topics/${post.topicId}`}
|
||||
target="_top"
|
||||
>
|
||||
{(topicContext && !topic)
|
||||
@@ -147,11 +168,11 @@ function Post({
|
||||
<span className="w-auto">{topicContext.unitName}</span>
|
||||
</>
|
||||
)
|
||||
: `${getTopicCategoryName(topic)} / ${topic.name}`}
|
||||
: getTopicInfo(topic)}
|
||||
</Hyperlink>
|
||||
</div>
|
||||
)}
|
||||
<PostFooter post={post} preview={preview} />
|
||||
{displayPostFooter && <PostFooter post={post} userHasModerationPrivileges={userHasModerationPrivileges} />}
|
||||
<ClosePostReasonModal
|
||||
isOpen={isClosing}
|
||||
onCancel={hideClosePostModal}
|
||||
@@ -167,12 +188,7 @@ function Post({
|
||||
Post.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
post: postShape.isRequired,
|
||||
preview: PropTypes.bool,
|
||||
handleAddResponseButton: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
Post.defaultProps = {
|
||||
preview: false,
|
||||
};
|
||||
|
||||
export default injectIntl(Post);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
@@ -9,20 +10,19 @@ import {
|
||||
import { Locked, People } from '@edx/paragon/icons';
|
||||
|
||||
import { StarFilled, StarOutline } from '../../../components/icons';
|
||||
import { selectUserHasModerationPrivileges } from '../../data/selectors';
|
||||
import { updateExistingThread } from '../data/thunks';
|
||||
import LikeButton from './LikeButton';
|
||||
import messages from './messages';
|
||||
import { postShape } from './proptypes';
|
||||
|
||||
function PostFooter({
|
||||
post,
|
||||
intl,
|
||||
post,
|
||||
userHasModerationPrivileges,
|
||||
}) {
|
||||
const dispatch = useDispatch();
|
||||
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
|
||||
return (
|
||||
<div className="d-flex align-items-center ml-n1.5 mt-10px" style={{ lineHeight: '32px' }}>
|
||||
<div className="d-flex align-items-center ml-n1.5 mt-10px" style={{ height: '32px' }} data-testid="post-footer">
|
||||
{post.voteCount !== 0 && (
|
||||
<LikeButton
|
||||
count={post.voteCount}
|
||||
@@ -83,6 +83,7 @@ function PostFooter({
|
||||
>
|
||||
<Icon
|
||||
src={Locked}
|
||||
className="text-primary-500"
|
||||
style={{
|
||||
width: '1rem',
|
||||
height: '1rem',
|
||||
@@ -99,7 +100,7 @@ function PostFooter({
|
||||
PostFooter.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
post: postShape.isRequired,
|
||||
|
||||
userHasModerationPrivileges: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(PostFooter);
|
||||
|
||||
@@ -14,11 +14,11 @@ import PostFooter from './PostFooter';
|
||||
|
||||
let store;
|
||||
|
||||
function renderComponent(post, preview = false, showNewCountLabel = false) {
|
||||
function renderComponent(post, userHasModerationPrivileges = false) {
|
||||
return render(
|
||||
<IntlProvider locale="en">
|
||||
<AppProvider store={store}>
|
||||
<PostFooter post={post} preview={preview} showNewCountLabel={showNewCountLabel} />
|
||||
<PostFooter post={post} userHasModerationPrivileges={userHasModerationPrivileges} />
|
||||
</AppProvider>
|
||||
</IntlProvider>,
|
||||
);
|
||||
@@ -64,23 +64,11 @@ describe('PostFooter', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("doesn't have 'new' badge when there are 0 new comments", () => {
|
||||
renderComponent({ ...mockPost, unreadCommentCount: 0 });
|
||||
expect(screen.queryByText('2 New')).toBeFalsy();
|
||||
expect(screen.queryByText('0 New')).toBeFalsy();
|
||||
});
|
||||
|
||||
it("doesn't has 'new' badge when the new-unread item is the post itself", () => {
|
||||
// commentCount === 1 means it's just the post without any further comments
|
||||
renderComponent({ ...mockPost, unreadCommentCount: 1, commentCount: 1 });
|
||||
expect(screen.queryByText('1 New')).toBeFalsy();
|
||||
});
|
||||
|
||||
it('has the cohort icon only when group information is present', () => {
|
||||
renderComponent(mockPost);
|
||||
expect(screen.queryByTestId('cohort-icon')).toBeFalsy();
|
||||
|
||||
renderComponent({ ...mockPost, groupId: 5, groupName: 'Test Cohort' });
|
||||
renderComponent({ ...mockPost, groupId: 5, groupName: 'Test Cohort' }, true);
|
||||
expect(screen.getByTestId('cohort-icon')).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -104,4 +92,24 @@ describe('PostFooter', () => {
|
||||
renderComponent({ ...mockPost, following: false });
|
||||
expect(screen.queryByRole('button', { name: /follow/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('tests like button when voteCount is zero', async () => {
|
||||
renderComponent({ ...mockPost, voteCount: 0 });
|
||||
expect(screen.queryByRole('button', { name: /like/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('tests like button when voteCount is not zero', async () => {
|
||||
renderComponent({ ...mockPost, voted: true, voteCount: 4 });
|
||||
const likeButton = screen.getByRole('button', { name: /like/i });
|
||||
await act(async () => {
|
||||
fireEvent.mouseEnter(likeButton);
|
||||
});
|
||||
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent(/unlike/i);
|
||||
await act(async () => {
|
||||
fireEvent.click(likeButton);
|
||||
});
|
||||
// clicking on the button triggers thread update.
|
||||
expect(store.getState().threads.status === RequestStatus.IN_PROGRESS).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {
|
||||
fireEvent, render, screen,
|
||||
act, fireEvent, render, screen, waitFor,
|
||||
} from '@testing-library/react';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { IntlProvider } from 'react-intl';
|
||||
@@ -26,9 +26,10 @@ const topicsApiUrl = `${getApiBaseUrl()}/api/discussion/v1/course_topics/${cours
|
||||
let store;
|
||||
let axiosMock;
|
||||
let lastLocation;
|
||||
let container;
|
||||
|
||||
function renderComponent() {
|
||||
render(
|
||||
const wrapper = render(
|
||||
<IntlProvider locale="en">
|
||||
<AppProvider store={store}>
|
||||
<DiscussionContext.Provider value={{ courseId }}>
|
||||
@@ -50,6 +51,7 @@ function renderComponent() {
|
||||
</AppProvider>
|
||||
</IntlProvider>,
|
||||
);
|
||||
container = wrapper.container;
|
||||
}
|
||||
|
||||
describe('Legacy Topics View', () => {
|
||||
@@ -67,7 +69,7 @@ describe('Legacy Topics View', () => {
|
||||
});
|
||||
|
||||
store = initializeStore({
|
||||
config: { provider: 'legacy' },
|
||||
config: { provider: 'legacy', hasModerationPrivileges: true },
|
||||
blocks: {
|
||||
topics: {},
|
||||
},
|
||||
@@ -134,4 +136,47 @@ describe('Legacy Topics View', () => {
|
||||
fireEvent.click(topic);
|
||||
expect(lastLocation.pathname.endsWith(`/category/${categoryPath}`)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should display Unnamed category message for undefined groupId', async () => {
|
||||
const mockData = {
|
||||
courseware_topics: Factory.buildList('category', 2),
|
||||
non_courseware_topics: Factory.buildList('topic', 3, {}, { topicPrefix: 'ncw' }),
|
||||
};
|
||||
mockData.courseware_topics[0].name = '';
|
||||
|
||||
axiosMock
|
||||
.onGet(topicsApiUrl)
|
||||
.reply(200, mockData);
|
||||
await executeThunk(fetchCourseTopics(courseId), store.dispatch, store.getState);
|
||||
await renderComponent();
|
||||
|
||||
expect(await screen.findByText('Unnamed category')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should select the specific topic', async () => {
|
||||
await setupMockResponse();
|
||||
renderComponent();
|
||||
|
||||
const topic = await container.querySelector('[data-topic-id="ncwtopic-5"]');
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(topic);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(lastLocation.pathname.endsWith('/ncwtopic-5')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it(`should display reported and previously reported messages by enabling canSeeReportedStats along with
|
||||
activeFlags and inactiveFlags`, async () => {
|
||||
await setupMockResponse();
|
||||
renderComponent();
|
||||
|
||||
const reportedIcon = await waitFor(() => container.querySelector('.text-danger'));
|
||||
await act(async () => fireEvent.mouseEnter(reportedIcon));
|
||||
|
||||
expect(reportedIcon).toBeInTheDocument();
|
||||
expect(screen.getByText('2 reported')).toBeInTheDocument();
|
||||
expect(screen.getByText('1 previously reported')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,9 +4,11 @@ Factory.define('topic')
|
||||
.option('topicPrefix', null, '')
|
||||
.sequence('id', ['topicPrefix'], (idx, topicPrefix) => `${topicPrefix}topic-${idx}`)
|
||||
.sequence('name', ['topicPrefix'], (idx, topicPrefix) => `${topicPrefix}topic ${idx}`)
|
||||
.attr('activeFlags', null, 2)
|
||||
.attr('inactiveFlags', null, 1)
|
||||
.attr('thread_counts', [], {
|
||||
discussion: 0,
|
||||
question: 0,
|
||||
discussion: 1,
|
||||
question: 1,
|
||||
})
|
||||
.attr('children', []);
|
||||
|
||||
@@ -22,18 +24,6 @@ Factory.define('category')
|
||||
return Factory.buildList('topic', 4, null, { topicPrefix: `${name}-` });
|
||||
});
|
||||
|
||||
Factory.define('topic.v2')
|
||||
.option('topicPrefix', null, '')
|
||||
.option('courseId', null, 'course-v1:edX+DemoX+Demo_Course')
|
||||
.sequence('id', ['topicPrefix'], (idx, topicPrefix) => `${topicPrefix}topic-${idx}`)
|
||||
.sequence('name', ['topicPrefix'], (idx, topicPrefix) => `${topicPrefix}topic ${idx}`)
|
||||
.sequence('usage_key', ['id', 'courseId'], (idx, id, courseId) => `block-v1:${courseId.replace('course-v1:', '')}+type@vertical+block@${id}`)
|
||||
.attr('enabled_in_context', null, true)
|
||||
.attr('thread_counts', [], {
|
||||
discussion: 0,
|
||||
question: 0,
|
||||
});
|
||||
|
||||
Factory.define('topic.withThreads')
|
||||
.option('topicPrefix', null, '')
|
||||
.option('courseId', null, 'course-v1:edX+DemoX+Demo_Course')
|
||||
|
||||
@@ -3,12 +3,11 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
import { getApiBaseUrl } from '../../../data/constants';
|
||||
|
||||
export async function getCourseTopics(courseId, topicIds) {
|
||||
export const getCourseTopicsApiUrl = () => `${getApiBaseUrl()}/api/discussion/v1/course_topics/`;
|
||||
|
||||
export async function getCourseTopics(courseId) {
|
||||
const url = `${getApiBaseUrl()}/api/discussion/v1/course_topics/${courseId}`;
|
||||
const params = {};
|
||||
if (topicIds) {
|
||||
params.topic_id = topicIds.join(',');
|
||||
}
|
||||
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(url);
|
||||
return data;
|
||||
|
||||
70
src/discussions/topics/data/api.test.js
Normal file
70
src/discussions/topics/data/api.test.js
Normal file
@@ -0,0 +1,70 @@
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { Factory } from 'rosie';
|
||||
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { initializeMockApp } from '@edx/frontend-platform/testing';
|
||||
|
||||
import { initializeStore } from '../../../store';
|
||||
import { executeThunk } from '../../../test-utils';
|
||||
import { getCourseTopics, getCourseTopicsApiUrl } from './api';
|
||||
import { fetchCourseTopics } from './thunks';
|
||||
|
||||
import './__factories__';
|
||||
|
||||
const courseId = 'course-v1:edX+TestX+Test_Course';
|
||||
const courseId2 = 'course-v1:edX+TestX+Test_Course2';
|
||||
const courseTopicsApiUrl = getCourseTopicsApiUrl();
|
||||
|
||||
let axiosMock = null;
|
||||
let store;
|
||||
|
||||
describe('Legacy Topic Api', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
store = initializeStore();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
axiosMock.reset();
|
||||
});
|
||||
|
||||
test('successfully get topics', async () => {
|
||||
axiosMock
|
||||
.onGet(`${courseTopicsApiUrl}${courseId}`)
|
||||
.reply(200, {
|
||||
courseware_topics: Factory.buildList('category', 2),
|
||||
non_courseware_topics: Factory.buildList('topic', 3, {}, { topicPrefix: 'ncw' }),
|
||||
});
|
||||
await executeThunk(fetchCourseTopics(courseId), store.dispatch, store.getState);
|
||||
|
||||
const response = await getCourseTopics(courseId);
|
||||
|
||||
expect(response).not.toBeUndefined();
|
||||
expect(response.courseware_topics).toHaveLength(2);
|
||||
expect(response.non_courseware_topics).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('failed to fetch topics', async () => {
|
||||
axiosMock.onGet(`${courseTopicsApiUrl}${courseId2}`)
|
||||
.reply(404);
|
||||
await executeThunk(fetchCourseTopics(courseId2), store.dispatch, store.getState);
|
||||
|
||||
expect(store.getState().topics.status).toEqual('failed');
|
||||
});
|
||||
|
||||
it('denied to fetch topics', async () => {
|
||||
axiosMock.onGet(`${courseTopicsApiUrl}${courseId}`)
|
||||
.reply(403, {});
|
||||
await executeThunk(fetchCourseTopics(courseId), store.dispatch);
|
||||
|
||||
expect(store.getState().topics.status).toEqual('denied');
|
||||
});
|
||||
});
|
||||
@@ -30,7 +30,7 @@ export const selectCoursewareTopics = createSelector(
|
||||
export const selectNonCoursewareIds = state => state.topics.nonCoursewareIds;
|
||||
|
||||
export const selectNonCoursewareTopics = state => state.topics.nonCoursewareIds?.map(id => state.topics.topics[id])
|
||||
|| [];
|
||||
|| [];
|
||||
|
||||
export const selectTopic = topicId => state => state.topics.topics[topicId];
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ export const {
|
||||
fetchCourseTopicsRequest,
|
||||
fetchCourseTopicsSuccess,
|
||||
fetchCourseTopicsFailed,
|
||||
fetchCourseTopicsDenied,
|
||||
setFilter,
|
||||
setSortBy,
|
||||
setTopicsCount,
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
import { camelCaseObject } from '@edx/frontend-platform';
|
||||
import { logError } from '@edx/frontend-platform/logging';
|
||||
|
||||
import { getHttpErrorStatus } from '../../utils';
|
||||
import { getCourseTopics } from './api';
|
||||
import { fetchCourseTopicsFailed, fetchCourseTopicsRequest, fetchCourseTopicsSuccess } from './slices';
|
||||
import {
|
||||
fetchCourseTopicsDenied, fetchCourseTopicsFailed, fetchCourseTopicsRequest, fetchCourseTopicsSuccess,
|
||||
} from './slices';
|
||||
|
||||
function normaliseTopics(data) {
|
||||
const topicsInCategory = {};
|
||||
@@ -33,7 +36,11 @@ export function fetchCourseTopics(courseId) {
|
||||
const data = normaliseTopics(camelCaseObject(await getCourseTopics(courseId)));
|
||||
dispatch(fetchCourseTopicsSuccess(data));
|
||||
} catch (error) {
|
||||
dispatch(fetchCourseTopicsFailed());
|
||||
if (getHttpErrorStatus(error) === 403) {
|
||||
dispatch(fetchCourseTopicsDenied());
|
||||
} else {
|
||||
dispatch(fetchCourseTopicsFailed());
|
||||
}
|
||||
logError(error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { selectArchivedTopics } from '../../../data/selectors';
|
||||
import messages from '../messages';
|
||||
import TopicGroupBase from './TopicGroupBase';
|
||||
|
||||
function ArchivedTopicGroup({ intl }) {
|
||||
const topics = useSelector(selectArchivedTopics);
|
||||
return (
|
||||
<TopicGroupBase
|
||||
groupId="archived"
|
||||
groupTitle={intl.formatMessage(messages.archivedTopics)}
|
||||
linkToGroup={false}
|
||||
topics={topics}
|
||||
/>
|
||||
);
|
||||
}
|
||||
ArchivedTopicGroup.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(ArchivedTopicGroup);
|
||||
@@ -1,28 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { selectTopicsById } from '../data/selectors';
|
||||
import TopicGroupBase from './TopicGroupBase';
|
||||
|
||||
function SequenceTopicGroup({
|
||||
sequence,
|
||||
}) {
|
||||
const topicsIds = sequence.topics;
|
||||
const topics = useSelector(selectTopicsById(topicsIds));
|
||||
|
||||
return (
|
||||
<TopicGroupBase groupId={sequence.id} groupTitle={sequence.displayName} topics={topics} />
|
||||
);
|
||||
}
|
||||
|
||||
SequenceTopicGroup.propTypes = {
|
||||
sequence: PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
displayName: PropTypes.string.isRequired,
|
||||
topics: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export default SequenceTopicGroup;
|
||||
@@ -1,29 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { SearchField } from '@edx/paragon';
|
||||
|
||||
import { setFilter } from '../data';
|
||||
import messages from '../messages';
|
||||
|
||||
function TopicSearchBar({ intl }) {
|
||||
const dispatch = useDispatch();
|
||||
return (
|
||||
<div className="d-flex flex-row p-1 align-items-center">
|
||||
<SearchField
|
||||
className="flex-fill m-1 border-0"
|
||||
placeholder={intl.formatMessage(messages.searchTopics)}
|
||||
onSubmit={(query) => dispatch(setFilter(query))}
|
||||
onChange={(query) => dispatch(setFilter(query))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
TopicSearchBar.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(TopicSearchBar);
|
||||
@@ -10,5 +10,13 @@ export default function tourCheckpoints(intl) {
|
||||
title: intl.formatMessage(messages.notRespondedFilterTourTitle),
|
||||
},
|
||||
],
|
||||
RESPONSE_SORT: [
|
||||
{
|
||||
body: intl.formatMessage(messages.responseSortTourBody),
|
||||
placement: 'left',
|
||||
target: '#comment-sort',
|
||||
title: intl.formatMessage(messages.responseSortTourTitle),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,29 +5,25 @@ import { initializeMockApp } from '@edx/frontend-platform/testing';
|
||||
|
||||
import { RequestStatus } from '../../../data/constants';
|
||||
import { initializeStore } from '../../../store';
|
||||
import { executeThunk } from '../../../test-utils';
|
||||
import { getDiscussionTourUrl } from './api';
|
||||
import { selectTours } from './selectors';
|
||||
import {
|
||||
discussionsTourRequest,
|
||||
discussionsToursRequestError,
|
||||
fetchUserDiscussionsToursSuccess,
|
||||
toursReducer,
|
||||
updateUserDiscussionsTourByName,
|
||||
updateUserDiscussionsTourSuccess,
|
||||
} from './slices';
|
||||
import { fetchDiscussionTours, updateTourShowStatus } from './thunks';
|
||||
import discussionTourFactory from './tours.factory';
|
||||
|
||||
let mockAxios;
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
let store;
|
||||
const url = getDiscussionTourUrl();
|
||||
describe('DiscussionToursThunk', () => {
|
||||
let actualActions;
|
||||
|
||||
const dispatch = (action) => {
|
||||
actualActions.push(action);
|
||||
};
|
||||
let actualActions;
|
||||
let mockAxios;
|
||||
let store;
|
||||
|
||||
describe('DiscussionTours data layer', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
@@ -46,168 +42,147 @@ describe('DiscussionToursThunk', () => {
|
||||
mockAxios.reset();
|
||||
});
|
||||
|
||||
it('dispatches get request, success actions', async () => {
|
||||
const mockData = discussionTourFactory.buildList(2);
|
||||
mockAxios.onGet(url)
|
||||
.reply(200, mockData);
|
||||
describe('DiscussionToursThunk', () => {
|
||||
const dispatch = (action) => {
|
||||
actualActions.push(action);
|
||||
};
|
||||
|
||||
const expectedActions = [
|
||||
{
|
||||
const getExpectedAction = (mockData) => ({
|
||||
request: {
|
||||
payload: undefined,
|
||||
type: 'userDiscussionsTours/discussionsTourRequest',
|
||||
},
|
||||
{
|
||||
fetch: {
|
||||
type: 'userDiscussionsTours/fetchUserDiscussionsToursSuccess',
|
||||
payload: mockData,
|
||||
},
|
||||
];
|
||||
await fetchDiscussionTours()(dispatch);
|
||||
expect(actualActions)
|
||||
.toEqual(expectedActions);
|
||||
});
|
||||
|
||||
it('dispatches request, and error actions', async () => {
|
||||
mockAxios.onGet('/api/discussion-tours/')
|
||||
.reply(500);
|
||||
const errorAction = [{
|
||||
payload: undefined,
|
||||
type: 'userDiscussionsTours/discussionsTourRequest',
|
||||
}, {
|
||||
payload: undefined,
|
||||
type: 'userDiscussionsTours/discussionsToursRequestError',
|
||||
}];
|
||||
|
||||
await fetchDiscussionTours()(dispatch);
|
||||
expect(actualActions)
|
||||
.toEqual(errorAction);
|
||||
});
|
||||
|
||||
it('dispatches put request, success actions', async () => {
|
||||
const mockData = discussionTourFactory.build();
|
||||
mockAxios.onPut(`${url}${1}`)
|
||||
.reply(200, mockData);
|
||||
|
||||
const expectedActions = [
|
||||
{
|
||||
payload: undefined,
|
||||
type: 'userDiscussionsTours/discussionsTourRequest',
|
||||
},
|
||||
{
|
||||
update: {
|
||||
type: 'userDiscussionsTours/updateUserDiscussionsTourSuccess',
|
||||
payload: mockData,
|
||||
},
|
||||
];
|
||||
await updateTourShowStatus(1)(dispatch);
|
||||
expect(actualActions)
|
||||
.toEqual(expectedActions);
|
||||
error: {
|
||||
payload: undefined,
|
||||
type: 'userDiscussionsTours/discussionsToursRequestError',
|
||||
},
|
||||
});
|
||||
|
||||
it('dispatches get request, success actions', async () => {
|
||||
const mockData = discussionTourFactory.buildList(2);
|
||||
mockAxios.onGet(url).reply(200, mockData);
|
||||
const expectedActions = [getExpectedAction().request, getExpectedAction(mockData).fetch];
|
||||
|
||||
await fetchDiscussionTours()(dispatch);
|
||||
expect(actualActions).toEqual(expectedActions);
|
||||
});
|
||||
|
||||
it('dispatches request, and error actions', async () => {
|
||||
mockAxios.onGet('/api/discussion-tours/').reply(500);
|
||||
const expectedActions = [getExpectedAction().request, getExpectedAction().error];
|
||||
|
||||
await fetchDiscussionTours()(dispatch);
|
||||
expect(actualActions).toEqual(expectedActions);
|
||||
});
|
||||
|
||||
it('dispatches put request, success actions', async () => {
|
||||
const mockData = discussionTourFactory.build();
|
||||
mockAxios.onPut(`${url}${1}`).reply(200, mockData);
|
||||
const expectedActions = [getExpectedAction().request, getExpectedAction(mockData).update];
|
||||
|
||||
await updateTourShowStatus(1)(dispatch);
|
||||
expect(actualActions).toEqual(expectedActions);
|
||||
});
|
||||
|
||||
it('dispatches update request, and error actions', async () => {
|
||||
mockAxios.onPut(`${url}${1}`).reply(500);
|
||||
const expectedActions = [getExpectedAction().request, getExpectedAction().error];
|
||||
|
||||
await updateTourShowStatus(1)(dispatch);
|
||||
expect(actualActions).toEqual(expectedActions);
|
||||
});
|
||||
});
|
||||
|
||||
it('dispatches update request, and error actions', async () => {
|
||||
mockAxios.onPut(`${url}${1}`)
|
||||
.reply(500);
|
||||
const errorAction = [{
|
||||
payload: undefined,
|
||||
type: 'userDiscussionsTours/discussionsTourRequest',
|
||||
}, {
|
||||
payload: undefined,
|
||||
type: 'userDiscussionsTours/discussionsToursRequestError',
|
||||
}];
|
||||
describe('toursReducer', () => {
|
||||
it('handles the discussionsToursRequest action', async () => {
|
||||
store.dispatch(discussionsTourRequest());
|
||||
const { tours } = store.getState();
|
||||
|
||||
await updateTourShowStatus(1)(dispatch);
|
||||
expect(actualActions)
|
||||
.toEqual(errorAction);
|
||||
});
|
||||
});
|
||||
expect(tours.tours).toEqual([]);
|
||||
expect(tours.error).toBeNull();
|
||||
expect(tours.loading).toEqual(RequestStatus.IN_PROGRESS);
|
||||
});
|
||||
|
||||
describe('toursReducer', () => {
|
||||
it('handles the discussionsToursRequest action', () => {
|
||||
const initialState = {
|
||||
tours: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
};
|
||||
const state = toursReducer(initialState, discussionsTourRequest());
|
||||
expect(state)
|
||||
.toEqual({
|
||||
tours: [],
|
||||
loading: RequestStatus.IN_PROGRESS,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
it('handles the fetchUserDiscussionsToursSuccess action', async () => {
|
||||
const mockData = [{ id: 1 }, { id: 2 }];
|
||||
await store.dispatch(fetchUserDiscussionsToursSuccess(mockData));
|
||||
const { tours } = store.getState();
|
||||
|
||||
it('handles the fetchUserDiscussionsToursSuccess action', () => {
|
||||
const initialState = {
|
||||
tours: [],
|
||||
loading: true,
|
||||
error: null,
|
||||
};
|
||||
const mockData = [{ id: 1 }, { id: 2 }];
|
||||
const state = toursReducer(initialState, fetchUserDiscussionsToursSuccess(mockData));
|
||||
expect(state)
|
||||
.toEqual({
|
||||
expect(tours).toEqual({
|
||||
tours: mockData,
|
||||
loading: RequestStatus.SUCCESSFUL,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('handles the updateUserDiscussionsTourSuccess action', () => {
|
||||
const initialState = {
|
||||
tours: [
|
||||
{ id: 1 },
|
||||
{ id: 2 },
|
||||
],
|
||||
};
|
||||
const updatedTour = {
|
||||
id: 2,
|
||||
name: 'Updated Tour',
|
||||
};
|
||||
const state = toursReducer(initialState, updateUserDiscussionsTourSuccess(updatedTour));
|
||||
expect(state.tours)
|
||||
.toEqual([{ id: 1 }, updatedTour]);
|
||||
});
|
||||
it('handles the updateUserDiscussionsTourSuccess action', async () => {
|
||||
const updatedTour = { id: 2, name: 'Updated Tour' };
|
||||
await store.dispatch(fetchUserDiscussionsToursSuccess([{ id: 1 }, { id: 2 }]));
|
||||
await store.dispatch(updateUserDiscussionsTourSuccess(updatedTour));
|
||||
const { tours } = store.getState();
|
||||
|
||||
it('handles the discussionsToursRequestError action', () => {
|
||||
const initialState = {
|
||||
tours: [],
|
||||
loading: true,
|
||||
error: null,
|
||||
};
|
||||
const mockError = new Error('Something went wrong');
|
||||
const state = toursReducer(initialState, discussionsToursRequestError(mockError));
|
||||
expect(state)
|
||||
.toEqual({
|
||||
expect(tours.tours).toEqual([{ id: 1 }, updatedTour]);
|
||||
});
|
||||
|
||||
it('handles the discussionsToursRequestError action', async () => {
|
||||
const errorMessage = 'Something went wrong';
|
||||
await store.dispatch(discussionsToursRequestError(errorMessage));
|
||||
const { tours } = store.getState();
|
||||
|
||||
expect(tours).toEqual({
|
||||
tours: [],
|
||||
loading: RequestStatus.FAILED,
|
||||
error: mockError,
|
||||
error: errorMessage,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('tourSelector', () => {
|
||||
it('returns the tours list from state', () => {
|
||||
const state = {
|
||||
tours: {
|
||||
tours: [
|
||||
{ id: 1, tourName: 'not_responded_filter' },
|
||||
{ id: 2, tourName: 'other_filter' },
|
||||
],
|
||||
},
|
||||
};
|
||||
const expectedResult = [
|
||||
{ id: 1, tourName: 'not_responded_filter' },
|
||||
{ id: 2, tourName: 'other_filter' },
|
||||
];
|
||||
expect(selectTours(state)).toEqual(expectedResult);
|
||||
it('handles the updateUserDiscussionsTourByName action', async () => {
|
||||
const tourName = 'response_sort';
|
||||
const updatedTour = {
|
||||
tourName: 'response_sort',
|
||||
enabled: false,
|
||||
};
|
||||
|
||||
await mockAxios.onGet(getDiscussionTourUrl(), {}).reply(200, [discussionTourFactory.build({ tourName })]);
|
||||
await executeThunk(fetchDiscussionTours(), store.dispatch, store.getState);
|
||||
store.dispatch(updateUserDiscussionsTourByName(updatedTour));
|
||||
|
||||
expect(store.getState().tours.tours).toEqual([{
|
||||
id: 4,
|
||||
tourName: 'response_sort',
|
||||
enabled: false,
|
||||
description: 'This is the description for Discussion Tour 4.',
|
||||
}]);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns an empty list if the tours state is not defined', () => {
|
||||
const state = {
|
||||
tours: {
|
||||
tours: [],
|
||||
},
|
||||
};
|
||||
expect(selectTours(state))
|
||||
.toEqual([]);
|
||||
describe('tourSelector', () => {
|
||||
it('returns the tours list from state', async () => {
|
||||
await mockAxios.onGet(getDiscussionTourUrl(), {}).reply(200, [
|
||||
discussionTourFactory.build({ tourName: 'other_filter' }),
|
||||
]);
|
||||
await executeThunk(fetchDiscussionTours(), store.dispatch, store.getState);
|
||||
|
||||
expect(selectTours(store.getState())).toEqual([{
|
||||
id: 5,
|
||||
tourName: 'other_filter',
|
||||
description: 'This is the description for Discussion Tour 5.',
|
||||
enabled: true,
|
||||
}]);
|
||||
});
|
||||
|
||||
it('returns an empty list if the tours state is not defined', async () => {
|
||||
await executeThunk(fetchDiscussionTours(), store.dispatch, store.getState);
|
||||
|
||||
expect(selectTours(store.getState())).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,6 +31,12 @@ const userDiscussionsToursSlice = createSlice({
|
||||
state.loading = RequestStatus.SUCCESSFUL;
|
||||
state.error = null;
|
||||
},
|
||||
updateUserDiscussionsTourByName: (state, action) => {
|
||||
const tourIndex = state.tours.findIndex(tour => tour.tourName === action.payload.tourName);
|
||||
state.tours[tourIndex] = { ...state.tours[tourIndex], ...action.payload };
|
||||
state.loading = RequestStatus.SUCCESSFUL;
|
||||
state.error = null;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -39,6 +45,7 @@ export const {
|
||||
fetchUserDiscussionsToursSuccess,
|
||||
discussionsToursRequestError,
|
||||
updateUserDiscussionsTourSuccess,
|
||||
updateUserDiscussionsTourByName,
|
||||
} = userDiscussionsToursSlice.actions;
|
||||
|
||||
export const toursReducer = userDiscussionsToursSlice.reducer;
|
||||
|
||||
@@ -9,6 +9,10 @@ import {
|
||||
updateUserDiscussionsTourSuccess,
|
||||
} from './slices';
|
||||
|
||||
function normaliseTourData(data) {
|
||||
return data.map(tour => ({ ...tour, enabled: true }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Action thunk to fetch the list of discussion tours for the current user.
|
||||
* @returns {function} - Thunk that dispatches the request, success, and error actions.
|
||||
@@ -18,7 +22,7 @@ export function fetchDiscussionTours() {
|
||||
try {
|
||||
dispatch(discussionsTourRequest());
|
||||
const data = await getDiscssionTours();
|
||||
dispatch(fetchUserDiscussionsToursSuccess(camelCaseObject(data)));
|
||||
dispatch(fetchUserDiscussionsToursSuccess(camelCaseObject(normaliseTourData(data))));
|
||||
} catch (error) {
|
||||
dispatch(discussionsToursRequestError());
|
||||
logError(error);
|
||||
|
||||
@@ -2,7 +2,8 @@ import { Factory } from 'rosie';
|
||||
|
||||
const discussionTourFactory = new Factory()
|
||||
.sequence('id')
|
||||
.attr('name', ['id'], (id) => `Discussion Tour ${id}`)
|
||||
.attr('description', ['id'], (id) => `This is the description for Discussion Tour ${id}.`);
|
||||
.attr('tourName', ['id'], (id) => `Discussion Tour ${id}`)
|
||||
.attr('description', ['id'], (id) => `This is the description for Discussion Tour ${id}.`)
|
||||
.attr('enabled', ['id'], true);
|
||||
|
||||
export default discussionTourFactory;
|
||||
|
||||
@@ -26,6 +26,16 @@ const messages = defineMessages({
|
||||
defaultMessage: 'New filtering option!',
|
||||
description: 'Title of the tour for the not responded filter',
|
||||
},
|
||||
responseSortTourBody: {
|
||||
id: 'tour.body.responseSortTour',
|
||||
defaultMessage: 'Responses and comments are now sorted by newest first. Please use this option to change the sort order',
|
||||
description: 'Body of the tour for the response sort',
|
||||
},
|
||||
responseSortTourTitle: {
|
||||
id: 'tour.title.responseSortTour',
|
||||
defaultMessage: 'Sort Responses!',
|
||||
description: 'Title of the tour for the response sort',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -5,7 +5,7 @@ import { generatePath, useRouteMatch } from 'react-router';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import {
|
||||
CheckCircle, CheckCircleOutline, Delete, Edit, Pin, QuestionAnswer, Report, Verified, VerifiedOutline,
|
||||
CheckCircle, CheckCircleOutline, Delete, Edit, Lock, LockOpen, Pin, Report, Verified, VerifiedOutline,
|
||||
} from '@edx/paragon/icons';
|
||||
|
||||
import { InsertLink } from '../components/icons';
|
||||
@@ -141,14 +141,14 @@ export const ACTIONS_LIST = [
|
||||
{
|
||||
id: 'close',
|
||||
action: ContentActions.CLOSE,
|
||||
icon: QuestionAnswer,
|
||||
icon: Lock,
|
||||
label: messages.closeAction,
|
||||
conditions: { closed: false },
|
||||
},
|
||||
{
|
||||
id: 'reopen',
|
||||
action: ContentActions.CLOSE,
|
||||
icon: QuestionAnswer,
|
||||
icon: LockOpen,
|
||||
label: messages.reopenAction,
|
||||
conditions: { closed: true },
|
||||
},
|
||||
|
||||
@@ -208,5 +208,7 @@
|
||||
"tour.action.dismiss": "Dismiss",
|
||||
"tour.action.end": "Okay",
|
||||
"tour.body.notRespondedFilter": "Now you can filter discussions to find posts with no response.",
|
||||
"tour.title.notRespondedFilter": "New filtering option!"
|
||||
"tour.title.notRespondedFilter": "New filtering option!",
|
||||
"tour.body.responseSortTour": "Responses and comments are now sorted by newest first. Please use this option to change the sort order",
|
||||
"tour.title.responseSortTour": "Sort Responses!"
|
||||
}
|
||||
@@ -109,7 +109,7 @@
|
||||
"discussions.post.closedBy": "Post geschlossen von",
|
||||
"discussion.comment.time": "{time} vor",
|
||||
"discussion.thread.notFound": "Thema nicht gefunden",
|
||||
"discussions.comment.sortFilterStatus": "{sort, select,\n false {Oldest first}\n true {Newest first}\n other {{sort}}\n }",
|
||||
"discussions.comment.sortFilterStatus": "{sort, select, false {Älteste zuerst} true {Neueste zuerst} other {{sort}} }",
|
||||
"discussions.app.title": "Diskussionen",
|
||||
"discussions.posts.actionBar.searchAllPosts": "Einträge durchsuchen",
|
||||
"discussions.posts.actionBar.search": "{page, select, topics {Suchthemen} posts {Alle Beiträge durchsuchen} learners {Lernende suchen} myPosts {Alle Beiträge durchsuchen} a00a14c5d87{d9fz}09 {d9fz}09 {d9fz}09 {d9fz}",
|
||||
@@ -208,5 +208,7 @@
|
||||
"tour.action.dismiss": "Abgewiesen",
|
||||
"tour.action.end": "okay",
|
||||
"tour.body.notRespondedFilter": "Jetzt können Sie Diskussionen filtern, um Beiträge ohne Antwort zu finden.",
|
||||
"tour.title.notRespondedFilter": "Neue Filteroption!"
|
||||
"tour.title.notRespondedFilter": "Neue Filteroption!",
|
||||
"tour.body.responseSortTour": "Antworten und Kommentare werden jetzt nach Datum sortiert. Bitte verwenden Sie diese Option, um die Sortierreihenfolge zu ändern.",
|
||||
"tour.title.responseSortTour": "Antworten sortieren!"
|
||||
}
|
||||
@@ -208,5 +208,7 @@
|
||||
"tour.action.dismiss": "Descartar",
|
||||
"tour.action.end": "Okey",
|
||||
"tour.body.notRespondedFilter": "Ahora puede filtrar debates para encontrar publicaciones sin respuesta.",
|
||||
"tour.title.notRespondedFilter": "¡Nueva opción de filtrado!"
|
||||
"tour.title.notRespondedFilter": "¡Nueva opción de filtrado!",
|
||||
"tour.body.responseSortTour": "Responses and comments are now sorted by newest first. Please use this option to change the sort order",
|
||||
"tour.title.responseSortTour": "Sort Responses!"
|
||||
}
|
||||
@@ -1,61 +1,61 @@
|
||||
{
|
||||
"navigation.course.tabs.label": "Course Material",
|
||||
"navigation.course.tabs.label": "Matériel de cours",
|
||||
"learn.course.tabs.navigation.overflow.menu": "Plus...",
|
||||
"discussions.topics.backAlt": "Back to topics list",
|
||||
"discussions.topics.backAlt": "Retour à la liste des sujets",
|
||||
"discussions.topics.discussions": "{count, plural,\n =0 {Discussion}\n one {# Discussion}\n other {# Discussions}\n }",
|
||||
"discussions.topics.questions": "{count, plural,\n =0 {Question}\n one {# Question}\n other {# Questions}\n }",
|
||||
"discussions.topics.reported": "{reported} reported",
|
||||
"discussions.topics.previouslyReported": "{previouslyReported} previously reported",
|
||||
"discussions.topics.find.label": "Search topics",
|
||||
"discussions.topics.unnamed.section.label": "Unnamed Section",
|
||||
"discussions.topics.unnamed.subsection.label": "Unnamed Subsection",
|
||||
"discussions.subtopics.unnamed.topic.label": "Unnamed Topic",
|
||||
"discussions.topics.title": "No topic exists",
|
||||
"discussions.topics.createTopic": "Please contact you admin to create a topic",
|
||||
"discussions.topics.reported": "{reported} signalé",
|
||||
"discussions.topics.previouslyReported": "{previouslyReported} signalé précédemment",
|
||||
"discussions.topics.find.label": "Rechercher des sujets",
|
||||
"discussions.topics.unnamed.section.label": "Section sans nom",
|
||||
"discussions.topics.unnamed.subsection.label": "Sous-section sans nom",
|
||||
"discussions.subtopics.unnamed.topic.label": "Sujet sans nom",
|
||||
"discussions.topics.title": "Aucun sujet n'existe",
|
||||
"discussions.topics.createTopic": "Veuillez contacter votre administrateur pour créer un sujet",
|
||||
"discussions.topics.nothing": "Nothing here yet",
|
||||
"discussions.topics.archived.label": "Archived",
|
||||
"discussions.topics.archived.label": "Archivé",
|
||||
"discussions.learner.reported": "{reported} signalé",
|
||||
"discussions.learner.previouslyReported": "{previouslyReported} previously reported",
|
||||
"discussions.learner.lastLogin": "Last active {lastActiveTime}",
|
||||
"discussions.learner.previouslyReported": "{previouslyReported} signalé précédemment",
|
||||
"discussions.learner.lastLogin": "Dernier actif {lastActiveTime}",
|
||||
"discussions.learner.loadMostLearners": "Charger plus",
|
||||
"discussions.learner.back": "Retour",
|
||||
"discussions.learner.activityForLearner": "Activité pour {username}",
|
||||
"discussions.learner.mostActivity": "Most activity",
|
||||
"discussions.learner.reportedActivity": "Reported activity",
|
||||
"discussions.learner.recentActivity": "Recent activity",
|
||||
"discussions.learner.mostActivity": "La plupart des activités",
|
||||
"discussions.learner.reportedActivity": "Activité signalée",
|
||||
"discussions.learner.recentActivity": "Activité récente",
|
||||
"discussions.learner.sortFilterStatus": "All learners sorted by {sort, select,\n flagged {reported activity}\n activity {most activity}\n other {{sort}}\n }",
|
||||
"discussion.learner.allActivity": "All activity",
|
||||
"discussion.learner.allActivity": "Toutes les activités",
|
||||
"discussion.learner.posts": "Posts",
|
||||
"discussions.actions.button.alt": "Actions menu",
|
||||
"discussions.actions.copylink": "Copy link",
|
||||
"discussions.actions.button.alt": "Menu Actions",
|
||||
"discussions.actions.copylink": "Copier le lien",
|
||||
"discussions.actions.edit": "Modifier",
|
||||
"discussions.actions.pin": "Épingler",
|
||||
"discussions.actions.unpin": "Unpin",
|
||||
"discussions.actions.delete": "Delete",
|
||||
"discussions.confirmation.button.confirm": "Confirm",
|
||||
"discussions.actions.close": "Close",
|
||||
"discussions.actions.reopen": "Reopen",
|
||||
"discussions.actions.unpin": "Ne plus épingler",
|
||||
"discussions.actions.delete": "Supprimer",
|
||||
"discussions.confirmation.button.confirm": "Confirmer",
|
||||
"discussions.actions.close": "Fermer",
|
||||
"discussions.actions.reopen": "Réouvrir",
|
||||
"discussions.actions.report": "Report",
|
||||
"discussions.actions.unreport": "Unreport",
|
||||
"discussions.actions.endorse": "Endorse",
|
||||
"discussions.actions.unendorse": "Unendorse",
|
||||
"discussions.actions.markAnswered": "Mark as answered",
|
||||
"discussions.actions.endorse": "Approuver",
|
||||
"discussions.actions.unendorse": "Ne plus approuver",
|
||||
"discussions.actions.markAnswered": "Marquer comme répondu",
|
||||
"discussions.actions.unMarkAnswered": "Unmark as answered",
|
||||
"discussions.modal.confirmation.button.cancel": "Cancel",
|
||||
"discussions.modal.confirmation.button.cancel": "Annuler",
|
||||
"discussions.empty.allTopics": "All discussion activity for these topics will show up here.",
|
||||
"discussions.empty.allPosts": "All discussion activity for your course will show up here.",
|
||||
"discussions.empty.myPosts": "Posts you've interacted with will show up here.",
|
||||
"discussions.empty.topic": "All discussion activity for this topic will show up here.",
|
||||
"discussions.empty.title": "Nothing here yet",
|
||||
"discussions.empty.title": "Rien ici encore",
|
||||
"discussions.empty.noPostSelected": "No post selected",
|
||||
"discussions.empty.noTopicSelected": "No topic selected",
|
||||
"discussions.sidebar.noResultsFound": "No results found",
|
||||
"discussions.empty.noTopicSelected": "Aucun sujet sélectionné",
|
||||
"discussions.sidebar.noResultsFound": "Aucun résultat trouvé",
|
||||
"discussions.sidebar.differentKeywords": "Try searching different keywords",
|
||||
"discussions.sidebar.removeKeywords": "Try searching different keywords or removing some filters",
|
||||
"discussions.sidebar.removeKeywordsOnly": "Try searching different keywords",
|
||||
"discussions.sidebar.removeFilters": "Try removing some filters",
|
||||
"discussions.empty.iconAlt": "Empty",
|
||||
"discussions.authors.label.staff": "Staff",
|
||||
"discussions.empty.iconAlt": "Vide",
|
||||
"discussions.authors.label.staff": "Équipe",
|
||||
"discussions.authors.label.ta": "TA",
|
||||
"discussions.learner.loadMostPosts": "Load more posts",
|
||||
"discussions.post.anonymous.author": "anonymous",
|
||||
@@ -208,5 +208,7 @@
|
||||
"tour.action.dismiss": "Dismiss",
|
||||
"tour.action.end": "Okay",
|
||||
"tour.body.notRespondedFilter": "Now you can filter discussions to find posts with no response.",
|
||||
"tour.title.notRespondedFilter": "New filtering option!"
|
||||
"tour.title.notRespondedFilter": "New filtering option!",
|
||||
"tour.body.responseSortTour": "Responses and comments are now sorted by newest first. Please use this option to change the sort order",
|
||||
"tour.title.responseSortTour": "Sort Responses!"
|
||||
}
|
||||
@@ -139,8 +139,8 @@
|
||||
"discussions.post.editor.questionText": "Votre question ou idée (obligatoire)",
|
||||
"discussions.post.editor.preview": "Aperçu",
|
||||
"discussions.post.editor.followPost": "Suivez ce message",
|
||||
"discussions.post.editor.anonymousPost": "Publiez anonymement",
|
||||
"discussions.post.editor.anonymousToPeersPost": "Publiez anonymement à vos pairs",
|
||||
"discussions.post.editor.anonymousPost": "Publier anonymement",
|
||||
"discussions.post.editor.anonymousToPeersPost": "Publier anonymement à vos pairs",
|
||||
"discussions.editor.posts.editReasonCode": "Raison de la modification",
|
||||
"discussions.editor.posts.showPreview.button": "Afficher l'aperçu",
|
||||
"discussions.topic.noName.label": "Catégorie sans nom",
|
||||
@@ -208,5 +208,7 @@
|
||||
"tour.action.dismiss": "Rejeter",
|
||||
"tour.action.end": "Okay",
|
||||
"tour.body.notRespondedFilter": "Vous pouvez maintenant filtrer les discussions pour trouver les messages sans réponse.",
|
||||
"tour.title.notRespondedFilter": "Nouvelle option de filtrage!"
|
||||
"tour.title.notRespondedFilter": "Nouvelle option de filtrage!",
|
||||
"tour.body.responseSortTour": "Les réponses et les commentaires sont désormais triés par les plus récents en premier. Veuillez utiliser cette option pour modifier l'ordre de tri",
|
||||
"tour.title.responseSortTour": "Triez les réponses !"
|
||||
}
|
||||
@@ -208,5 +208,7 @@
|
||||
"tour.action.dismiss": "Dismiss",
|
||||
"tour.action.end": "Okay",
|
||||
"tour.body.notRespondedFilter": "Now you can filter discussions to find posts with no response.",
|
||||
"tour.title.notRespondedFilter": "New filtering option!"
|
||||
"tour.title.notRespondedFilter": "New filtering option!",
|
||||
"tour.body.responseSortTour": "Responses and comments are now sorted by newest first. Please use this option to change the sort order",
|
||||
"tour.title.responseSortTour": "Sort Responses!"
|
||||
}
|
||||
@@ -208,5 +208,7 @@
|
||||
"tour.action.dismiss": "Dismiss",
|
||||
"tour.action.end": "Okay",
|
||||
"tour.body.notRespondedFilter": "Now you can filter discussions to find posts with no response.",
|
||||
"tour.title.notRespondedFilter": "New filtering option!"
|
||||
"tour.title.notRespondedFilter": "New filtering option!",
|
||||
"tour.body.responseSortTour": "Responses and comments are now sorted by newest first. Please use this option to change the sort order",
|
||||
"tour.title.responseSortTour": "Sort Responses!"
|
||||
}
|
||||
@@ -208,5 +208,7 @@
|
||||
"tour.action.dismiss": "Dismiss",
|
||||
"tour.action.end": "Okay",
|
||||
"tour.body.notRespondedFilter": "Now you can filter discussions to find posts with no response.",
|
||||
"tour.title.notRespondedFilter": "New filtering option!"
|
||||
"tour.title.notRespondedFilter": "New filtering option!",
|
||||
"tour.body.responseSortTour": "Responses and comments are now sorted by newest first. Please use this option to change the sort order",
|
||||
"tour.title.responseSortTour": "Sort Responses!"
|
||||
}
|
||||
@@ -208,5 +208,7 @@
|
||||
"tour.action.dismiss": "İptal",
|
||||
"tour.action.end": "Tamam",
|
||||
"tour.body.notRespondedFilter": "Artık yanıt vermeyen iletileri bulmak için tartışmaları filtreleyebilirsiniz.",
|
||||
"tour.title.notRespondedFilter": "Yeni filtreleme seçeneği!"
|
||||
"tour.title.notRespondedFilter": "Yeni filtreleme seçeneği!",
|
||||
"tour.body.responseSortTour": "Responses and comments are now sorted by newest first. Please use this option to change the sort order",
|
||||
"tour.title.responseSortTour": "Sort Responses!"
|
||||
}
|
||||
@@ -208,5 +208,7 @@
|
||||
"tour.action.dismiss": "Dismiss",
|
||||
"tour.action.end": "Okay",
|
||||
"tour.body.notRespondedFilter": "Now you can filter discussions to find posts with no response.",
|
||||
"tour.title.notRespondedFilter": "New filtering option!"
|
||||
"tour.title.notRespondedFilter": "New filtering option!",
|
||||
"tour.body.responseSortTour": "Responses and comments are now sorted by newest first. Please use this option to change the sort order",
|
||||
"tour.title.responseSortTour": "Sort Responses!"
|
||||
}
|
||||
@@ -251,7 +251,7 @@ header {
|
||||
min-width: 29rem;
|
||||
}
|
||||
|
||||
.filter-menu:focus-within {
|
||||
.filter-menu:focus-visible {
|
||||
background-color: #e9e6e4 !important;
|
||||
}
|
||||
|
||||
@@ -340,6 +340,14 @@ header {
|
||||
box-shadow: 0px 2px 4px rgb(0 0 0 / 15%), 0px 2px 8px rgb(0 0 0 / 15%);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
|
||||
.nav-button-group{
|
||||
.nav-item:not(:last-child){
|
||||
.nav-link {
|
||||
border-right: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.breadcrumb-menu {
|
||||
@@ -433,7 +441,7 @@ header {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.on-focus:focus-visible {
|
||||
.on-focus:focus-within {
|
||||
outline: 2px solid black;
|
||||
}
|
||||
|
||||
@@ -442,6 +450,8 @@ header {
|
||||
}
|
||||
|
||||
.post-card-comment {
|
||||
outline: none;
|
||||
|
||||
&:not(:hover),
|
||||
&:not(:focus) {
|
||||
.hover-card {
|
||||
@@ -450,7 +460,7 @@ header {
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
&:focus-within {
|
||||
.hover-card {
|
||||
display: flex;
|
||||
}
|
||||
@@ -483,3 +493,12 @@ header {
|
||||
margin-bottom: -44px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.post-preview,
|
||||
.discussion-comments {
|
||||
blockquote {
|
||||
border-left: 2px solid #ccc;
|
||||
margin-left: 1.5rem;
|
||||
padding-left: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
|
||||
export const executeThunk = async (thunk, dispatch, getState) => {
|
||||
await thunk(dispatch, getState);
|
||||
await new Promise(setImmediate);
|
||||
|
||||
Reference in New Issue
Block a user