Compare commits

...

16 Commits

Author SHA1 Message Date
ayesha waris
67b0216a1d fix: removes outline around comment cards 2023-03-29 15:34:24 +05:00
ayesha waris
d0d80a2b17 fix: removes double line between tabs (#486) 2023-03-29 15:34:06 +05:00
Ahtisham Shahid
5240d0d5a4 feat: added support for dual feedback forms (#478)
* feat: added support for dual feedback forms

* fix: resolved linter errors
2023-03-29 14:08:24 +05:00
ayesha waris
b171de291e fix: lock is used for post close or reopen (#484) 2023-03-29 12:57:18 +05:00
ayesha waris
24163d15c0 fix: hover color removed when filter buttons are clicked (#483) 2023-03-28 21:00:50 +05:00
ayesha waris
58b6b69cb0 fix: typeset failed: window.MathJax.typesetClear is not a function (#482) 2023-03-28 16:30:34 +05:00
ayesha waris
025edf7b66 temp: fix for typeset failed:e.typesetPromise is not a function (#479) 2023-03-27 14:56:40 +05:00
sundasnoreen12
1e47d102a3 test: added newly cases to cover codeCov (#474)
* test: added newly cases to cover codeCov

* test: added new cases of empty posts

* test: added test cases of legacy topics

* refactor: removed extra lines and extra const objects

* test: added test cases for postPreviewpane

* test: added test cases for post comment view api

* refactor: removed extra lines and code

* refactor: fixed issues identified during code review

* refactor: changed description of one test case

---------

Co-authored-by: sundasnoreen12 <sundasnoreen12@ggmail.com>
2023-03-27 14:05:40 +05:00
ayesha waris
39da42ee3f fix: fix post height and remove overstate from author label (#472)
* fix: fixed post length according to figma

* fix: remove hoverstate from author label and author icon

* style: adds tooltip in hovercard for endorsemment icon

* test: fix test cases

* test: adds test cases for descreaased coverage

* refactor: updated api call to a method for reusability

* refactor: changed test descriptions
2023-03-22 17:19:15 +05:00
ayesha waris
15aee6a534 temp: fix for resize observer loop limit exceeded (#477) 2023-03-22 16:17:14 +05:00
ayesha waris
cd2d67e137 fix: fix typeset failed error (#475)
* fix: fix typeset failed error

* test: adds test cases for decreased coverage

* test: adds test cases for reply component
2023-03-21 19:17:48 +05:00
Jenkins
c4f861c24f chore(i18n): update translations 2023-03-19 16:26:48 -04:00
sundasnoreen12
78a85255e4 test: added test cases of learners and post (#470)
* test: added test cases of learners and post

* refactor: fixed reviewed issues

* refactor: changed data test id names for load more buttons

---------

Co-authored-by: sundasnoreen12 <sundasnoreen12@ggmail.com>
2023-03-16 14:48:00 +05:00
Stanislav
b21048e4e6 fix: Fix for code block formatting on the post preview and published post view (#450) 2023-03-14 12:18:33 +05:00
sundasnoreen12
45dea79a87 test: added test cases for learner post view (#466)
* test: added test cases for learner post view

* refactor: fixed requested changes for code optimization

* refactor: added url change

---------

Co-authored-by: sundasnoreen12 <sundasnoreen12@ggmail.com>
2023-03-13 16:44:46 +05:00
sundasnoreen12
530f2cec82 test: added test cases for learner view (#465)
* test: added test cases for learner view

* refactor: fixed changes for code optimization

* refactor: added url changes

---------

Co-authored-by: sundasnoreen12 <sundasnoreen12@ggmail.com>
2023-03-13 13:18:09 +05:00
50 changed files with 1264 additions and 402 deletions

View File

@@ -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>

View File

@@ -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} />
);
}

View File

@@ -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);

View File

@@ -72,6 +72,7 @@ function Search({ intl }) {
<Icon
src={SearchIcon}
onClick={() => onSubmit(searchValue)}
data-testid="search-icon"
/>
</span>
</SearchField.Advanced>

View File

@@ -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 = {

View File

@@ -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 });

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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) {

View File

@@ -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. */

View File

@@ -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();
});
});

View 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]);
}

View File

@@ -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);
});
});
});

View File

@@ -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 (

View File

@@ -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();
});
});

View File

@@ -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>
)

View File

@@ -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);
});
});

View File

@@ -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>
)

View File

@@ -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: ['learner-1', 'learner-2', 'learner-3'],
});
axiosMock.onGet(`${userProfileApiUrl}?username=learner-1,learner-2,learner-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);
});
});

View File

@@ -5,6 +5,8 @@ Factory.define('learner')
.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,16 +15,17 @@ 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 numPages = Math.ceil(pageSize / count);
const next = page < numPages ? page + 1 : null;
const prev = page > 1 ? page - 1 : null;
return {
@@ -35,24 +38,24 @@ 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;
},
@@ -135,8 +138,8 @@ Factory.define('learnerPosts')
},
)
.attr('pagination', [], () => ({
next: null,
next: 1,
prev: null,
count: 2,
num_pages: 1,
num_pages: 2,
}));

View File

@@ -73,7 +73,7 @@ describe('Learner redux test cases', () => {
expect(learners.nextPage).toEqual(2);
expect(learners.totalPages).toEqual(2);
expect(learners.totalLearners).toEqual(6);
expect(learners.totalLearners).toEqual(3);
expect(learners.sortedBy).toEqual('activity');
});

View File

@@ -23,7 +23,7 @@ const userProfileApiUrl = getUserProfileApiUrl();
let axiosMock;
let store;
const username = 'abc123';
const learnerCount = 6;
const learnerCount = 3;
let state;
describe('Learner selectors test cases', () => {
@@ -43,7 +43,8 @@ describe('Learner selectors test cases', () => {
axiosMock.onGet(learnersApiUrl(courseId))
.reply(() => [200, Factory.build('learnersResult', {}, {
count: learnerCount,
pageSize: 3,
pageSize: 6,
page: 1,
})]);
axiosMock.onGet(`${userProfileApiUrl}?username=learner-1,learner-2,learner-3`)

View File

@@ -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) || {},
);

View File

@@ -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">

View File

@@ -5,6 +5,8 @@ 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';
@@ -13,7 +15,7 @@ const courseId = 'course-v1:edX+DemoX+Demo_Course';
export async function setupLearnerMockResponse({
learnerCourseId = courseId,
statusCode = 200,
learnerCount = 6,
learnerCount = 3,
} = {}) {
const store = initializeStore();
const axiosMock = new MockAdapter(getAuthenticatedHttpClient());
@@ -21,7 +23,8 @@ export async function setupLearnerMockResponse({
axiosMock.onGet(learnersApiUrl(learnerCourseId))
.reply(() => [statusCode, Factory.build('learnersResult', {}, {
count: learnerCount,
pageSize: 3,
pageSize: 6,
page: 1,
})]);
axiosMock.onGet(`${getUserProfileApiUrl()}?username=learner-1,learner-2,learner-3`)
@@ -50,3 +53,11 @@ export async function setupPostsMockResponse({
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);
}

View File

@@ -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)}

View File

@@ -97,9 +97,9 @@ function mockAxiosReturnPagedCommentsResponses() {
}
}
async function getThreadAPIResponse(threadId, topicId) {
async function getThreadAPIResponse(attr = null) {
axiosMock.onGet(`${threadsApiUrl}${discussionPostId}/`)
.reply(200, Factory.build('thread', { id: threadId, topic_id: topicId }));
.reply(200, Factory.build('thread', attr));
await executeThunk(fetchThread(discussionPostId), store.dispatch, store.getState);
}
@@ -152,14 +152,14 @@ describe('PostView', () => {
});
it('should show Topic Info for non-courseware topics', async () => {
await getThreadAPIResponse('thread-1', 'non-courseware-topic-1');
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('thread-2', 'courseware-topic-2');
await getThreadAPIResponse({ id: 'thread-2', topic_id: 'courseware-topic-2' });
renderComponent('thread-2');
expect(await screen.findByText('Related to')).toBeInTheDocument();
@@ -223,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');
@@ -742,6 +772,20 @@ describe('ThreadView', () => {
});
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);

View File

@@ -110,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"
>

View File

@@ -11,7 +11,7 @@ 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 {
@@ -150,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"

View 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');
});
});

View File

@@ -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';
@@ -441,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 && (

View File

@@ -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();
});
});
});
});

View File

@@ -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">

View File

@@ -15,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';
@@ -26,7 +26,6 @@ import { postShape } from './proptypes';
function Post({
post,
preview,
intl,
handleAddResponseButton,
}) {
@@ -42,6 +41,9 @@ function Post({
const [isDeleting, showDeleteConfirmation, hideDeleteConfirmation] = useToggle(false);
const [isReporting, showReportConfirmation, hideReportConfirmation] = useToggle(false);
const [isClosing, showClosePostModal, hideClosePostModal] = useToggle(false);
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
const displayPostFooter = post.following || post.voteCount || post.closed
|| (post.groupId && userHasModerationPrivileges);
const handleAbusedFlag = useCallback(() => {
if (post.abuseFlagged) {
@@ -147,8 +149,8 @@ function Post({
</div>
{(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>
@@ -170,7 +172,7 @@ function Post({
</Hyperlink>
</div>
)}
<PostFooter post={post} preview={preview} />
{displayPostFooter && <PostFooter post={post} userHasModerationPrivileges={userHasModerationPrivileges} />}
<ClosePostReasonModal
isOpen={isClosing}
onCancel={hideClosePostModal}
@@ -186,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);

View File

@@ -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);

View File

@@ -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();
});
});

View File

@@ -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();
});
});

View File

@@ -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')

View File

@@ -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;

View 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');
});
});

View File

@@ -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];

View File

@@ -54,6 +54,7 @@ export const {
fetchCourseTopicsRequest,
fetchCourseTopicsSuccess,
fetchCourseTopicsFailed,
fetchCourseTopicsDenied,
setFilter,
setSortBy,
setTopicsCount,

View File

@@ -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);
}
};

View File

@@ -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);

View File

@@ -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;

View File

@@ -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);

View File

@@ -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 },
},

View File

@@ -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}",
@@ -209,6 +209,6 @@
"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.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!"
"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!"
}

View File

@@ -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",

View File

@@ -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 {
@@ -485,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;
}
}