Compare commits
28 Commits
open-relea
...
sundas/INF
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02a2cc78d2 | ||
|
|
d36c4af4e9 | ||
|
|
fa772053c4 | ||
|
|
76da74ae20 | ||
|
|
750720f648 | ||
|
|
909d133acc | ||
|
|
3cda02be76 | ||
|
|
806c989eac | ||
|
|
2241575cc0 | ||
|
|
8618e8cfe9 | ||
|
|
3b7239d72c | ||
|
|
7ebdf1be3e | ||
|
|
5d75e0361d | ||
|
|
edd3f73211 | ||
|
|
33375a51e0 | ||
|
|
ac471e2dd7 | ||
|
|
f04429f6f7 | ||
|
|
bad12462f5 | ||
|
|
ec915f622b | ||
|
|
60da5eafc4 | ||
|
|
05cf174335 | ||
|
|
ff72dab001 | ||
|
|
c38887ec2b | ||
|
|
58aa512f47 | ||
|
|
62a5c11f52 | ||
|
|
3ef8515891 | ||
|
|
3cc39d83c4 | ||
|
|
af6cd1853c |
2
.env
2
.env
@@ -22,3 +22,5 @@ USER_INFO_COOKIE_NAME=''
|
||||
SUPPORT_URL=''
|
||||
LEARNER_FEEDBACK_URL=''
|
||||
STAFF_FEEDBACK_URL=''
|
||||
# Fallback in local style files
|
||||
PARAGON_THEME_URLS={}
|
||||
|
||||
@@ -23,3 +23,5 @@ USER_INFO_COOKIE_NAME='edx-user-info'
|
||||
SUPPORT_URL='https://support.edx.org'
|
||||
LEARNER_FEEDBACK_URL=''
|
||||
STAFF_FEEDBACK_URL=''
|
||||
# Fallback in local style files
|
||||
PARAGON_THEME_URLS={}
|
||||
|
||||
5
.github/workflows/ci.yml
vendored
5
.github/workflows/ci.yml
vendored
@@ -9,9 +9,6 @@ on:
|
||||
jobs:
|
||||
tests:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node: [18, 20]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -21,7 +18,7 @@ jobs:
|
||||
- name: Setup Nodejs
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
node-version-file: '.nvmrc'
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Validate package-lock.json changes
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -6,6 +6,7 @@ node_modules
|
||||
npm-debug.log
|
||||
coverage
|
||||
module.config.js
|
||||
env.config.*
|
||||
|
||||
dist/
|
||||
src/i18n/transifex_input.json
|
||||
|
||||
@@ -76,7 +76,7 @@ How to Contribute
|
||||
|
||||
Details about how to become a contributor to the Open edX project may be found in the wiki at `How to contribute`_
|
||||
|
||||
.. _How to contribute: https://edx.readthedocs.io/projects/edx-developer-guide/en/latest/process/index.html
|
||||
.. _How to contribute: https://docs.openedx.org/en/latest/developers/references/developer_guide/process/index.html
|
||||
|
||||
PR description template should be automatically applied if you are sending PR from github interface; otherwise you
|
||||
can find it it at `PULL_REQUEST_TEMPLATE.md <https://github.com/openedx/frontend-app-discussions/blob/master/.github/pull_request_template.md>`_
|
||||
@@ -125,4 +125,4 @@ Please see `edx/frontend-platform's i18n module <https://edx.github.io/frontend-
|
||||
Reporting Security Issues
|
||||
=========================
|
||||
|
||||
Please do not report security issues in public. Please email security@openedx.org.
|
||||
Please do not report security issues in public. Please email security@openedx.org.
|
||||
|
||||
@@ -12,6 +12,7 @@ metadata:
|
||||
icon: "Web"
|
||||
annotations:
|
||||
openedx.org/arch-interest-groups: ""
|
||||
openedx.org/release: "master"
|
||||
spec:
|
||||
owner: group:edx-infinity
|
||||
type: 'website'
|
||||
|
||||
11
openedx.yaml
11
openedx.yaml
@@ -1,11 +0,0 @@
|
||||
# This file describes this Open edX repo, as described in OEP-2:
|
||||
# http://open-edx-proposals.readthedocs.io/en/latest/oeps/oep-0002.html#specification
|
||||
|
||||
nick: tmpa
|
||||
oeps: {}
|
||||
owner: edx/arch-team
|
||||
openedx-release:
|
||||
# The openedx-release key is described in OEP-10:
|
||||
# https://open-edx-proposals.readthedocs.io/en/latest/oep-0010-proc-openedx-releases.html
|
||||
# The FAQ might also be helpful: https://openedx.atlassian.net/wiki/spaces/COMM/pages/1331268879/Open+edX+Release+FAQ
|
||||
ref: master
|
||||
10623
package-lock.json
generated
10623
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
26
package.json
26
package.json
@@ -16,13 +16,9 @@
|
||||
"lint:fix": "fedx-scripts eslint --ext .js --ext .jsx . --fix",
|
||||
"snapshot": "fedx-scripts jest --updateSnapshot",
|
||||
"start": "fedx-scripts webpack-dev-server --progress",
|
||||
"dev": "PUBLIC_PATH=/discussions/ MFE_CONFIG_API_URL='http://localhost:8000/api/mfe_config/v1' fedx-scripts webpack-dev-server --progress --host apps.local.openedx.io",
|
||||
"test": "fedx-scripts jest --coverage --passWithNoTests"
|
||||
},
|
||||
"husky": {
|
||||
"hooks": {
|
||||
"pre-commit": "npm run lint"
|
||||
}
|
||||
},
|
||||
"author": "edX",
|
||||
"license": "AGPL-3.0",
|
||||
"homepage": "https://github.com/openedx/frontend-app-discussions#readme",
|
||||
@@ -34,13 +30,13 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
|
||||
"@edx/frontend-component-header": "^5.6.0",
|
||||
"@edx/frontend-platform": "8.0.0",
|
||||
"@edx/frontend-component-footer": "^14.6.0",
|
||||
"@edx/frontend-component-header": "^6.2.0",
|
||||
"@edx/frontend-platform": "^8.3.3",
|
||||
"@edx/openedx-atlas": "^0.6.0",
|
||||
"@openedx/frontend-slot-footer": "^1.0.2",
|
||||
"@openedx/paragon": "^22.1.1",
|
||||
"@openedx/paragon": "^23.4.5",
|
||||
"@reduxjs/toolkit": "1.9.7",
|
||||
"@tinymce/tinymce-react": "3.13.1",
|
||||
"@tinymce/tinymce-react": "5.1.1",
|
||||
"babel-polyfill": "6.26.0",
|
||||
"classnames": "2.5.1",
|
||||
"core-js": "3.21.1",
|
||||
@@ -49,8 +45,9 @@
|
||||
"lodash.snakecase": "4.1.1",
|
||||
"prop-types": "15.8.1",
|
||||
"raw-loader": "4.0.2",
|
||||
"react": "17.0.2",
|
||||
"react-dom": "17.0.2",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-google-recaptcha": "^3.1.0",
|
||||
"react-helmet": "6.1.0",
|
||||
"react-redux": "7.2.9",
|
||||
"react-router": "6.18.0",
|
||||
@@ -64,16 +61,15 @@
|
||||
"devDependencies": {
|
||||
"@edx/browserslist-config": "1.2.0",
|
||||
"@edx/reactifex": "1.1.0",
|
||||
"@openedx/frontend-build": "14.0.3",
|
||||
"@openedx/frontend-build": "^14.3.3",
|
||||
"@testing-library/jest-dom": "5.17.0",
|
||||
"@testing-library/react": "12.1.5",
|
||||
"@testing-library/react": "14.3.1",
|
||||
"@testing-library/user-event": "13.5.0",
|
||||
"axios": "^0.28.0",
|
||||
"axios-mock-adapter": "1.22.0",
|
||||
"babel-plugin-react-intl": "8.2.25",
|
||||
"eslint-plugin-simple-import-sort": "7.0.0",
|
||||
"glob": "7.2.0",
|
||||
"husky": "7.0.4",
|
||||
"jest": "29.7.0",
|
||||
"rosie": "2.1.1"
|
||||
}
|
||||
|
||||
@@ -1,24 +1,16 @@
|
||||
@import "~@edx/brand/paragon/fonts.scss";
|
||||
@import "~@edx/brand/paragon/variables.scss";
|
||||
@import "~@openedx/paragon/scss/core/core.scss";
|
||||
@import "~@edx/brand/paragon/overrides.scss";
|
||||
|
||||
$fa-font-path: "~font-awesome/fonts";
|
||||
@import "~font-awesome/scss/font-awesome";
|
||||
|
||||
.course-tabs-navigation {
|
||||
border-bottom: solid 1px #eaeaea;
|
||||
|
||||
.nav a,
|
||||
.nav button {
|
||||
&:hover {
|
||||
background-color: $light-400;
|
||||
background-color: var(--pgn-color-light-400);
|
||||
}
|
||||
}
|
||||
|
||||
.nav a {
|
||||
&:not(.active):hover {
|
||||
background-color: $light-400;
|
||||
background-color: var(--pgn-color-light-400);
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
@@ -30,7 +22,7 @@ $fa-font-path: "~font-awesome/fonts";
|
||||
.nav-link {
|
||||
border-bottom: 4px solid transparent;
|
||||
border-top: 4px solid transparent;
|
||||
color: $gray-700;
|
||||
color: var(--pgn-color-gray-700);
|
||||
|
||||
// temporary until we can remove .btn class from dropdowns
|
||||
border-left: 0;
|
||||
@@ -40,9 +32,9 @@ $fa-font-path: "~font-awesome/fonts";
|
||||
&:hover,
|
||||
&:focus,
|
||||
&.active {
|
||||
font-weight: $font-weight-normal;
|
||||
color: $primary-500;
|
||||
border-bottom-color: $primary-500;
|
||||
font-weight: var(--pgn-typography-font-weight-normal);
|
||||
color: var(--pgn-color-primary-500);
|
||||
border-bottom-color: var(--pgn-color-primary-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,8 @@ export const ContentActions = {
|
||||
CHANGE_TOPIC: 'topic_id',
|
||||
CHANGE_TYPE: 'type',
|
||||
VOTE: 'voted',
|
||||
DELETE_COURSE_POSTS: 'delete-course-posts',
|
||||
DELETE_ORG_POSTS: 'delete-org-posts',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -55,7 +55,11 @@ const AuthorLabel = ({
|
||||
placement={authorToolTip ? 'top' : 'right'}
|
||||
overlay={(
|
||||
<Tooltip id={authorToolTip ? `endorsed-by-${author}-tooltip` : `${authorLabel}-label-tooltip`}>
|
||||
{authorToolTip ? author : authorLabel}
|
||||
<>
|
||||
{authorToolTip ? author : authorLabel}
|
||||
<br />
|
||||
{intl.formatMessage(messages.authorAdminDescription)}
|
||||
</>
|
||||
</Tooltip>
|
||||
)}
|
||||
trigger={['hover', 'focus']}
|
||||
@@ -97,18 +101,38 @@ const AuthorLabel = ({
|
||||
</>
|
||||
), [author, authorLabelMessage, authorToolTip, icon, isRetiredUser, postCreatedAt, showTextPrimary, alert]);
|
||||
|
||||
const learnerPostsLink = (
|
||||
<Link
|
||||
data-testid="learner-posts-link"
|
||||
id="learner-posts-link"
|
||||
to={generatePath(Routes.LEARNERS.POSTS, { learnerUsername: author, courseId })}
|
||||
className="text-decoration-none text-reset"
|
||||
style={{ width: 'fit-content' }}
|
||||
>
|
||||
{!alert && authorName}
|
||||
</Link>
|
||||
);
|
||||
|
||||
return showUserNameAsLink
|
||||
? (
|
||||
<div className={`${className} flex-wrap`}>
|
||||
<Link
|
||||
data-testid="learner-posts-link"
|
||||
id="learner-posts-link"
|
||||
to={generatePath(Routes.LEARNERS.POSTS, { learnerUsername: author, courseId })}
|
||||
className="text-decoration-none text-reset"
|
||||
style={{ width: 'fit-content' }}
|
||||
>
|
||||
{!alert && authorName}
|
||||
</Link>
|
||||
{!authorLabel ? (
|
||||
<OverlayTrigger
|
||||
placement={authorToolTip ? 'top' : 'right'}
|
||||
overlay={(
|
||||
<Tooltip id={authorToolTip ? `endorsed-by-${author}-tooltip` : `${authorLabel}-label-tooltip`}>
|
||||
<>
|
||||
{intl.formatMessage(messages.authorLearnerTitle)}
|
||||
<br />
|
||||
{intl.formatMessage(messages.authorLearnerDescription)}
|
||||
</>
|
||||
</Tooltip>
|
||||
)}
|
||||
trigger={['hover', 'focus']}
|
||||
>
|
||||
{learnerPostsLink}
|
||||
</OverlayTrigger>
|
||||
) : learnerPostsLink }
|
||||
{labelContents}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { ActionRow, Button, ModalDialog } from '@openedx/paragon';
|
||||
import {
|
||||
ActionRow,
|
||||
ModalDialog,
|
||||
Spinner, StatefulButton,
|
||||
} from '@openedx/paragon';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
@@ -11,34 +15,58 @@ const Confirmation = ({
|
||||
isOpen,
|
||||
title,
|
||||
description,
|
||||
boldDescription,
|
||||
onClose,
|
||||
confirmAction,
|
||||
closeButtonVariant,
|
||||
confirmButtonState,
|
||||
confirmButtonVariant,
|
||||
confirmButtonText,
|
||||
isDataLoading,
|
||||
isConfirmButtonPending,
|
||||
pendingConfirmButtonText,
|
||||
closeButtonText,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
return (
|
||||
<ModalDialog title={title} isOpen={isOpen} hasCloseButton={false} onClose={onClose} zIndex={5000}>
|
||||
<ModalDialog.Header>
|
||||
<ModalDialog.Title>
|
||||
{title}
|
||||
</ModalDialog.Title>
|
||||
</ModalDialog.Header>
|
||||
<ModalDialog.Body>
|
||||
{description}
|
||||
</ModalDialog.Body>
|
||||
<ModalDialog.Footer>
|
||||
<ActionRow>
|
||||
<ModalDialog.CloseButton variant={closeButtonVariant}>
|
||||
{intl.formatMessage(messages.confirmationCancel)}
|
||||
</ModalDialog.CloseButton>
|
||||
<Button variant={confirmButtonVariant} onClick={confirmAction}>
|
||||
{ confirmButtonText || intl.formatMessage(messages.confirmationConfirm)}
|
||||
</Button>
|
||||
</ActionRow>
|
||||
</ModalDialog.Footer>
|
||||
{isDataLoading && !isConfirmButtonPending ? (
|
||||
<ModalDialog.Body>
|
||||
<div className="d-flex justify-content-center p-4">
|
||||
<Spinner animation="border" variant="primary" size="lg" />
|
||||
</div>
|
||||
</ModalDialog.Body>
|
||||
) : (
|
||||
<>
|
||||
<ModalDialog.Header>
|
||||
<ModalDialog.Title>
|
||||
{title}
|
||||
</ModalDialog.Title>
|
||||
</ModalDialog.Header>
|
||||
<ModalDialog.Body style={{ whiteSpace: 'pre-line' }}>
|
||||
{description}
|
||||
{boldDescription && <><br /><p className="font-weight-bold pt-2">{boldDescription}</p></>}
|
||||
</ModalDialog.Body>
|
||||
<ModalDialog.Footer>
|
||||
<ActionRow>
|
||||
<ModalDialog.CloseButton variant={closeButtonVariant}>
|
||||
{closeButtonText || intl.formatMessage(messages.confirmationCancel)}
|
||||
</ModalDialog.CloseButton>
|
||||
<StatefulButton
|
||||
labels={{
|
||||
default: confirmButtonText || intl.formatMessage(messages.confirmationConfirm),
|
||||
pending: pendingConfirmButtonText || confirmButtonText
|
||||
|| intl.formatMessage(messages.confirmationConfirm),
|
||||
}}
|
||||
state={isConfirmButtonPending ? 'pending' : confirmButtonState}
|
||||
variant={confirmButtonVariant}
|
||||
onClick={confirmAction}
|
||||
/>
|
||||
</ActionRow>
|
||||
</ModalDialog.Footer>
|
||||
</>
|
||||
)}
|
||||
</ModalDialog>
|
||||
);
|
||||
};
|
||||
@@ -49,15 +77,27 @@ Confirmation.propTypes = {
|
||||
confirmAction: PropTypes.func.isRequired,
|
||||
title: PropTypes.string.isRequired,
|
||||
description: PropTypes.string.isRequired,
|
||||
boldDescription: PropTypes.string,
|
||||
closeButtonVariant: PropTypes.string,
|
||||
confirmButtonVariant: PropTypes.string,
|
||||
confirmButtonText: PropTypes.string,
|
||||
isDataLoading: PropTypes.bool,
|
||||
isConfirmButtonPending: PropTypes.bool,
|
||||
pendingConfirmButtonText: PropTypes.string,
|
||||
closeButtonText: PropTypes.string,
|
||||
confirmButtonState: PropTypes.string,
|
||||
};
|
||||
|
||||
Confirmation.defaultProps = {
|
||||
closeButtonVariant: 'default',
|
||||
confirmButtonVariant: 'primary',
|
||||
confirmButtonText: '',
|
||||
boldDescription: '',
|
||||
isDataLoading: false,
|
||||
isConfirmButtonPending: false,
|
||||
pendingConfirmButtonText: '',
|
||||
closeButtonText: '',
|
||||
confirmButtonState: 'default',
|
||||
};
|
||||
|
||||
export default React.memo(Confirmation);
|
||||
|
||||
@@ -106,8 +106,8 @@ describe('HoverCard', () => {
|
||||
});
|
||||
|
||||
test('it should have hover card on post', async () => {
|
||||
await waitFor(() => renderComponent(discussionPostId));
|
||||
const post = screen.getByTestId('post-thread-1');
|
||||
renderComponent(discussionPostId);
|
||||
const post = await screen.findByTestId('post-thread-1');
|
||||
expect(within(post).getByTestId('hover-card-thread-1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
67
src/discussions/common/withEmailConfirmation.jsx
Normal file
67
src/discussions/common/withEmailConfirmation.jsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import { selectConfirmEmailStatus, selectShouldShowEmailConfirmation } from '../data/selectors';
|
||||
import { sendAccountActivationEmail } from '../posts/data/thunks';
|
||||
import postMessages from '../posts/post-actions-bar/messages';
|
||||
import { Confirmation } from '.';
|
||||
|
||||
const withEmailConfirmation = (WrappedComponent) => {
|
||||
const EnhancedComponent = (props) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
const [isConfirming, setIsConfirming] = useState(false);
|
||||
const shouldShowEmailConfirmation = useSelector(selectShouldShowEmailConfirmation);
|
||||
const confirmEmailStatus = useSelector(selectConfirmEmailStatus);
|
||||
|
||||
const openConfirmation = useCallback(() => {
|
||||
setIsConfirming(true);
|
||||
}, []);
|
||||
|
||||
const closeConfirmation = useCallback(() => {
|
||||
setIsConfirming(false);
|
||||
}, []);
|
||||
|
||||
const handleConfirmation = useCallback(() => {
|
||||
dispatch(sendAccountActivationEmail());
|
||||
}, [dispatch]);
|
||||
|
||||
const confirmButtonState = useMemo(() => {
|
||||
if (confirmEmailStatus === RequestStatus.IN_PROGRESS) { return 'pending'; }
|
||||
if (confirmEmailStatus === RequestStatus.SUCCESSFUL) { return 'complete'; }
|
||||
return 'primary';
|
||||
}, [confirmEmailStatus]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<WrappedComponent
|
||||
{...props}
|
||||
openEmailConfirmation={openConfirmation}
|
||||
/>
|
||||
{shouldShowEmailConfirmation
|
||||
&& (
|
||||
<Confirmation
|
||||
isOpen={isConfirming}
|
||||
title={intl.formatMessage(postMessages.confirmEmailTitle)}
|
||||
description={intl.formatMessage(postMessages.confirmEmailDescription)}
|
||||
onClose={closeConfirmation}
|
||||
confirmAction={handleConfirmation}
|
||||
closeButtonVariant="tertiary"
|
||||
confirmButtonState={confirmButtonState}
|
||||
confirmButtonText={intl.formatMessage(postMessages.confirmEmailButton)}
|
||||
closeButtonText={intl.formatMessage(postMessages.closeButton)}
|
||||
confirmButtonVariant="danger"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return EnhancedComponent;
|
||||
};
|
||||
|
||||
export default withEmailConfirmation;
|
||||
84
src/discussions/common/withEmailConfirmation.test.jsx
Normal file
84
src/discussions/common/withEmailConfirmation.test.jsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { IntlProvider } from 'react-intl';
|
||||
import { Context as ResponsiveContext } from 'react-responsive';
|
||||
import { MemoryRouter } from 'react-router';
|
||||
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
|
||||
import { initializeStore } from '../../store';
|
||||
import EmptyPosts from '../empty-posts/EmptyPosts';
|
||||
import messages from '../messages';
|
||||
import { sendEmailForAccountActivation } from '../posts/data/api';
|
||||
|
||||
let store;
|
||||
const courseId = 'course-v1:edX+DemoX+Demo_Course';
|
||||
|
||||
jest.mock('../posts/data/api', () => ({
|
||||
sendEmailForAccountActivation: jest.fn(),
|
||||
}));
|
||||
|
||||
function renderComponent(location = `/${courseId}/`) {
|
||||
return render(
|
||||
<IntlProvider locale="en">
|
||||
<ResponsiveContext.Provider value={{ width: 1280 }}>
|
||||
<AppProvider store={store} wrapWithRouter={false}>
|
||||
<MemoryRouter initialEntries={[location]}>
|
||||
<EmptyPosts subTitleMessage={messages.emptyMyPosts} />
|
||||
</MemoryRouter>
|
||||
</AppProvider>
|
||||
</ResponsiveContext.Provider>
|
||||
</IntlProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('EmptyPage', () => {
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
|
||||
store = initializeStore({
|
||||
config: { provider: 'openedx', onlyVerifiedUsersCanPost: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('should open the confirmation link dialogue box.', async () => {
|
||||
renderComponent(`/${courseId}/my-posts/`);
|
||||
|
||||
const addPostButton = screen.getByRole('button', { name: 'Add a post' });
|
||||
await userEvent.click(addPostButton);
|
||||
|
||||
expect(screen.queryByText('Send confirmation link')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('dispatches sendAccountActivationEmail on confirm', async () => {
|
||||
sendEmailForAccountActivation.mockResolvedValue({ success: true });
|
||||
renderComponent(`/${courseId}/my-posts/`);
|
||||
|
||||
const addPostButton = screen.getByRole('button', { name: 'Add a post' });
|
||||
await userEvent.click(addPostButton);
|
||||
const confirmButton = screen.getByText('Send confirmation link');
|
||||
fireEvent.click(confirmButton);
|
||||
expect(sendEmailForAccountActivation).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should close the confirmation dialogue box.', async () => {
|
||||
renderComponent(`/${courseId}/my-posts/`);
|
||||
|
||||
const addPostButton = screen.getByRole('button', { name: 'Add a post' });
|
||||
await userEvent.click(addPostButton);
|
||||
const confirmButton = screen.getByText('Close');
|
||||
fireEvent.click(confirmButton);
|
||||
|
||||
expect(sendEmailForAccountActivation).toHaveBeenCalled();
|
||||
|
||||
expect(screen.queryByText('Close')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -33,14 +33,11 @@ import { ContentSelectors } from './constants';
|
||||
import {
|
||||
selectAreThreadsFiltered,
|
||||
selectEnableInContext,
|
||||
selectIsCourseAdmin,
|
||||
selectIsCourseStaff,
|
||||
selectIsPostingEnabled,
|
||||
selectIsUserLearner,
|
||||
selectPostThreadCount,
|
||||
selectUserHasModerationPrivileges,
|
||||
selectUserIsGroupTa,
|
||||
selectUserIsStaff,
|
||||
} from './selectors';
|
||||
import fetchCourseConfig from './thunks';
|
||||
|
||||
@@ -220,20 +217,13 @@ export const useCurrentDiscussionTopic = () => {
|
||||
|
||||
export const useUserPostingEnabled = () => {
|
||||
const isPostingEnabled = useSelector(selectIsPostingEnabled);
|
||||
const isUserAdmin = useSelector(selectUserIsStaff);
|
||||
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
|
||||
const isUserGroupTA = useSelector(selectUserIsGroupTa);
|
||||
const isCourseAdmin = useSelector(selectIsCourseAdmin);
|
||||
const isCourseStaff = useSelector(selectIsCourseStaff);
|
||||
const isPrivileged = isUserAdmin || userHasModerationPrivileges || isUserGroupTA || isCourseAdmin || isCourseStaff;
|
||||
const isPrivileged = userHasModerationPrivileges || isUserGroupTA;
|
||||
|
||||
return (isPostingEnabled || isPrivileged);
|
||||
};
|
||||
|
||||
function camelToConstant(string) {
|
||||
return string.replace(/[A-Z]/g, (match) => `_${match}`).toUpperCase();
|
||||
}
|
||||
|
||||
export const useTourConfiguration = () => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
@@ -258,7 +248,7 @@ export const useTourConfiguration = () => {
|
||||
enabled: tour && Boolean(tour.enabled && tour.showTour && !enableInContextSidebar),
|
||||
onDismiss: () => handleOnDismiss(tour.id),
|
||||
onEnd: () => handleOnEnd(tour.id),
|
||||
checkpoints: tourCheckpoints(intl)[camelToConstant(tour.tourName)],
|
||||
checkpoints: tourCheckpoints(intl)[tour.tourName],
|
||||
}
|
||||
))
|
||||
), [tours, enableInContextSidebar]);
|
||||
|
||||
@@ -19,11 +19,11 @@ const courseConfigApiUrl = getCourseConfigApiUrl();
|
||||
let store;
|
||||
let axiosMock;
|
||||
|
||||
const generateApiResponse = (isPostingEnabled, isCourseAdmin = false) => ({
|
||||
const generateApiResponse = (isPostingEnabled, hasModerationPrivileges = false) => ({
|
||||
isPostingEnabled,
|
||||
hasModerationPrivileges: false,
|
||||
hasModerationPrivileges,
|
||||
isGroupTa: false,
|
||||
isCourseAdmin,
|
||||
isCourseAdmin: false,
|
||||
isCourseStaff: false,
|
||||
isUserAdmin: false,
|
||||
});
|
||||
@@ -160,7 +160,7 @@ describe('Hooks', () => {
|
||||
expect(queryByText('false')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('when posting is not disabled and Role is not Learner return true', async () => {
|
||||
test('when posting is disabled and Role is not Learner return true', async () => {
|
||||
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`)
|
||||
.reply(200, generateApiResponse(false, true));
|
||||
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
|
||||
|
||||
@@ -11,6 +11,8 @@ export const selectAnonymousPostingConfig = state => ({
|
||||
|
||||
export const selectUserHasModerationPrivileges = state => state.config.hasModerationPrivileges;
|
||||
|
||||
export const selectUserHasBulkDeletePrivileges = state => state.config.hasBulkDeletePrivileges;
|
||||
|
||||
export const selectUserIsStaff = state => state.config.isUserAdmin;
|
||||
|
||||
export const selectUserIsGroupTa = state => state.config.isGroupTa;
|
||||
@@ -31,6 +33,21 @@ export const selectEnableInContext = state => state.config.enableInContext;
|
||||
|
||||
export const selectIsPostingEnabled = state => state.config.isPostingEnabled;
|
||||
|
||||
export const selectIsNotifyAllLearnersEnabled = state => state.config.isNotifyAllLearnersEnabled;
|
||||
|
||||
export const selectCaptchaSettings = state => state.config.captchaSettings;
|
||||
|
||||
export const selectIsEmailVerified = state => state.config.isEmailVerified;
|
||||
|
||||
export const selectOnlyVerifiedUsersCanPost = state => state.config.onlyVerifiedUsersCanPost;
|
||||
|
||||
export const selectConfirmEmailStatus = state => state.threads.confirmEmailStatus;
|
||||
|
||||
export const selectShouldShowEmailConfirmation = createSelector(
|
||||
[selectIsEmailVerified, selectOnlyVerifiedUsersCanPost],
|
||||
(isEmailVerified, onlyVerifiedUsersCanPost) => !isEmailVerified && onlyVerifiedUsersCanPost,
|
||||
);
|
||||
|
||||
export const selectModerationSettings = state => ({
|
||||
postCloseReasons: state.config.postCloseReasons,
|
||||
editReasons: state.config.editReasons,
|
||||
|
||||
@@ -11,6 +11,7 @@ const configSlice = createSlice({
|
||||
userRoles: [],
|
||||
groupAtSubsection: false,
|
||||
hasModerationPrivileges: false,
|
||||
hasBulkDeletePrivileges: false,
|
||||
isGroupTa: false,
|
||||
isCourseAdmin: false,
|
||||
isCourseStaff: false,
|
||||
@@ -22,9 +23,14 @@ const configSlice = createSlice({
|
||||
dividedInlineDiscussions: [],
|
||||
dividedCourseWideDiscussions: [],
|
||||
},
|
||||
captchaSettings: {
|
||||
enabled: false,
|
||||
siteKey: '',
|
||||
},
|
||||
editReasons: [],
|
||||
postCloseReasons: [],
|
||||
enableInContext: false,
|
||||
isEmailVerified: false,
|
||||
},
|
||||
reducers: {
|
||||
fetchConfigRequest: (state) => (
|
||||
|
||||
@@ -110,7 +110,7 @@ const DiscussionSidebar = ({ displaySidebar, postActionBarRef }) => {
|
||||
}
|
||||
<Route path={ROUTES.LEARNERS.PATH} element={<LearnersView />} />
|
||||
{configStatus === RequestStatus.SUCCESSFUL && (
|
||||
<Route path={`${ROUTES.DISCUSSIONS.PATH}/*`} element={<Navigate to="posts" />} />
|
||||
<Route path={`${ROUTES.DISCUSSIONS.PATH}/*`} element={<Navigate to="posts" replace />} />
|
||||
)}
|
||||
</Routes>
|
||||
</Suspense>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { IntlProvider } from 'react-intl';
|
||||
import { Context as ResponsiveContext } from 'react-responsive';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
@@ -85,7 +84,7 @@ describe('DiscussionSidebar', () => {
|
||||
},
|
||||
})]);
|
||||
renderComponent();
|
||||
await act(async () => expect(await screen.findAllByText('Thread by other users')).toBeTruthy());
|
||||
await screen.findAllByText('Thread by other users');
|
||||
expect(screen.queryByText('Thread by abc123')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -100,7 +99,7 @@ describe('DiscussionSidebar', () => {
|
||||
},
|
||||
})]);
|
||||
renderComponent();
|
||||
await act(async () => expect(await screen.findAllByText('Thread by other users')).toBeTruthy());
|
||||
await screen.findAllByText('Thread by other users');
|
||||
expect(screen.queryByText('Thread by abc123')).not.toBeInTheDocument();
|
||||
expect(container.querySelectorAll('.discussion-post')).toHaveLength(postCount);
|
||||
});
|
||||
|
||||
@@ -27,7 +27,7 @@ import { selectPostEditorVisible } from '../posts/data/selectors';
|
||||
import { isCourseStatusValid } from '../utils';
|
||||
import useFeedbackWrapper from './FeedbackWrapper';
|
||||
|
||||
const FooterSlot = lazy(() => import('@openedx/frontend-slot-footer'));
|
||||
const FooterSlot = lazy(() => import('@edx/frontend-component-footer').then(module => ({ default: module.FooterSlot })));
|
||||
const PostActionsBar = lazy(() => import('../posts/post-actions-bar/PostActionsBar'));
|
||||
const CourseTabsNavigation = lazy(() => import('../../components/NavigationBar/CourseTabsNavigation'));
|
||||
const LegacyBreadcrumbMenu = lazy(() => import('../navigation/breadcrumb-menu/LegacyBreadcrumbMenu'));
|
||||
|
||||
@@ -90,8 +90,7 @@ describe('DiscussionsHome', () => {
|
||||
|
||||
test('full view should hide close button', async () => {
|
||||
renderComponent(`/${courseId}/topics`);
|
||||
expect(screen.queryByText(navigationBarMessages.allTopics.defaultMessage))
|
||||
.toBeInTheDocument();
|
||||
await screen.findByText(navigationBarMessages.allTopics.defaultMessage);
|
||||
expect(screen.queryByRole('button', { name: 'Close' }))
|
||||
.not
|
||||
.toBeInTheDocument();
|
||||
@@ -144,9 +143,7 @@ describe('DiscussionsHome', () => {
|
||||
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
|
||||
await renderComponent(`/${courseId}/${searchByEndPoint}`);
|
||||
|
||||
waitFor(() => {
|
||||
expect(screen.queryByText('Add a post')).toBeInTheDocument();
|
||||
});
|
||||
await screen.findByText('Add a post');
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -170,9 +167,7 @@ describe('DiscussionsHome', () => {
|
||||
await executeThunk(fetchThreads(courseId), store.dispatch, store.getState);
|
||||
await renderComponent(`/${courseId}/${searchByEndPoint}`);
|
||||
|
||||
waitFor(() => {
|
||||
expect(screen.queryByText(result)).toBeInTheDocument();
|
||||
});
|
||||
await screen.findByText(result);
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -199,9 +194,7 @@ describe('DiscussionsHome', () => {
|
||||
await executeThunk(fetchCourseTopicsV3(courseId), store.dispatch, store.getState);
|
||||
await renderComponent(`/${courseId}/${searchByEndPoint}`);
|
||||
|
||||
waitFor(() => {
|
||||
expect(screen.queryByText('No topic selected')).toBeInTheDocument();
|
||||
});
|
||||
await screen.findByText('No topic selected');
|
||||
},
|
||||
);
|
||||
|
||||
@@ -210,12 +203,13 @@ describe('DiscussionsHome', () => {
|
||||
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
|
||||
await renderComponent(`/${courseId}/learners`);
|
||||
|
||||
waitFor(() => {
|
||||
expect(screen.queryByText('Nothing here yet')).toBeInTheDocument();
|
||||
});
|
||||
await screen.findByText('Nothing here yet');
|
||||
});
|
||||
|
||||
it('should display post editor form when click on add a post button for posts', async () => {
|
||||
axiosMock.onGet(getDiscussionsConfigUrl(courseId)).reply(200, {
|
||||
enableInContext: true, provider: 'openedx', hasModerationPrivileges: true, isEmailVerified: true,
|
||||
});
|
||||
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
|
||||
await renderComponent(`/${courseId}/my-posts`);
|
||||
|
||||
@@ -230,15 +224,15 @@ describe('DiscussionsHome', () => {
|
||||
|
||||
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, hasModerationPrivileges: true,
|
||||
enable_in_context: false, hasModerationPrivileges: true, isEmailVerified: true,
|
||||
});
|
||||
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
|
||||
await renderComponent(`/${courseId}/topics`);
|
||||
|
||||
await waitFor(() => expect(screen.queryByText('Nothing here yet')).toBeInTheDocument());
|
||||
await screen.findByText('Nothing here yet');
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.queryByText('Add a post'));
|
||||
fireEvent.click((await screen.findAllByText('Add a post'))[0]);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(container.querySelector('.post-form')).toBeInTheDocument());
|
||||
@@ -247,27 +241,27 @@ describe('DiscussionsHome', () => {
|
||||
it('should display Add a post button for legacy topics view', async () => {
|
||||
await renderComponent(`/${courseId}/topics/topic-1`);
|
||||
|
||||
await waitFor(() => expect(screen.queryByText('Add a post')).toBeInTheDocument());
|
||||
await screen.findByText('Add a post');
|
||||
});
|
||||
|
||||
it('should display No post selected for legacy topics view', async () => {
|
||||
await setUpV1TopicsMockResponse();
|
||||
await renderComponent(`/${courseId}/topics/category-1-topic-1`);
|
||||
|
||||
await waitFor(() => expect(screen.queryByText('No post selected')).toBeInTheDocument());
|
||||
await screen.findByText('No post selected');
|
||||
});
|
||||
|
||||
it('should display No topic selected for legacy topics view', async () => {
|
||||
await setUpV1TopicsMockResponse();
|
||||
await renderComponent(`/${courseId}/topics`);
|
||||
|
||||
await waitFor(() => expect(screen.queryByText('No topic selected')).toBeInTheDocument());
|
||||
await screen.findByText('No topic selected');
|
||||
});
|
||||
|
||||
it('should display navigation tabs', async () => {
|
||||
renderComponent(`/${courseId}/topics`);
|
||||
|
||||
await waitFor(() => expect(screen.queryByText('Discussion')).toBeInTheDocument());
|
||||
await screen.findByText('Discussion');
|
||||
});
|
||||
|
||||
it('should display content unavailable message when the user is not enrolled in the course.', async () => {
|
||||
@@ -276,7 +270,7 @@ describe('DiscussionsHome', () => {
|
||||
|
||||
renderComponent();
|
||||
|
||||
await waitFor(() => expect(screen.queryByText('Content unavailable')).toBeInTheDocument());
|
||||
await screen.findByText('Content unavailable');
|
||||
});
|
||||
|
||||
it('should redirect to dashboard when the user clicks on the Enroll button.', async () => {
|
||||
|
||||
@@ -5,23 +5,29 @@ import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import withEmailConfirmation from '../common/withEmailConfirmation';
|
||||
import { useIsOnTablet } from '../data/hooks';
|
||||
import { selectAreThreadsFiltered, selectPostThreadCount } from '../data/selectors';
|
||||
import {
|
||||
selectAreThreadsFiltered,
|
||||
selectPostThreadCount,
|
||||
selectShouldShowEmailConfirmation,
|
||||
} from '../data/selectors';
|
||||
import messages from '../messages';
|
||||
import { showPostEditor } from '../posts/data';
|
||||
import postMessages from '../posts/post-actions-bar/messages';
|
||||
import EmptyPage from './EmptyPage';
|
||||
|
||||
const EmptyPosts = ({ subTitleMessage }) => {
|
||||
const EmptyPosts = ({ subTitleMessage, openEmailConfirmation }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
const isOnTabletorDesktop = useIsOnTablet();
|
||||
const isFiltered = useSelector(selectAreThreadsFiltered);
|
||||
const totalThreads = useSelector(selectPostThreadCount);
|
||||
const shouldShowEmailConfirmation = useSelector(selectShouldShowEmailConfirmation);
|
||||
|
||||
const addPost = useCallback(() => (
|
||||
dispatch(showPostEditor())
|
||||
), []);
|
||||
const addPost = useCallback(() => {
|
||||
if (shouldShowEmailConfirmation) { openEmailConfirmation(); } else { dispatch(showPostEditor()); }
|
||||
}, [shouldShowEmailConfirmation, openEmailConfirmation]);
|
||||
|
||||
let title = messages.noPostSelected;
|
||||
let subTitle = null;
|
||||
@@ -58,6 +64,7 @@ EmptyPosts.propTypes = {
|
||||
defaultMessage: propTypes.string,
|
||||
description: propTypes.string,
|
||||
}).isRequired,
|
||||
openEmailConfirmation: propTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default React.memo(EmptyPosts);
|
||||
export default React.memo(withEmailConfirmation(EmptyPosts));
|
||||
|
||||
@@ -1,28 +1,31 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import propTypes from 'prop-types';
|
||||
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import withEmailConfirmation from '../common/withEmailConfirmation';
|
||||
import { useIsOnTablet, useTotalTopicThreadCount } from '../data/hooks';
|
||||
import { selectTopicThreadCount } from '../data/selectors';
|
||||
import { selectShouldShowEmailConfirmation, selectTopicThreadCount } from '../data/selectors';
|
||||
import messages from '../messages';
|
||||
import { showPostEditor } from '../posts/data';
|
||||
import postMessages from '../posts/post-actions-bar/messages';
|
||||
import EmptyPage from './EmptyPage';
|
||||
|
||||
const EmptyTopics = () => {
|
||||
const EmptyTopics = ({ openEmailConfirmation }) => {
|
||||
const intl = useIntl();
|
||||
const { topicId } = useParams();
|
||||
const dispatch = useDispatch();
|
||||
const isOnTabletorDesktop = useIsOnTablet();
|
||||
const hasGlobalThreads = useTotalTopicThreadCount() > 0;
|
||||
const topicThreadCount = useSelector(selectTopicThreadCount(topicId));
|
||||
const shouldShowEmailConfirmation = useSelector(selectShouldShowEmailConfirmation);
|
||||
|
||||
const addPost = useCallback(() => (
|
||||
dispatch(showPostEditor())
|
||||
), []);
|
||||
const addPost = useCallback(() => {
|
||||
if (shouldShowEmailConfirmation) { openEmailConfirmation(); } else { dispatch(showPostEditor()); }
|
||||
}, [shouldShowEmailConfirmation, openEmailConfirmation]);
|
||||
|
||||
let title = messages.emptyTitle;
|
||||
let fullWidth = false;
|
||||
@@ -63,4 +66,8 @@ const EmptyTopics = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default EmptyTopics;
|
||||
EmptyTopics.propTypes = {
|
||||
openEmailConfirmation: propTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default React.memo(withEmailConfirmation(EmptyTopics));
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { IntlProvider } from 'react-intl';
|
||||
import { Context as ResponsiveContext } from 'react-responsive';
|
||||
@@ -61,20 +62,27 @@ describe('EmptyTopics', () => {
|
||||
},
|
||||
});
|
||||
|
||||
store = initializeStore({ config: { provider: 'legacy' } });
|
||||
store = initializeStore({ config: { provider: 'legacy', onlyVerifiedUsersCanPost: true } });
|
||||
});
|
||||
|
||||
test('"no topic selected" text shown when viewing topics page', async () => {
|
||||
renderComponent(`/${courseId}/topics/`);
|
||||
expect(screen.queryByText(messages.emptyTitle.defaultMessage))
|
||||
.toBeInTheDocument();
|
||||
await screen.findByText(messages.emptyTitle.defaultMessage);
|
||||
});
|
||||
|
||||
test('"no post selected" text shown when viewing a specific topic', async () => {
|
||||
await setupMockResponse();
|
||||
renderComponent(`/${courseId}/topics/ncwtopic-3/`);
|
||||
|
||||
expect(screen.queryByText(messages.noPostSelected.defaultMessage))
|
||||
.toBeInTheDocument();
|
||||
await screen.findByText(messages.noPostSelected.defaultMessage);
|
||||
});
|
||||
|
||||
it('should open the confirmation link dialogue box.', async () => {
|
||||
renderComponent(`/${courseId}/topics/ncwtopic-3/`);
|
||||
|
||||
const addPostButton = screen.getByRole('button', { name: 'Add a post' });
|
||||
await userEvent.click(addPostButton);
|
||||
|
||||
expect(screen.queryByText('Send confirmation link')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -283,9 +283,9 @@ describe('InContext Topic Posts View', () => {
|
||||
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();
|
||||
await within(container).findByText('Nothing here yet');
|
||||
await within(container).findByText('No topic exists');
|
||||
await within(container).findByText('Unnamed Topic');
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { ResponsiveContext } from '@openedx/paragon';
|
||||
import {
|
||||
fireEvent, render, screen, waitFor,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { IntlProvider } from 'react-intl';
|
||||
@@ -17,6 +19,7 @@ import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { initializeStore } from '../../store';
|
||||
import executeThunk from '../../test-utils';
|
||||
import DiscussionContext from '../common/context';
|
||||
import EmptyTopics from './components/EmptyTopics';
|
||||
import { getCourseTopicsApiUrl } from './data/api';
|
||||
import { selectCoursewareTopics, selectNonCoursewareTopics } from './data/selectors';
|
||||
import fetchCourseTopicsV3 from './data/thunks';
|
||||
@@ -39,6 +42,24 @@ const LocationComponent = () => {
|
||||
return null;
|
||||
};
|
||||
|
||||
function renderEmptyTopicComponent(topicId = 'sample-topic-id') {
|
||||
return render(
|
||||
<IntlProvider locale="en">
|
||||
<ResponsiveContext.Provider value={{ width: 1280 }}>
|
||||
<AppProvider store={store} wrapWithRouter={false}>
|
||||
<DiscussionContext.Provider value={{ courseId, category }}>
|
||||
<MemoryRouter initialEntries={[`/discussion/${category}/${topicId}`]}>
|
||||
<Routes>
|
||||
<Route path="/discussion/:category/:topicId" element={<EmptyTopics />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</DiscussionContext.Provider>
|
||||
</AppProvider>
|
||||
</ResponsiveContext.Provider>
|
||||
</IntlProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function renderComponent() {
|
||||
const wrapper = render(
|
||||
<IntlProvider locale="en">
|
||||
@@ -71,7 +92,9 @@ describe('InContext Topics View', () => {
|
||||
});
|
||||
|
||||
store = initializeStore({
|
||||
config: { enableInContext: true, provider: 'openedx', hasModerationPrivileges: true },
|
||||
config: {
|
||||
enableInContext: true, provider: 'openedx', hasModerationPrivileges: true, onlyVerifiedUsersCanPost: true,
|
||||
},
|
||||
});
|
||||
Factory.resetAll();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
@@ -165,9 +188,10 @@ describe('InContext Topics View', () => {
|
||||
it('The subsection should have a title name, be clickable, and have the stats', async () => {
|
||||
await setupMockResponse();
|
||||
renderComponent();
|
||||
await screen.findByTestId('redux-provider');
|
||||
const subsectionObject = coursewareTopics[0].children[0];
|
||||
const subSection = await container.querySelector(`[data-subsection-id=${subsectionObject.id}]`);
|
||||
const subSectionTitle = await within(subSection).queryByText(subsectionObject.displayName);
|
||||
const subSectionTitle = await within(subSection).findByText(subsectionObject.displayName);
|
||||
const statsList = await subSection.querySelectorAll('.icon-size');
|
||||
|
||||
expect(subSectionTitle).toBeInTheDocument();
|
||||
@@ -177,11 +201,12 @@ describe('InContext Topics View', () => {
|
||||
it('Subsection names should be clickable and redirected to the units lists', async () => {
|
||||
await setupMockResponse();
|
||||
renderComponent();
|
||||
await screen.findByTestId('redux-provider');
|
||||
|
||||
const subsectionObject = coursewareTopics[0].children[0];
|
||||
const subSection = await container.querySelector(`[data-subsection-id=${subsectionObject.id}]`);
|
||||
|
||||
await act(async () => { fireEvent.click(subSection); });
|
||||
await userEvent.click(subSection);
|
||||
await waitFor(async () => {
|
||||
const backButton = await screen.getByLabelText('Back to topics list');
|
||||
const topicsList = await screen.getByRole('list');
|
||||
@@ -198,9 +223,11 @@ describe('InContext Topics View', () => {
|
||||
it('The number of units should be matched with the actual unit length.', async () => {
|
||||
await setupMockResponse();
|
||||
renderComponent();
|
||||
await screen.findByTestId('redux-provider');
|
||||
|
||||
const subSection = await container.querySelector(`[data-subsection-id=${coursewareTopics[0].children[0].id}]`);
|
||||
|
||||
await act(async () => { fireEvent.click(subSection); });
|
||||
await userEvent.click(subSection);
|
||||
await waitFor(async () => {
|
||||
const units = await container.querySelectorAll('.discussion-topic');
|
||||
|
||||
@@ -211,12 +238,14 @@ describe('InContext Topics View', () => {
|
||||
it('A unit should have a title and should be clickable', async () => {
|
||||
await setupMockResponse();
|
||||
renderComponent();
|
||||
await screen.findByTestId('redux-provider');
|
||||
|
||||
const subSectionObject = coursewareTopics[0].children[0];
|
||||
const unitObject = subSectionObject.children[0];
|
||||
|
||||
const subSection = await container.querySelector(`[data-subsection-id=${subSectionObject.id}]`);
|
||||
|
||||
await act(async () => { fireEvent.click(subSection); });
|
||||
await userEvent.click(subSection);
|
||||
await waitFor(async () => {
|
||||
const unitElement = await screen.findByText(unitObject.name);
|
||||
const unitContainer = await container.querySelector(`[data-topic-id=${unitObject.id}]`);
|
||||
@@ -231,4 +260,14 @@ describe('InContext Topics View', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should open the confirmation link dialogue box.', async () => {
|
||||
renderEmptyTopicComponent();
|
||||
|
||||
const addPostButton = screen.getByRole('button', { name: /Add a post/i });
|
||||
await userEvent.click(addPostButton);
|
||||
|
||||
const confirmationText = await screen.findByText(/send confirmation link/i);
|
||||
expect(confirmationText).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useCallback, useContext } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useParams } from 'react-router-dom';
|
||||
@@ -6,14 +7,15 @@ import { useParams } from 'react-router-dom';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import DiscussionContext from '../../common/context';
|
||||
import withEmailConfirmation from '../../common/withEmailConfirmation';
|
||||
import { useIsOnTablet } from '../../data/hooks';
|
||||
import { selectPostThreadCount } from '../../data/selectors';
|
||||
import { selectPostThreadCount, selectShouldShowEmailConfirmation } from '../../data/selectors';
|
||||
import EmptyPage from '../../empty-posts/EmptyPage';
|
||||
import messages from '../../messages';
|
||||
import { messages as postMessages, showPostEditor } from '../../posts';
|
||||
import { selectCourseWareThreadsCount, selectTotalTopicsThreadsCount } from '../data/selectors';
|
||||
|
||||
const EmptyTopics = () => {
|
||||
const EmptyTopics = ({ openEmailConfirmation }) => {
|
||||
const intl = useIntl();
|
||||
const { category, topicId } = useParams();
|
||||
const dispatch = useDispatch();
|
||||
@@ -23,10 +25,11 @@ const EmptyTopics = () => {
|
||||
const topicThreadsCount = useSelector(selectPostThreadCount);
|
||||
// hasGlobalThreads is used to determine if there are any post available in courseware and non-courseware topics
|
||||
const hasGlobalThreads = useSelector(selectTotalTopicsThreadsCount) > 0;
|
||||
const shouldShowEmailConfirmation = useSelector(selectShouldShowEmailConfirmation);
|
||||
|
||||
const addPost = useCallback(() => (
|
||||
dispatch(showPostEditor())
|
||||
), []);
|
||||
const addPost = useCallback(() => {
|
||||
if (shouldShowEmailConfirmation) { openEmailConfirmation(); } else { dispatch(showPostEditor()); }
|
||||
}, [shouldShowEmailConfirmation, openEmailConfirmation]);
|
||||
|
||||
let title = messages.emptyTitle;
|
||||
let fullWidth = false;
|
||||
@@ -75,4 +78,8 @@ const EmptyTopics = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default EmptyTopics;
|
||||
EmptyTopics.propTypes = {
|
||||
openEmailConfirmation: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default React.memo(withEmailConfirmation(EmptyTopics));
|
||||
|
||||
107
src/discussions/learners/LearnerActionsDropdown.jsx
Normal file
107
src/discussions/learners/LearnerActionsDropdown.jsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import React, {
|
||||
useCallback, useRef, useState,
|
||||
} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import {
|
||||
Button, Dropdown, Icon, IconButton, ModalPopup, useToggle,
|
||||
} from '@openedx/paragon';
|
||||
import { MoreHoriz } from '@openedx/paragon/icons';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { useLearnerActions } from './utils';
|
||||
|
||||
const LearnerActionsDropdown = ({
|
||||
actionHandlers,
|
||||
dropDownIconSize,
|
||||
userHasBulkDeletePrivileges,
|
||||
}) => {
|
||||
const buttonRef = useRef();
|
||||
const intl = useIntl();
|
||||
const [isOpen, open, close] = useToggle(false);
|
||||
const [target, setTarget] = useState(null);
|
||||
const actions = useLearnerActions(userHasBulkDeletePrivileges);
|
||||
|
||||
const handleActions = useCallback((action) => {
|
||||
const actionFunction = actionHandlers[action];
|
||||
if (actionFunction) {
|
||||
actionFunction();
|
||||
}
|
||||
}, [actionHandlers]);
|
||||
|
||||
const onClickButton = useCallback((event) => {
|
||||
event.preventDefault();
|
||||
setTarget(buttonRef.current);
|
||||
open();
|
||||
}, [open]);
|
||||
|
||||
const onCloseModal = useCallback(() => {
|
||||
close();
|
||||
setTarget(null);
|
||||
}, [close]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
onClick={onClickButton}
|
||||
alt={intl.formatMessage({ id: 'discussions.learner.actions.alt', defaultMessage: 'Actions menu' })}
|
||||
src={MoreHoriz}
|
||||
iconAs={Icon}
|
||||
size="sm"
|
||||
ref={buttonRef}
|
||||
iconClassNames={dropDownIconSize ? 'dropdown-icon-dimensions' : ''}
|
||||
/>
|
||||
<div className="actions-dropdown">
|
||||
<ModalPopup
|
||||
onClose={onCloseModal}
|
||||
positionRef={target}
|
||||
isOpen={isOpen}
|
||||
placement="bottom-start"
|
||||
>
|
||||
<div
|
||||
className="bg-white shadow d-flex flex-column mt-1"
|
||||
data-testid="learner-actions-dropdown-modal-popup"
|
||||
>
|
||||
{actions.map(action => (
|
||||
<React.Fragment key={action.id}>
|
||||
<Dropdown.Item
|
||||
as={Button}
|
||||
variant="tertiary"
|
||||
size="inline"
|
||||
onClick={() => {
|
||||
close();
|
||||
handleActions(action.action);
|
||||
}}
|
||||
className="d-flex justify-content-start actions-dropdown-item"
|
||||
data-testId={action.id}
|
||||
>
|
||||
<Icon
|
||||
src={action.icon}
|
||||
className="icon-size-24"
|
||||
/>
|
||||
<span className="font-weight-normal ml-2">
|
||||
{action.label.defaultMessage}
|
||||
</span>
|
||||
</Dropdown.Item>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</ModalPopup>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
LearnerActionsDropdown.propTypes = {
|
||||
actionHandlers: PropTypes.objectOf(PropTypes.func).isRequired,
|
||||
dropDownIconSize: PropTypes.bool,
|
||||
userHasBulkDeletePrivileges: PropTypes.bool,
|
||||
};
|
||||
|
||||
LearnerActionsDropdown.defaultProps = {
|
||||
dropDownIconSize: false,
|
||||
userHasBulkDeletePrivileges: false,
|
||||
};
|
||||
|
||||
export default LearnerActionsDropdown;
|
||||
153
src/discussions/learners/LearnerActionsDropdown.test.jsx
Normal file
153
src/discussions/learners/LearnerActionsDropdown.test.jsx
Normal file
@@ -0,0 +1,153 @@
|
||||
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';
|
||||
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 { ContentActions } from '../../data/constants';
|
||||
import { initializeStore } from '../../store';
|
||||
import executeThunk from '../../test-utils';
|
||||
import { getCourseConfigApiUrl } from '../data/api';
|
||||
import fetchCourseConfig from '../data/thunks';
|
||||
import LearnerActionsDropdown from './LearnerActionsDropdown';
|
||||
|
||||
let store;
|
||||
let axiosMock;
|
||||
const courseId = 'course-v1:edX+TestX+Test_Course';
|
||||
const username = 'abc123';
|
||||
|
||||
const renderComponent = ({
|
||||
contentType = 'LEARNER',
|
||||
userHasBulkDeletePrivileges = false,
|
||||
actionHandlers = {},
|
||||
} = {}) => {
|
||||
render(
|
||||
<IntlProvider locale="en">
|
||||
<AppProvider store={store}>
|
||||
<LearnerActionsDropdown
|
||||
contentType={contentType}
|
||||
userHasBulkDeletePrivileges={userHasBulkDeletePrivileges}
|
||||
actionHandlers={actionHandlers}
|
||||
/>
|
||||
</AppProvider>
|
||||
</IntlProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
const findOpenActionsDropdownButton = async () => (
|
||||
screen.findByRole('button', { name: 'Actions menu' })
|
||||
);
|
||||
|
||||
describe('LearnerActionsDropdown', () => {
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username,
|
||||
administrator: false,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore();
|
||||
Factory.resetAll();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
|
||||
axiosMock.onGet(`${getCourseConfigApiUrl()}${courseId}/`)
|
||||
.reply(200, { isPostingEnabled: true });
|
||||
|
||||
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
|
||||
});
|
||||
|
||||
it('can open dropdown if enabled', async () => {
|
||||
renderComponent({ userHasBulkDeletePrivileges: true });
|
||||
|
||||
const openButton = await findOpenActionsDropdownButton();
|
||||
await act(async () => {
|
||||
fireEvent.click(openButton);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(screen.queryByTestId('learner-actions-dropdown-modal-popup')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('shows delete action for privileged users', async () => {
|
||||
const mockHandler = jest.fn();
|
||||
renderComponent({
|
||||
userHasBulkDeletePrivileges: true,
|
||||
actionHandlers: { deleteCoursePosts: mockHandler, deleteOrgPosts: mockHandler },
|
||||
});
|
||||
|
||||
const openButton = await findOpenActionsDropdownButton();
|
||||
await act(async () => {
|
||||
fireEvent.click(openButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const deleteCourseItem = screen.queryByTestId('delete-course-posts');
|
||||
const deleteOrgItem = screen.queryByTestId('delete-org-posts');
|
||||
expect(deleteCourseItem).toBeInTheDocument();
|
||||
expect(deleteOrgItem).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('triggers deleteCoursePosts handler when delete-course-posts is clicked', async () => {
|
||||
const mockDeleteCourseHandler = jest.fn();
|
||||
const mockDeleteOrgHandler = jest.fn();
|
||||
renderComponent({
|
||||
userHasBulkDeletePrivileges: true,
|
||||
actionHandlers: {
|
||||
[ContentActions.DELETE_COURSE_POSTS]: mockDeleteCourseHandler,
|
||||
[ContentActions.DELETE_ORG_POSTS]: mockDeleteOrgHandler,
|
||||
},
|
||||
});
|
||||
|
||||
const openButton = await findOpenActionsDropdownButton();
|
||||
await act(async () => {
|
||||
fireEvent.click(openButton);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(screen.queryByTestId('learner-actions-dropdown-modal-popup')).toBeInTheDocument());
|
||||
|
||||
const deleteCourseItem = await screen.findByTestId('delete-course-posts');
|
||||
await act(async () => {
|
||||
fireEvent.click(deleteCourseItem);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(screen.queryByTestId('learner-actions-dropdown-modal-popup')).not.toBeInTheDocument());
|
||||
expect(mockDeleteCourseHandler).toHaveBeenCalled();
|
||||
expect(mockDeleteOrgHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('triggers deleteOrgPosts handler when delete-org-posts is clicked', async () => {
|
||||
const mockDeleteCourseHandler = jest.fn();
|
||||
const mockDeleteOrgHandler = jest.fn();
|
||||
renderComponent({
|
||||
userHasBulkDeletePrivileges: true,
|
||||
actionHandlers: {
|
||||
[ContentActions.DELETE_COURSE_POSTS]: mockDeleteCourseHandler,
|
||||
[ContentActions.DELETE_ORG_POSTS]: mockDeleteOrgHandler,
|
||||
},
|
||||
});
|
||||
|
||||
const openButton = await findOpenActionsDropdownButton();
|
||||
await act(async () => {
|
||||
fireEvent.click(openButton);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(screen.queryByTestId('learner-actions-dropdown-modal-popup')).toBeInTheDocument());
|
||||
|
||||
const deleteOrgItem = await screen.findByTestId('delete-org-posts');
|
||||
await act(async () => {
|
||||
fireEvent.click(deleteOrgItem);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(screen.queryByTestId('learner-actions-dropdown-modal-popup')).not.toBeInTheDocument());
|
||||
expect(mockDeleteOrgHandler).toHaveBeenCalled();
|
||||
expect(mockDeleteCourseHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,9 @@
|
||||
import React, {
|
||||
useCallback, useContext, useEffect, useMemo,
|
||||
useCallback, useContext, useEffect, useMemo, useState,
|
||||
} from 'react';
|
||||
|
||||
import {
|
||||
Button, Icon, IconButton, Spinner,
|
||||
Button, Icon, IconButton, Spinner, useToggle,
|
||||
} from '@openedx/paragon';
|
||||
import { ArrowBack } from '@openedx/paragon/icons';
|
||||
import capitalize from 'lodash/capitalize';
|
||||
@@ -13,11 +13,18 @@ import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import {
|
||||
ContentActions,
|
||||
RequestStatus,
|
||||
Routes,
|
||||
} from '../../data/constants';
|
||||
import useDispatchWithState from '../../data/hooks';
|
||||
import { Confirmation } from '../common';
|
||||
import DiscussionContext from '../common/context';
|
||||
import { selectUserHasModerationPrivileges, selectUserIsStaff } from '../data/selectors';
|
||||
import {
|
||||
selectUserHasBulkDeletePrivileges,
|
||||
selectUserHasModerationPrivileges,
|
||||
selectUserIsStaff,
|
||||
} from '../data/selectors';
|
||||
import usePostList from '../posts/data/hooks';
|
||||
import {
|
||||
selectAllThreadsIds,
|
||||
@@ -28,8 +35,11 @@ import { clearPostsPages } from '../posts/data/slices';
|
||||
import NoResults from '../posts/NoResults';
|
||||
import { PostLink } from '../posts/post';
|
||||
import { discussionsPath } from '../utils';
|
||||
import { fetchUserPosts } from './data/thunks';
|
||||
import { BulkDeleteType } from './data/constants';
|
||||
import { learnersLoadingStatus, selectBulkDeleteStats } from './data/selectors';
|
||||
import { deleteUserPosts, fetchUserPosts } from './data/thunks';
|
||||
import LearnerPostFilterBar from './learner-post-filter-bar/LearnerPostFilterBar';
|
||||
import LearnerActionsDropdown from './LearnerActionsDropdown';
|
||||
import messages from './messages';
|
||||
|
||||
const LearnerPostsView = () => {
|
||||
@@ -38,14 +48,20 @@ const LearnerPostsView = () => {
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const [bulkDeleting, dispatchDelete] = useDispatchWithState();
|
||||
const postsIds = useSelector(selectAllThreadsIds);
|
||||
const loadingStatus = useSelector(threadsLoadingStatus());
|
||||
const learnerLoadingStatus = useSelector(learnersLoadingStatus());
|
||||
const postFilter = useSelector(state => state.learners.postFilter);
|
||||
const { courseId, learnerUsername: username } = useContext(DiscussionContext);
|
||||
const nextPage = useSelector(selectThreadNextPage());
|
||||
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
|
||||
const userIsStaff = useSelector(selectUserIsStaff);
|
||||
const userHasBulkDeletePrivileges = useSelector(selectUserHasBulkDeletePrivileges);
|
||||
const bulkDeleteStats = useSelector(selectBulkDeleteStats());
|
||||
const sortedPostsIds = usePostList(postsIds);
|
||||
const [isDeleting, showDeleteConfirmation, hideDeleteConfirmation] = useToggle(false);
|
||||
const [isDeletingCourseOrOrg, setIsDeletingCourseOrOrg] = useState(BulkDeleteType.COURSE);
|
||||
|
||||
const loadMorePosts = useCallback((pageNum = undefined) => {
|
||||
const params = {
|
||||
@@ -59,6 +75,23 @@ const LearnerPostsView = () => {
|
||||
dispatch(fetchUserPosts(courseId, params));
|
||||
}, [courseId, postFilter, username, userHasModerationPrivileges, userIsStaff]);
|
||||
|
||||
const handleShowDeleteConfirmation = useCallback(async (courseOrOrg) => {
|
||||
setIsDeletingCourseOrOrg(courseOrOrg);
|
||||
showDeleteConfirmation();
|
||||
await dispatch(deleteUserPosts(courseId, username, courseOrOrg, false));
|
||||
}, [courseId, username, showDeleteConfirmation]);
|
||||
|
||||
const handleDeletePosts = useCallback(async (courseOrOrg) => {
|
||||
await dispatchDelete(deleteUserPosts(courseId, username, courseOrOrg, true));
|
||||
navigate({ ...discussionsPath(Routes.LEARNERS.PATH, { courseId })(location) });
|
||||
hideDeleteConfirmation();
|
||||
}, [courseId, username, hideDeleteConfirmation]);
|
||||
|
||||
const actionHandlers = useMemo(() => ({
|
||||
[ContentActions.DELETE_COURSE_POSTS]: () => handleShowDeleteConfirmation(BulkDeleteType.COURSE),
|
||||
[ContentActions.DELETE_ORG_POSTS]: () => handleShowDeleteConfirmation(BulkDeleteType.ORG),
|
||||
}), [handleShowDeleteConfirmation]);
|
||||
|
||||
const postInstances = useMemo(() => (
|
||||
sortedPostsIds?.map((postId, idx) => (
|
||||
<PostLink
|
||||
@@ -77,19 +110,31 @@ const LearnerPostsView = () => {
|
||||
|
||||
return (
|
||||
<div className="discussion-posts d-flex flex-column">
|
||||
<div className="d-flex align-items-center justify-content-between px-2.5">
|
||||
<IconButton
|
||||
src={ArrowBack}
|
||||
iconAs={Icon}
|
||||
style={{ padding: '18px' }}
|
||||
size="inline"
|
||||
onClick={() => navigate({ ...discussionsPath(Routes.LEARNERS.PATH, { courseId })(location) })}
|
||||
alt={intl.formatMessage(messages.back)}
|
||||
/>
|
||||
<div className="text-primary-500 font-style font-weight-bold py-2.5">
|
||||
<div className="row d-flex align-items-center justify-content-between px-2.5">
|
||||
<div className="col-1">
|
||||
<IconButton
|
||||
src={ArrowBack}
|
||||
iconAs={Icon}
|
||||
style={{ padding: '18px' }}
|
||||
size="inline"
|
||||
onClick={() => navigate({ ...discussionsPath(Routes.LEARNERS.PATH, { courseId })(location) })}
|
||||
alt={intl.formatMessage(messages.back)}
|
||||
/>
|
||||
</div>
|
||||
<div className=" col-auto text-primary-500 font-style font-weight-bold py-2.5">
|
||||
{intl.formatMessage(messages.activityForLearner, { username: capitalize(username) })}
|
||||
</div>
|
||||
<div style={{ padding: '18px' }} />
|
||||
{userHasBulkDeletePrivileges ? (
|
||||
<div className="col-2">
|
||||
<LearnerActionsDropdown
|
||||
id={username}
|
||||
actionHandlers={actionHandlers}
|
||||
userHasBulkDeletePrivileges={userHasBulkDeletePrivileges}
|
||||
dropDownIconSize
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
: (<div style={{ padding: '18px' }} />)}
|
||||
</div>
|
||||
<div className="bg-light-400 border border-light-300" />
|
||||
<LearnerPostFilterBar />
|
||||
@@ -109,6 +154,22 @@ const LearnerPostsView = () => {
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<Confirmation
|
||||
isOpen={isDeleting}
|
||||
title={intl.formatMessage(messages.deletePostsTitle)}
|
||||
description={intl.formatMessage(messages.deletePostsDescription, {
|
||||
count: bulkDeleteStats.threadCount + bulkDeleteStats.commentCount,
|
||||
bulkType: isDeletingCourseOrOrg,
|
||||
})}
|
||||
boldDescription={intl.formatMessage(messages.deletePostsBoldDescription)}
|
||||
onClose={hideDeleteConfirmation}
|
||||
confirmAction={() => handleDeletePosts(isDeletingCourseOrOrg)}
|
||||
confirmButtonText={intl.formatMessage(messages.deletePostsConfirm)}
|
||||
confirmButtonVariant="danger"
|
||||
isDataLoading={!(learnerLoadingStatus === RequestStatus.SUCCESSFUL)}
|
||||
isConfirmButtonPending={bulkDeleting}
|
||||
pendingConfirmButtonText={intl.formatMessage(messages.deletePostConfirmPending)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
|
||||
import {
|
||||
fireEvent, render, screen, waitFor,
|
||||
fireEvent, render, screen, waitFor, within,
|
||||
} from '@testing-library/react';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
@@ -20,7 +20,7 @@ import executeThunk from '../../test-utils';
|
||||
import { getCohortsApiUrl } from '../cohorts/data/api';
|
||||
import fetchCourseCohorts from '../cohorts/data/thunks';
|
||||
import DiscussionContext from '../common/context';
|
||||
import { learnerPostsApiUrl } from './data/api';
|
||||
import { deletePostsApiUrl, learnerPostsApiUrl } from './data/api';
|
||||
import { fetchUserPosts } from './data/thunks';
|
||||
import LearnerPostsView from './LearnerPostsView';
|
||||
import { setUpPrivilages } from './test-utils';
|
||||
@@ -220,4 +220,128 @@ describe('Learner Posts View', () => {
|
||||
expect(loadMoreButton).not.toBeInTheDocument();
|
||||
expect(container.querySelectorAll('.discussion-post')).toHaveLength(4);
|
||||
});
|
||||
|
||||
test('should display dropdown menu button for bulk delete user posts for privileged users', async () => {
|
||||
await setUpPrivilages(axiosMock, store, true, true);
|
||||
await renderComponent();
|
||||
expect(within(container).queryByRole('button', { name: /actions menu/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should NOT display dropdown menu button for bulk delete user posts for other users', async () => {
|
||||
await setUpPrivilages(axiosMock, store, true, false);
|
||||
await renderComponent();
|
||||
expect(within(container).queryByRole('button', { name: /actions menu/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should display confirmation dialog when delete course posts is clicked', async () => {
|
||||
await setUpPrivilages(axiosMock, store, true, true);
|
||||
axiosMock.onPost(deletePostsApiUrl(courseId, username, 'course', false))
|
||||
.reply(202, { thread_count: 2, comment_count: 3 });
|
||||
await renderComponent();
|
||||
|
||||
const actionsButton = await screen.findByRole('button', { name: /actions menu/i });
|
||||
await act(async () => {
|
||||
fireEvent.click(actionsButton);
|
||||
});
|
||||
|
||||
const deleteCourseItem = await screen.findByTestId('delete-course-posts');
|
||||
await act(async () => {
|
||||
fireEvent.click(deleteCourseItem);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const dialog = screen.getByText('Are you sure you want to delete this user\'s discussion contributions?');
|
||||
expect(dialog).toBeInTheDocument();
|
||||
expect(screen.getByText('You are about to delete 5 discussion contributions by this user in this course. This includes all discussion threads, responses, and comments authored by them.')).toBeInTheDocument();
|
||||
expect(screen.getByText('This action cannot be undone.')).toBeInTheDocument();
|
||||
expect(screen.getByText('Cancel')).toBeInTheDocument();
|
||||
expect(screen.getByText('Delete')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('should complete delete course posts flow and redirect', async () => {
|
||||
await setUpPrivilages(axiosMock, store, true, true);
|
||||
axiosMock.onPost(deletePostsApiUrl(courseId, username, 'course', false))
|
||||
.reply(202, { thread_count: 2, comment_count: 3 });
|
||||
axiosMock.onPost(deletePostsApiUrl(courseId, username, 'course', true))
|
||||
.reply(202, { thread_count: 0, comment_count: 0 });
|
||||
await renderComponent();
|
||||
|
||||
const actionsButton = await screen.findByRole('button', { name: /actions menu/i });
|
||||
await act(async () => {
|
||||
fireEvent.click(actionsButton);
|
||||
});
|
||||
|
||||
const deleteCourseItem = await screen.findByTestId('delete-course-posts');
|
||||
await act(async () => {
|
||||
fireEvent.click(deleteCourseItem);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Are you sure you want to delete this user\'s discussion contributions?')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const confirmButton = await screen.findByText('Delete');
|
||||
await act(async () => {
|
||||
fireEvent.click(confirmButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastLocation.pathname.endsWith('/learners')).toBeTruthy();
|
||||
expect(screen.queryByText('Are you sure you want to delete this user\'s discussion contributions?')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('should close confirmation dialog when cancel is clicked', async () => {
|
||||
await setUpPrivilages(axiosMock, store, true, true);
|
||||
axiosMock.onPost(deletePostsApiUrl(courseId, username, 'course', false))
|
||||
.reply(202, { thread_count: 2, comment_count: 3 });
|
||||
await renderComponent();
|
||||
|
||||
const actionsButton = await screen.findByRole('button', { name: /actions menu/i });
|
||||
await act(async () => {
|
||||
fireEvent.click(actionsButton);
|
||||
});
|
||||
|
||||
const deleteCourseItem = await screen.findByTestId('delete-course-posts');
|
||||
await act(async () => {
|
||||
fireEvent.click(deleteCourseItem);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Are you sure you want to delete this user\'s discussion contributions?')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const cancelButton = await screen.findByText('Cancel');
|
||||
await act(async () => {
|
||||
fireEvent.click(cancelButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Are you sure you want to delete this user\'s discussion contributions?')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('should display confirmation dialog for org posts deletion', async () => {
|
||||
await setUpPrivilages(axiosMock, store, true, true);
|
||||
axiosMock.onPost(deletePostsApiUrl(courseId, username, 'org', false))
|
||||
.reply(202, { thread_count: 5, comment_count: 10 });
|
||||
await renderComponent();
|
||||
|
||||
const actionsButton = await screen.findByRole('button', { name: /actions menu/i });
|
||||
await act(async () => {
|
||||
fireEvent.click(actionsButton);
|
||||
});
|
||||
|
||||
const deleteOrgItem = await screen.findByTestId('delete-org-posts');
|
||||
await act(async () => {
|
||||
fireEvent.click(deleteOrgItem);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Are you sure you want to delete this user\'s discussion contributions?')).toBeInTheDocument();
|
||||
expect(screen.getByText('You are about to delete 15 discussion contributions by this user across the organization. This includes all discussion threads, responses, and comments authored by them.')).toBeInTheDocument();
|
||||
expect(screen.getByText('This action cannot be undone.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ export const getCoursesApiUrl = () => `${getConfig().LMS_BASE_URL}/api/discussio
|
||||
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/`;
|
||||
export const deletePostsApiUrl = (courseId, username, courseOrOrg, execute) => `${getConfig().LMS_BASE_URL}/api/discussion/v1/bulk_delete_user_posts/${courseId}?username=${username}&course_or_org=${courseOrOrg}&execute=${execute}`;
|
||||
|
||||
/**
|
||||
* Fetches all the learners in the given course.
|
||||
@@ -82,3 +83,23 @@ export async function getUserPosts(courseId, {
|
||||
.get(learnerPostsApiUrl(courseId), { params });
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes posts by a specific user in a course or organization
|
||||
* @param {string} courseId Course ID of the course
|
||||
* @param {string} username Username of the user whose posts are to be deleted
|
||||
* @param {string} courseOrOrg Can be 'course' or 'org' to specify deletion scope
|
||||
* @param {boolean} execute If true, deletes posts; if false, returns count of threads and comments
|
||||
* @returns API Response object in the format
|
||||
* {
|
||||
* thread_count: number,
|
||||
* comment_count: number
|
||||
* }
|
||||
*/
|
||||
export async function deleteUserPostsApi(courseId, username, courseOrOrg, execute) {
|
||||
const { data } = await getAuthenticatedHttpClient().post(
|
||||
deletePostsApiUrl(courseId, username, courseOrOrg, execute),
|
||||
null,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ 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 { setupDeleteUserPostsMockResponse, setupLearnerMockResponse, setupPostsMockResponse } from '../test-utils';
|
||||
|
||||
import './__factories__';
|
||||
|
||||
@@ -80,4 +80,25 @@ describe('Learner api test cases', () => {
|
||||
|
||||
expect(threads.status).toEqual('denied');
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ courseOrOrg: 'course', execute: false, response: { comment_count: 3, thread_count: 2 } },
|
||||
{ courseOrOrg: 'course', execute: true, response: { comment_count: 0, thread_count: 0 } },
|
||||
{ courseOrOrg: 'org', execute: false, response: { comment_count: 3, thread_count: 2 } },
|
||||
{ courseOrOrg: 'org', execute: true, response: { comment_count: 0, thread_count: 0 } },
|
||||
])(
|
||||
'Successfully fetches user post stats and bulk deletes user posts based on execute',
|
||||
async ({ courseOrOrg, execute, response }) => {
|
||||
const learners = await setupDeleteUserPostsMockResponse({ courseOrOrg, execute, response });
|
||||
|
||||
expect(learners.status).toEqual('successful');
|
||||
expect(Object.values(learners.bulkDeleteStats)).toEqual(Object.values(response));
|
||||
},
|
||||
);
|
||||
|
||||
it('Failed to bulk delete user posts', async () => {
|
||||
const learners = await setupPostsMockResponse({ statusCode: 400 });
|
||||
|
||||
expect(learners.status).toEqual('failed');
|
||||
});
|
||||
});
|
||||
|
||||
4
src/discussions/learners/data/constants.js
Normal file
4
src/discussions/learners/data/constants.js
Normal file
@@ -0,0 +1,4 @@
|
||||
export const BulkDeleteType = {
|
||||
COURSE: 'course',
|
||||
ORG: 'org',
|
||||
};
|
||||
@@ -12,3 +12,9 @@ export const selectUsernameSearch = () => state => state.learners.usernameSearch
|
||||
export const selectLearnerSorting = () => state => state.learners.sortedBy;
|
||||
|
||||
export const selectLearnerNextPage = () => state => state.learners.nextPage;
|
||||
|
||||
export const selectLearnerAvatar = author => state => (
|
||||
state.learners.learnerProfiles[author]?.profileImage?.imageUrlLarge
|
||||
);
|
||||
|
||||
export const selectBulkDeleteStats = () => state => state.learners.bulkDeleteStats;
|
||||
|
||||
@@ -25,6 +25,10 @@ const learnersSlice = createSlice({
|
||||
cohort: '',
|
||||
},
|
||||
usernameSearch: null,
|
||||
bulkDeleteStats: {
|
||||
commentCount: 0,
|
||||
threadCount: 0,
|
||||
},
|
||||
},
|
||||
reducers: {
|
||||
fetchLearnersSuccess: (state, { payload }) => (
|
||||
@@ -84,6 +88,25 @@ const learnersSlice = createSlice({
|
||||
postFilter: payload,
|
||||
}
|
||||
),
|
||||
deleteUserPostsRequest: (state) => (
|
||||
{
|
||||
...state,
|
||||
status: RequestStatus.IN_PROGRESS,
|
||||
}
|
||||
),
|
||||
deleteUserPostsSuccess: (state, { payload }) => (
|
||||
{
|
||||
...state,
|
||||
status: RequestStatus.SUCCESSFUL,
|
||||
bulkDeleteStats: payload,
|
||||
}
|
||||
),
|
||||
deleteUserPostsFailed: (state) => (
|
||||
{
|
||||
...state,
|
||||
status: RequestStatus.FAILED,
|
||||
}
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -95,6 +118,9 @@ export const {
|
||||
setSortedBy,
|
||||
setUsernameSearch,
|
||||
setPostFilter,
|
||||
deleteUserPostsRequest,
|
||||
deleteUserPostsSuccess,
|
||||
deleteUserPostsFailed,
|
||||
} = learnersSlice.actions;
|
||||
|
||||
export const learnersReducer = learnersSlice.reducer;
|
||||
|
||||
@@ -12,8 +12,16 @@ import {
|
||||
} from '../../posts/data/slices';
|
||||
import { normaliseThreads } from '../../posts/data/thunks';
|
||||
import { getHttpErrorStatus } from '../../utils';
|
||||
import { getLearners, getUserPosts, getUserProfiles } from './api';
|
||||
import {
|
||||
deleteUserPostsApi,
|
||||
getLearners,
|
||||
getUserPosts,
|
||||
getUserProfiles,
|
||||
} from './api';
|
||||
import {
|
||||
deleteUserPostsFailed,
|
||||
deleteUserPostsRequest,
|
||||
deleteUserPostsSuccess,
|
||||
fetchLearnersDenied,
|
||||
fetchLearnersFailed,
|
||||
fetchLearnersRequest,
|
||||
@@ -121,3 +129,19 @@ export function fetchUserPosts(courseId, {
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const deleteUserPosts = (
|
||||
courseId,
|
||||
username,
|
||||
courseOrOrg,
|
||||
execute,
|
||||
) => async (dispatch) => {
|
||||
try {
|
||||
dispatch(deleteUserPostsRequest({ courseId, username }));
|
||||
const response = await deleteUserPostsApi(courseId, username, courseOrOrg, execute);
|
||||
dispatch(deleteUserPostsSuccess(camelCaseObject(response)));
|
||||
} catch (error) {
|
||||
dispatch(deleteUserPostsFailed());
|
||||
logError(error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,19 +2,26 @@ import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Avatar } from '@openedx/paragon';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
const LearnerAvatar = ({ username }) => (
|
||||
<div className="mr-3 mt-1">
|
||||
<Avatar
|
||||
size="sm"
|
||||
alt={username}
|
||||
style={{
|
||||
height: '2rem',
|
||||
width: '2rem',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
import { selectLearnerAvatar } from '../data/selectors';
|
||||
|
||||
const LearnerAvatar = ({ username }) => {
|
||||
const learnerAvatar = useSelector(selectLearnerAvatar(username));
|
||||
return (
|
||||
<div className="mr-3 mt-1">
|
||||
<Avatar
|
||||
size="sm"
|
||||
alt={username}
|
||||
src={learnerAvatar}
|
||||
style={{
|
||||
height: '2rem',
|
||||
width: '2rem',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
LearnerAvatar.propTypes = {
|
||||
username: PropTypes.string.isRequired,
|
||||
|
||||
@@ -62,6 +62,45 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Posts',
|
||||
description: 'Tooltip text for all posts icon',
|
||||
},
|
||||
deleteCoursePosts: {
|
||||
id: 'discussions.learner.actions.deleteCoursePosts',
|
||||
defaultMessage: 'Delete user posts within this course',
|
||||
description: 'Action to delete user posts within a specific course',
|
||||
},
|
||||
deleteOrgPosts: {
|
||||
id: 'discussions.learner.actions.deleteOrgPosts',
|
||||
defaultMessage: 'Delete user posts within this organization',
|
||||
description: 'Action to delete user posts within the organization',
|
||||
},
|
||||
deletePostsTitle: {
|
||||
id: 'discussions.learner.deletePosts.title',
|
||||
defaultMessage: 'Are you sure you want to delete this user\'s discussion contributions?',
|
||||
description: 'Title for delete course posts confirmation dialog',
|
||||
},
|
||||
deletePostsDescription: {
|
||||
id: 'discussions.learner.deletePosts.description',
|
||||
defaultMessage: `{bulkType, select,
|
||||
course {You are about to delete {count, plural, one {# discussion contribution} other {# discussion contributions}} by this user in this course. This includes all discussion threads, responses, and comments authored by them.}
|
||||
org {You are about to delete {count, plural, one {# discussion contribution} other {# discussion contributions}} by this user across the organization. This includes all discussion threads, responses, and comments authored by them.}
|
||||
other {You are about to delete {count, plural, one {# discussion contribution} other {# discussion contributions}} by this user. This includes all discussion threads, responses, and comments authored by them.}
|
||||
}`,
|
||||
description: 'Description for delete posts confirmation dialog',
|
||||
},
|
||||
deletePostsConfirm: {
|
||||
id: 'discussions.learner.deletePosts.confirm',
|
||||
defaultMessage: 'Delete',
|
||||
description: 'Confirm button text for delete posts',
|
||||
},
|
||||
deletePostConfirmPending: {
|
||||
id: 'discussions.learner.deletePosts.confirm.pending',
|
||||
defaultMessage: 'Deleting',
|
||||
description: 'Pending state of confirm button text for delete posts',
|
||||
},
|
||||
deletePostsBoldDescription: {
|
||||
id: 'discussions.learner.deletePosts.boldDescription',
|
||||
defaultMessage: 'This action cannot be undone.',
|
||||
description: 'Bold disclaimer description for delete confirmation dialog',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -7,8 +7,13 @@ 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';
|
||||
import {
|
||||
deletePostsApiUrl,
|
||||
getUserProfileApiUrl,
|
||||
learnerPostsApiUrl,
|
||||
learnersApiUrl,
|
||||
} from './data/api';
|
||||
import { deleteUserPosts, fetchLearners, fetchUserPosts } from './data/thunks';
|
||||
|
||||
const courseId = 'course-v1:edX+DemoX+Demo_Course';
|
||||
|
||||
@@ -54,9 +59,26 @@ export async function setupPostsMockResponse({
|
||||
return store.getState().threads;
|
||||
}
|
||||
|
||||
export async function setUpPrivilages(axiosMock, store, hasModerationPrivileges) {
|
||||
export async function setupDeleteUserPostsMockResponse({
|
||||
username = 'abc123',
|
||||
courseOrOrg,
|
||||
statusCode = 202,
|
||||
execute,
|
||||
response,
|
||||
} = {}) {
|
||||
const store = initializeStore();
|
||||
const axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
|
||||
axiosMock.onPost(deletePostsApiUrl(courseId, username, courseOrOrg, execute)).reply(statusCode, response);
|
||||
|
||||
await executeThunk(deleteUserPosts(courseId, username, courseOrOrg, execute), store.dispatch, store.getState);
|
||||
return store.getState().learners;
|
||||
}
|
||||
|
||||
export async function setUpPrivilages(axiosMock, store, hasModerationPrivileges, hasBulkDeletePrivileges) {
|
||||
axiosMock.onGet(getDiscussionsConfigUrl(courseId)).reply(200, {
|
||||
hasModerationPrivileges,
|
||||
hasBulkDeletePrivileges,
|
||||
});
|
||||
|
||||
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
|
||||
|
||||
42
src/discussions/learners/utils.js
Normal file
42
src/discussions/learners/utils.js
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { Delete } from '@openedx/paragon/icons';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { ContentActions } from '../../data/constants';
|
||||
import messages from './messages';
|
||||
|
||||
export const LEARNER_ACTIONS_LIST = [
|
||||
{
|
||||
id: 'delete-course-posts',
|
||||
action: ContentActions.DELETE_COURSE_POSTS,
|
||||
icon: Delete,
|
||||
label: messages.deleteCoursePosts,
|
||||
},
|
||||
{
|
||||
id: 'delete-org-posts',
|
||||
action: ContentActions.DELETE_ORG_POSTS,
|
||||
icon: Delete,
|
||||
label: messages.deleteOrgPosts,
|
||||
},
|
||||
];
|
||||
|
||||
export function useLearnerActions(userHasBulkDeletePrivileges = false) {
|
||||
const intl = useIntl();
|
||||
|
||||
const actions = useMemo(() => {
|
||||
if (!userHasBulkDeletePrivileges) {
|
||||
return [];
|
||||
}
|
||||
return LEARNER_ACTIONS_LIST.map(action => ({
|
||||
...action,
|
||||
label: {
|
||||
id: action.label.id,
|
||||
defaultMessage: intl.formatMessage(action.label),
|
||||
},
|
||||
}));
|
||||
}, [userHasBulkDeletePrivileges, intl]);
|
||||
|
||||
return actions;
|
||||
}
|
||||
@@ -208,6 +208,21 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Enroll',
|
||||
description: 'Action button on content page when the user has not logged into the MFE or not enrolled in the course.',
|
||||
},
|
||||
authorAdminDescription: {
|
||||
id: 'discussions.author.admin.description',
|
||||
defaultMessage: 'Part of the team that runs this course',
|
||||
description: 'tooltip for course admins',
|
||||
},
|
||||
authorLearnerTitle: {
|
||||
id: 'discussions.author.learner.title',
|
||||
defaultMessage: 'Learner',
|
||||
description: 'tooltip for course learners title',
|
||||
},
|
||||
authorLearnerDescription: {
|
||||
id: 'discussions.author.learner.description',
|
||||
defaultMessage: 'Taking the course just like you',
|
||||
description: 'tooltip for course learners',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import React, { useRef } from 'react';
|
||||
|
||||
import {
|
||||
act, fireEvent, render, screen, waitFor, within,
|
||||
} from '@testing-library/react';
|
||||
@@ -12,6 +14,8 @@ import { camelCaseObject, initializeMockApp } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
|
||||
import { getCourseMetadataApiUrl } from '../../components/NavigationBar/data/api';
|
||||
import fetchTab from '../../components/NavigationBar/data/thunks';
|
||||
import { getApiBaseUrl, ThreadType } from '../../data/constants';
|
||||
import { initializeStore } from '../../store';
|
||||
import executeThunk from '../../test-utils';
|
||||
@@ -23,6 +27,12 @@ import fetchCourseConfig from '../data/thunks';
|
||||
import DiscussionContent from '../discussions-home/DiscussionContent';
|
||||
import { getThreadsApiUrl } from '../posts/data/api';
|
||||
import { fetchThread, fetchThreads } from '../posts/data/thunks';
|
||||
import MockReCAPTCHA, {
|
||||
mockOnChange,
|
||||
mockOnError,
|
||||
mockOnExpired,
|
||||
mockReset,
|
||||
} from '../posts/post-editor/mocksData/react-google-recaptcha';
|
||||
import fetchCourseTopics from '../topics/data/thunks';
|
||||
import { getDiscussionTourUrl } from '../tours/data/api';
|
||||
import selectTours from '../tours/data/selectors';
|
||||
@@ -35,6 +45,7 @@ import '../posts/data/__factories__';
|
||||
import './data/__factories__';
|
||||
import '../topics/data/__factories__';
|
||||
import '../cohorts/data/__factories__';
|
||||
import '../../components/NavigationBar/data/__factories__';
|
||||
|
||||
const courseConfigApiUrl = getCourseConfigApiUrl();
|
||||
const courseSettingsApiUrl = getCourseSettingsApiUrl();
|
||||
@@ -51,6 +62,8 @@ let testLocation;
|
||||
let container;
|
||||
let unmount;
|
||||
|
||||
jest.mock('react-google-recaptcha', () => MockReCAPTCHA);
|
||||
|
||||
async function mockAxiosReturnPagedComments(threadId, threadType = ThreadType.DISCUSSION, page = 1, count = 2) {
|
||||
axiosMock.onGet(commentsApiUrl).reply(200, Factory.build('commentsResult', { can_delete: true }, {
|
||||
threadId,
|
||||
@@ -93,9 +106,13 @@ async function getThreadAPIResponse(attr = null) {
|
||||
await executeThunk(fetchThread(discussionPostId), store.dispatch, store.getState);
|
||||
}
|
||||
|
||||
async function setupCourseConfig() {
|
||||
async function setupCourseConfig(
|
||||
isEmailVerified = true,
|
||||
onlyVerifiedUsersCanPost = false,
|
||||
hasModerationPrivileges = true,
|
||||
) {
|
||||
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`).reply(200, {
|
||||
has_moderation_privileges: true,
|
||||
hasModerationPrivileges,
|
||||
isPostingEnabled: true,
|
||||
editReasons: [
|
||||
{ code: 'reason-1', label: 'reason 1' },
|
||||
@@ -105,6 +122,8 @@ async function setupCourseConfig() {
|
||||
{ code: 'reason-1', label: 'reason 1' },
|
||||
{ code: 'reason-2', label: 'reason 2' },
|
||||
],
|
||||
isEmailVerified,
|
||||
onlyVerifiedUsersCanPost,
|
||||
});
|
||||
axiosMock.onGet(`${courseSettingsApiUrl}${courseId}/settings`).reply(200, {});
|
||||
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
|
||||
@@ -194,6 +213,7 @@ describe('ThreadView', () => {
|
||||
store = initializeStore();
|
||||
Factory.resetAll();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
axiosMock.onGet(`${getCourseMetadataApiUrl(courseId)}`).reply(200, (Factory.build('navigationBar', 1, { isEnrolled: true })));
|
||||
axiosMock.onGet(threadsApiUrl).reply(200, Factory.build('threadsResult'));
|
||||
axiosMock.onGet(getCohortsApiUrl(courseId)).reply(200, Factory.buildList('cohort', 3));
|
||||
axiosMock.onPatch(new RegExp(`${commentsApiUrl}*`)).reply(({ url, data }) => {
|
||||
@@ -215,9 +235,16 @@ describe('ThreadView', () => {
|
||||
endorsed: false,
|
||||
})];
|
||||
});
|
||||
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`).reply(200, { isPostingEnabled: true });
|
||||
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`).reply(200, {
|
||||
isPostingEnabled: true,
|
||||
captchaSettings: {
|
||||
enabled: true,
|
||||
siteKey: 'test-key',
|
||||
},
|
||||
});
|
||||
window.HTMLElement.prototype.scrollIntoView = jest.fn();
|
||||
|
||||
await executeThunk(fetchTab(courseId, 'outline'), store.dispatch, store.getState);
|
||||
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
|
||||
await executeThunk(fetchCourseCohorts(courseId), store.dispatch, store.getState);
|
||||
await mockAxiosReturnPagedComments(discussionPostId);
|
||||
@@ -279,6 +306,7 @@ describe('ThreadView', () => {
|
||||
});
|
||||
|
||||
it('should show and hide the editor', async () => {
|
||||
await setupCourseConfig();
|
||||
await waitFor(() => renderComponent(discussionPostId));
|
||||
|
||||
const post = screen.getByTestId('post-thread-1');
|
||||
@@ -292,7 +320,55 @@ describe('ThreadView', () => {
|
||||
expect(screen.queryByTestId('tinymce-editor')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should open the confirmation link dialogue box by clicking on add comment button.', async () => {
|
||||
await setupCourseConfig(false, true);
|
||||
await waitFor(() => renderComponent(discussionPostId));
|
||||
|
||||
const comment = await waitFor(() => screen.findByTestId('comment-comment-1'));
|
||||
const hoverCard = within(comment).getByTestId('hover-card-comment-1');
|
||||
await act(async () => { fireEvent.click(within(hoverCard).getByRole('button', { name: /Add comment/i })); });
|
||||
|
||||
expect(screen.queryByText('Send confirmation link')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should open the confirmation link dialogue box by clicking on add response.', async () => {
|
||||
await setupCourseConfig(false, true);
|
||||
await waitFor(() => renderComponent(discussionPostId));
|
||||
|
||||
const post = await screen.findByTestId('post-thread-1');
|
||||
const hoverCard = within(post).getByTestId('hover-card-thread-1');
|
||||
const addResponseButton = within(hoverCard).getByRole('button', { name: /Add response/i });
|
||||
await act(async () => { fireEvent.click(addResponseButton); });
|
||||
|
||||
expect(screen.queryByText('Send confirmation link')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should allow posting a comment with CAPTCHA', async () => {
|
||||
await setupCourseConfig(true, false, false);
|
||||
await waitFor(() => renderComponent(discussionPostId));
|
||||
|
||||
const comment = await waitFor(() => screen.findByTestId('comment-comment-1'));
|
||||
const hoverCard = within(comment).getByTestId('hover-card-comment-1');
|
||||
await act(async () => { fireEvent.click(within(hoverCard).getByRole('button', { name: /Add comment/i })); });
|
||||
await act(async () => { fireEvent.change(screen.getByTestId('tinymce-editor'), { target: { value: 'New comment with CAPTCHA' } }); });
|
||||
await act(async () => { fireEvent.click(screen.getByText('Solve CAPTCHA')); });
|
||||
await act(async () => { fireEvent.click(screen.getByText(/submit/i)); });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(axiosMock.history.post).toHaveLength(1);
|
||||
expect(JSON.parse(axiosMock.history.post[0].data)).toMatchObject({
|
||||
captcha_token: 'mock-token',
|
||||
enable_in_context_sidebar: false,
|
||||
parent_id: 'comment-1',
|
||||
raw_body: 'New comment with CAPTCHA',
|
||||
thread_id: 'thread-1',
|
||||
});
|
||||
expect(mockOnChange).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow posting a comment', async () => {
|
||||
await setupCourseConfig();
|
||||
await waitFor(() => renderComponent(discussionPostId));
|
||||
|
||||
const post = await screen.findByTestId('post-thread-1');
|
||||
@@ -302,7 +378,6 @@ describe('ThreadView', () => {
|
||||
await act(async () => { fireEvent.change(screen.getByTestId('tinymce-editor'), { target: { value: 'testing123' } }); });
|
||||
await act(async () => { fireEvent.click(screen.getByText(/submit/i)); });
|
||||
|
||||
expect(screen.queryByTestId('tinymce-editor')).not.toBeInTheDocument();
|
||||
await waitFor(async () => expect(await screen.findByTestId('comment-1')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
@@ -315,6 +390,7 @@ describe('ThreadView', () => {
|
||||
});
|
||||
|
||||
it('should allow posting a comment', async () => {
|
||||
await setupCourseConfig();
|
||||
await waitFor(() => renderComponent(discussionPostId));
|
||||
|
||||
const comment = await waitFor(() => screen.findByTestId('comment-comment-1'));
|
||||
@@ -323,7 +399,6 @@ describe('ThreadView', () => {
|
||||
await act(async () => { fireEvent.change(screen.getByTestId('tinymce-editor'), { target: { value: 'testing123' } }); });
|
||||
await act(async () => { fireEvent.click(screen.getByText(/submit/i)); });
|
||||
|
||||
expect(screen.queryByTestId('tinymce-editor')).not.toBeInTheDocument();
|
||||
await waitFor(async () => expect(await screen.findByTestId('reply-comment-2')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
@@ -581,6 +656,46 @@ describe('ThreadView', () => {
|
||||
describe('for discussion thread', () => {
|
||||
const findLoadMoreCommentsButton = () => screen.findByTestId('load-more-comments');
|
||||
|
||||
it('renders the mocked ReCAPTCHA.', async () => {
|
||||
await setupCourseConfig(true, false, false);
|
||||
await waitFor(() => renderComponent(discussionPostId));
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.queryByText('Add comment'));
|
||||
});
|
||||
expect(screen.getByTestId('mocked-recaptcha')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('successfully calls onTokenChange when Solve CAPTCHA button is clicked', async () => {
|
||||
await setupCourseConfig(true, false, false);
|
||||
await waitFor(() => renderComponent(discussionPostId));
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.queryByText('Add comment'));
|
||||
});
|
||||
const solveButton = screen.getByText('Solve CAPTCHA');
|
||||
fireEvent.click(solveButton);
|
||||
expect(mockOnChange).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('successfully calls onExpired handler when CAPTCHA expires', async () => {
|
||||
await setupCourseConfig(true, false, false);
|
||||
await waitFor(() => renderComponent(discussionPostId));
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.queryByText('Add comment'));
|
||||
});
|
||||
fireEvent.click(screen.getByText('Expire CAPTCHA'));
|
||||
expect(mockOnExpired).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('successfully calls onError handler when CAPTCHA errors', async () => {
|
||||
await setupCourseConfig(true, false, false);
|
||||
await waitFor(() => renderComponent(discussionPostId));
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.queryByText('Add comment'));
|
||||
});
|
||||
fireEvent.click(screen.getByText('Error CAPTCHA'));
|
||||
expect(mockOnError).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shown post not found when post id does not belong to course', async () => {
|
||||
await waitFor(() => renderComponent('unloaded-id'));
|
||||
expect(await screen.findByText('Thread not found', { exact: true }))
|
||||
@@ -672,6 +787,7 @@ describe('ThreadView', () => {
|
||||
});
|
||||
|
||||
it('successfully added comment in the draft.', async () => {
|
||||
await setupCourseConfig();
|
||||
await waitFor(() => renderComponent(discussionPostId));
|
||||
|
||||
await act(async () => {
|
||||
@@ -731,6 +847,7 @@ describe('ThreadView', () => {
|
||||
});
|
||||
|
||||
it('successfully removed comment from the draft.', async () => {
|
||||
await setupCourseConfig();
|
||||
await waitFor(() => renderComponent(discussionPostId));
|
||||
|
||||
await act(async () => {
|
||||
@@ -749,10 +866,11 @@ describe('ThreadView', () => {
|
||||
fireEvent.click(screen.queryAllByText('Add comment')[0]);
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId('tinymce-editor').value).toBe('');
|
||||
expect(screen.queryByTestId('tinymce-editor').value).not.toBe('Draft comment 123!');
|
||||
});
|
||||
|
||||
it('successfully added response in the draft.', async () => {
|
||||
await setupCourseConfig();
|
||||
await waitFor(() => renderComponent(discussionPostId));
|
||||
|
||||
await act(async () => {
|
||||
@@ -775,6 +893,7 @@ describe('ThreadView', () => {
|
||||
});
|
||||
|
||||
it('successfully removed response from the draft.', async () => {
|
||||
await setupCourseConfig();
|
||||
await waitFor(() => renderComponent(discussionPostId));
|
||||
|
||||
await act(async () => {
|
||||
@@ -793,10 +912,11 @@ describe('ThreadView', () => {
|
||||
fireEvent.click(screen.queryByText('Add response'));
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId('tinymce-editor').value).toBe('');
|
||||
expect(screen.queryByTestId('tinymce-editor').value).not.toBe('Draft Response!');
|
||||
});
|
||||
|
||||
it('successfully maintain response for the specific post in the draft.', async () => {
|
||||
await setupCourseConfig();
|
||||
await waitFor(() => renderComponent(discussionPostId));
|
||||
|
||||
await act(async () => {
|
||||
@@ -974,4 +1094,73 @@ describe('ThreadView', () => {
|
||||
expect(responseSortTour().enabled).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
it('should open the confirmation link dialogue box on add response button.', async () => {
|
||||
await setupCourseConfig(false, true);
|
||||
await waitFor(() => renderComponent(discussionPostId));
|
||||
|
||||
const addResponseButton = screen.getByTestId('add-response');
|
||||
|
||||
await act(async () => { fireEvent.click(addResponseButton); });
|
||||
|
||||
expect(screen.queryByText('Send confirmation link')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MockReCAPTCHA', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('uses defaultProps when props are not provided', () => {
|
||||
render(<MockReCAPTCHA />);
|
||||
|
||||
expect(screen.getByTestId('mocked-recaptcha')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText('Solve CAPTCHA'));
|
||||
fireEvent.click(screen.getByText('Expire CAPTCHA'));
|
||||
fireEvent.click(screen.getByText('Error CAPTCHA'));
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalled();
|
||||
expect(mockOnExpired).toHaveBeenCalled();
|
||||
expect(mockOnError).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('triggers all callbacks and exposes reset via ref', () => {
|
||||
const onChange = jest.fn();
|
||||
const onExpired = jest.fn();
|
||||
const onError = jest.fn();
|
||||
|
||||
const Wrapper = () => {
|
||||
const recaptchaRef = useRef(null);
|
||||
return (
|
||||
<div>
|
||||
<MockReCAPTCHA
|
||||
ref={recaptchaRef}
|
||||
onChange={onChange}
|
||||
onExpired={onExpired}
|
||||
onError={onError}
|
||||
/>
|
||||
<button onClick={() => recaptchaRef.current.reset()} data-testid="reset-btn" type="button">Reset</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const { getByText, getByTestId } = render(<Wrapper />);
|
||||
|
||||
fireEvent.click(getByText('Solve CAPTCHA'));
|
||||
fireEvent.click(getByText('Expire CAPTCHA'));
|
||||
fireEvent.click(getByText('Error CAPTCHA'));
|
||||
|
||||
fireEvent.click(getByTestId('reset-btn'));
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalled();
|
||||
expect(mockOnExpired).toHaveBeenCalled();
|
||||
expect(mockOnError).toHaveBeenCalled();
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('mock-token');
|
||||
expect(onExpired).toHaveBeenCalled();
|
||||
expect(onError).toHaveBeenCalled();
|
||||
expect(mockReset).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,22 +2,26 @@ import React, { useCallback, useContext, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Button, Spinner } from '@openedx/paragon';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { ThreadType } from '../../../data/constants';
|
||||
import withEmailConfirmation from '../../common/withEmailConfirmation';
|
||||
import { useUserPostingEnabled } from '../../data/hooks';
|
||||
import { selectShouldShowEmailConfirmation } from '../../data/selectors';
|
||||
import { isLastElementOfList } from '../../utils';
|
||||
import { usePostComments } from '../data/hooks';
|
||||
import messages from '../messages';
|
||||
import PostCommentsContext from '../postCommentsContext';
|
||||
import { Comment, ResponseEditor } from './comment';
|
||||
|
||||
const CommentsView = ({ threadType }) => {
|
||||
const CommentsView = ({ threadType, openEmailConfirmation }) => {
|
||||
const intl = useIntl();
|
||||
const [addingResponse, setAddingResponse] = useState(false);
|
||||
const { isClosed } = useContext(PostCommentsContext);
|
||||
const isUserPrivilegedInPostingRestriction = useUserPostingEnabled();
|
||||
const shouldShowEmailConfirmation = useSelector(selectShouldShowEmailConfirmation);
|
||||
|
||||
const {
|
||||
endorsedCommentsIds,
|
||||
@@ -28,8 +32,8 @@ const CommentsView = ({ threadType }) => {
|
||||
} = usePostComments(threadType);
|
||||
|
||||
const handleAddResponse = useCallback(() => {
|
||||
setAddingResponse(true);
|
||||
}, []);
|
||||
if (shouldShowEmailConfirmation) { openEmailConfirmation(); } else { setAddingResponse(true); }
|
||||
}, [shouldShowEmailConfirmation, openEmailConfirmation]);
|
||||
|
||||
const handleCloseResponseEditor = useCallback(() => {
|
||||
setAddingResponse(false);
|
||||
@@ -92,7 +96,7 @@ const CommentsView = ({ threadType }) => {
|
||||
variant="plain"
|
||||
block="true"
|
||||
className="card mb-4 px-0 border-0 py-10px mt-2 font-style font-weight-500
|
||||
line-height-24 text-primary-500"
|
||||
line-height-24 text-primary-500 bg-white"
|
||||
onClick={handleAddResponse}
|
||||
data-testid="add-response"
|
||||
>
|
||||
@@ -115,6 +119,7 @@ CommentsView.propTypes = {
|
||||
threadType: PropTypes.oneOf([
|
||||
ThreadType.DISCUSSION, ThreadType.QUESTION,
|
||||
]).isRequired,
|
||||
openEmailConfirmation: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default React.memo(CommentsView);
|
||||
export default React.memo(withEmailConfirmation(CommentsView));
|
||||
|
||||
@@ -14,8 +14,10 @@ import { ContentActions, EndorsementStatus } from '../../../../data/constants';
|
||||
import { AlertBanner, Confirmation, EndorsedAlertBanner } from '../../../common';
|
||||
import DiscussionContext from '../../../common/context';
|
||||
import HoverCard from '../../../common/HoverCard';
|
||||
import withEmailConfirmation from '../../../common/withEmailConfirmation';
|
||||
import { ContentTypes } from '../../../data/constants';
|
||||
import { useUserPostingEnabled } from '../../../data/hooks';
|
||||
import { selectShouldShowEmailConfirmation } from '../../../data/selectors';
|
||||
import { fetchThread } from '../../../posts/data/thunks';
|
||||
import LikeButton from '../../../posts/post/LikeButton';
|
||||
import { useActions } from '../../../utils';
|
||||
@@ -38,6 +40,7 @@ const Comment = ({
|
||||
commentId,
|
||||
marginBottom,
|
||||
showFullThread = true,
|
||||
openEmailConfirmation,
|
||||
}) => {
|
||||
const comment = useSelector(selectCommentOrResponseById(commentId));
|
||||
const {
|
||||
@@ -62,6 +65,7 @@ const Comment = ({
|
||||
const sortedOrder = useSelector(selectCommentSortOrder);
|
||||
const actions = useActions(ContentTypes.COMMENT, id);
|
||||
const isUserPrivilegedInPostingRestriction = useUserPostingEnabled();
|
||||
const shouldShowEmailConfirmation = useSelector(selectShouldShowEmailConfirmation);
|
||||
|
||||
useEffect(() => {
|
||||
// If the comment has a parent comment, it won't have any children, so don't fetch them.
|
||||
@@ -179,7 +183,7 @@ const Comment = ({
|
||||
id={id}
|
||||
contentType={ContentTypes.COMMENT}
|
||||
actionHandlers={actionHandlers}
|
||||
handleResponseCommentButton={handleAddCommentButton}
|
||||
handleResponseCommentButton={shouldShowEmailConfirmation ? openEmailConfirmation : handleAddCommentButton}
|
||||
addResponseCommentButtonMessage={intl.formatMessage(messages.addComment)}
|
||||
onLike={handleCommentLike}
|
||||
voted={voted}
|
||||
@@ -270,7 +274,7 @@ const Comment = ({
|
||||
className="d-flex flex-grow mt-2 font-style font-weight-500 text-primary-500 add-comment-btn rounded-0"
|
||||
variant="plain"
|
||||
style={{ height: '36px' }}
|
||||
onClick={handleAddCommentReply}
|
||||
onClick={shouldShowEmailConfirmation ? openEmailConfirmation : handleAddCommentReply}
|
||||
>
|
||||
{intl.formatMessage(messages.addComment)}
|
||||
</Button>
|
||||
@@ -287,6 +291,7 @@ Comment.propTypes = {
|
||||
commentId: PropTypes.string.isRequired,
|
||||
marginBottom: PropTypes.bool,
|
||||
showFullThread: PropTypes.bool,
|
||||
openEmailConfirmation: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
Comment.defaultProps = {
|
||||
@@ -294,4 +299,4 @@ Comment.defaultProps = {
|
||||
showFullThread: true,
|
||||
};
|
||||
|
||||
export default React.memo(Comment);
|
||||
export default React.memo(withEmailConfirmation(Comment));
|
||||
|
||||
@@ -5,6 +5,7 @@ import PropTypes from 'prop-types';
|
||||
|
||||
import { Button, Form, StatefulButton } from '@openedx/paragon';
|
||||
import { Formik } from 'formik';
|
||||
import ReCAPTCHA from 'react-google-recaptcha';
|
||||
import { useSelector } from 'react-redux';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
@@ -17,6 +18,8 @@ import PostPreviewPanel from '../../../../components/PostPreviewPanel';
|
||||
import useDispatchWithState from '../../../../data/hooks';
|
||||
import DiscussionContext from '../../../common/context';
|
||||
import {
|
||||
selectCaptchaSettings,
|
||||
selectIsUserLearner,
|
||||
selectModerationSettings,
|
||||
selectUserHasModerationPrivileges,
|
||||
selectUserIsGroupTa,
|
||||
@@ -40,6 +43,7 @@ const CommentEditor = ({
|
||||
const intl = useIntl();
|
||||
const editorRef = useRef(null);
|
||||
const formRef = useRef(null);
|
||||
const recaptchaRef = useRef(null);
|
||||
const { authenticatedUser } = useContext(AppContext);
|
||||
const { enableInContextSidebar } = useContext(DiscussionContext);
|
||||
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
|
||||
@@ -49,6 +53,14 @@ const CommentEditor = ({
|
||||
const [submitting, dispatch] = useDispatchWithState();
|
||||
const [editorContent, setEditorContent] = useState();
|
||||
const { addDraftContent, getDraftContent, removeDraftContent } = useDraftContent();
|
||||
const captchaSettings = useSelector(selectCaptchaSettings);
|
||||
const isUserLearner = useSelector(selectIsUserLearner);
|
||||
|
||||
const shouldRequireCaptcha = !id && captchaSettings.enabled && isUserLearner;
|
||||
|
||||
const captchaValidation = {
|
||||
recaptchaToken: Yup.string().required(intl.formatMessage(messages.captchaVerificationLabel)),
|
||||
};
|
||||
|
||||
const canDisplayEditReason = (edit
|
||||
&& (userHasModerationPrivileges || userIsGroupTa || userIsStaff)
|
||||
@@ -62,16 +74,31 @@ const CommentEditor = ({
|
||||
const validationSchema = Yup.object().shape({
|
||||
comment: Yup.string()
|
||||
.required(),
|
||||
...(shouldRequireCaptcha ? { recaptchaToken: Yup.string().required() } : { }),
|
||||
...editReasonCodeValidation,
|
||||
...(shouldRequireCaptcha ? captchaValidation : {}),
|
||||
});
|
||||
|
||||
const initialValues = {
|
||||
comment: editorContent,
|
||||
editReasonCode: lastEdit?.reasonCode || (userIsStaff && canDisplayEditReason ? 'violates-guidelines' : undefined),
|
||||
recaptchaToken: '',
|
||||
};
|
||||
|
||||
const handleCaptchaChange = useCallback((token, setFieldValue) => {
|
||||
setFieldValue('recaptchaToken', token || '');
|
||||
}, []);
|
||||
|
||||
const handleCaptchaExpired = useCallback((setFieldValue) => {
|
||||
setFieldValue('recaptchaToken', '');
|
||||
}, []);
|
||||
|
||||
const handleCloseEditor = useCallback((resetForm) => {
|
||||
resetForm({ values: initialValues });
|
||||
// Reset CAPTCHA when hiding editor
|
||||
if (recaptchaRef.current) {
|
||||
recaptchaRef.current.reset();
|
||||
}
|
||||
onCloseEditor();
|
||||
}, [onCloseEditor, initialValues]);
|
||||
|
||||
@@ -92,7 +119,7 @@ const CommentEditor = ({
|
||||
};
|
||||
await dispatch(editComment(id, payload));
|
||||
} else {
|
||||
await dispatch(addComment(values.comment, threadId, parentId, enableInContextSidebar));
|
||||
await dispatch(addComment(values.comment, threadId, parentId, enableInContextSidebar, shouldRequireCaptcha ? values.recaptchaToken : ''));
|
||||
}
|
||||
/* istanbul ignore if: TinyMCE is mocked so this cannot be easily tested */
|
||||
if (editorRef.current) {
|
||||
@@ -100,7 +127,7 @@ const CommentEditor = ({
|
||||
}
|
||||
handleCloseEditor(resetForm);
|
||||
deleteEditorContent();
|
||||
}, [id, threadId, parentId, enableInContextSidebar, handleCloseEditor]);
|
||||
}, [id, threadId, parentId, enableInContextSidebar, handleCloseEditor, shouldRequireCaptcha]);
|
||||
// The editorId is used to autosave contents to localstorage. This format means that the autosave is scoped to
|
||||
// the current comment id, or the current comment parent or the curren thread.
|
||||
const editorId = `comment-editor-${id || parentId || threadId}`;
|
||||
@@ -147,6 +174,7 @@ const CommentEditor = ({
|
||||
handleBlur,
|
||||
handleChange,
|
||||
resetForm,
|
||||
setFieldValue,
|
||||
}) => (
|
||||
<Form onSubmit={handleSubmit} className={formClasses} ref={formRef}>
|
||||
{canDisplayEditReason && (
|
||||
@@ -202,6 +230,32 @@ const CommentEditor = ({
|
||||
</Form.Control.Feedback>
|
||||
)}
|
||||
<PostPreviewPanel htmlNode={values.comment} />
|
||||
{/* CAPTCHA Section - Only show for new posts from non-staff users */}
|
||||
{ shouldRequireCaptcha && captchaSettings.siteKey && (
|
||||
<div className="mb-3">
|
||||
<Form.Group
|
||||
isInvalid={isFormikFieldInvalid('recaptchaToken', {
|
||||
errors,
|
||||
touched,
|
||||
})}
|
||||
>
|
||||
<Form.Label className="h6">
|
||||
{intl.formatMessage(messages.verifyHumanLabel)}
|
||||
</Form.Label>
|
||||
<div className="d-flex justify-content-start">
|
||||
<ReCAPTCHA
|
||||
ref={recaptchaRef}
|
||||
sitekey={captchaSettings.siteKey}
|
||||
onChange={(token) => handleCaptchaChange(token, setFieldValue)}
|
||||
onExpired={() => handleCaptchaExpired(setFieldValue)}
|
||||
onError={() => handleCaptchaExpired(setFieldValue)}
|
||||
/>
|
||||
</div>
|
||||
<FormikErrorFeedback name="recaptchaToken" />
|
||||
</Form.Group>
|
||||
</div>
|
||||
) }
|
||||
|
||||
<div className="d-flex py-2 justify-content-end">
|
||||
<Button
|
||||
variant="outline-primary"
|
||||
|
||||
@@ -3,10 +3,12 @@ import PropTypes from 'prop-types';
|
||||
|
||||
import { Avatar } from '@openedx/paragon';
|
||||
import classNames from 'classnames';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { AvatarOutlineAndLabelColors } from '../../../../data/constants';
|
||||
import { AuthorLabel } from '../../../common';
|
||||
import { useAlertBannerVisible } from '../../../data/hooks';
|
||||
import { selectAuthorAvatar } from '../../../posts/data/selectors';
|
||||
|
||||
const CommentHeader = ({
|
||||
author,
|
||||
@@ -23,6 +25,7 @@ const CommentHeader = ({
|
||||
lastEdit,
|
||||
closed,
|
||||
});
|
||||
const authorAvatar = useSelector(selectAuthorAvatar(author));
|
||||
|
||||
return (
|
||||
<div className={classNames('d-flex flex-row justify-content-between', {
|
||||
@@ -33,6 +36,7 @@ const CommentHeader = ({
|
||||
<Avatar
|
||||
className={`border-0 ml-0.5 mr-2.5 ${colorClass ? `outline-${colorClass}` : 'outline-anonymous'}`}
|
||||
alt={author}
|
||||
src={authorAvatar?.imageUrlSmall}
|
||||
style={{
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import timeLocale from '../../../common/time-locale';
|
||||
import { ContentTypes } from '../../../data/constants';
|
||||
import { useAlertBannerVisible } from '../../../data/hooks';
|
||||
import { selectAuthorAvatar } from '../../../posts/data/selectors';
|
||||
import { selectCommentOrResponseById } from '../../data/selectors';
|
||||
import { editComment, removeComment } from '../../data/thunks';
|
||||
import messages from '../../messages';
|
||||
@@ -38,6 +39,7 @@ const Reply = ({ responseId }) => {
|
||||
lastEdit,
|
||||
closed,
|
||||
});
|
||||
const authorAvatar = useSelector(selectAuthorAvatar(author));
|
||||
|
||||
const handleDeleteConfirmation = useCallback(() => {
|
||||
dispatch(removeComment(id));
|
||||
@@ -121,6 +123,7 @@ const Reply = ({ responseId }) => {
|
||||
<Avatar
|
||||
className={`ml-0.5 mt-0.5 border-0 ${colorClass ? `outline-${colorClass}` : 'outline-anonymous'}`}
|
||||
alt={author}
|
||||
src={authorAvatar?.imageUrlSmall}
|
||||
style={{
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
@@ -129,7 +132,7 @@ const Reply = ({ responseId }) => {
|
||||
</div>
|
||||
<div
|
||||
className="bg-light-300 pl-4 pt-2.5 pr-2.5 pb-10px flex-fill"
|
||||
style={{ borderRadius: '0rem 0.375rem 0.375rem' }}
|
||||
style={{ borderRadius: '0rem 0.375rem 0.375rem', maxWidth: 'calc(100% - 50px)' }}
|
||||
>
|
||||
<div className="d-flex flex-row justify-content-between">
|
||||
<AuthorLabel
|
||||
|
||||
@@ -73,10 +73,16 @@ export const getCommentResponses = async (commentId, {
|
||||
* @param {boolean} enableInContextSidebar
|
||||
* @returns {Promise<{}>}
|
||||
*/
|
||||
export const postComment = async (comment, threadId, parentId = null, enableInContextSidebar = false) => {
|
||||
export const postComment = async (
|
||||
comment,
|
||||
threadId,
|
||||
parentId,
|
||||
enableInContextSidebar,
|
||||
recaptchaToken,
|
||||
) => {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(getCommentsApiUrl(), snakeCaseObject({
|
||||
threadId, raw_body: comment, parentId, enableInContextSidebar,
|
||||
threadId, raw_body: comment, parentId, enableInContextSidebar, captchaToken: recaptchaToken,
|
||||
}));
|
||||
return data;
|
||||
};
|
||||
|
||||
@@ -39,7 +39,7 @@ describe('Post comments view api tests', () => {
|
||||
axiosMock.reset();
|
||||
});
|
||||
|
||||
test('successfully get thread comments', async () => {
|
||||
it('successfully get thread comments', async () => {
|
||||
axiosMock.onGet(commentsApiUrl).reply(200, Factory.build('commentsResult'));
|
||||
await executeThunk(fetchThreadComments(threadId, { endorsed: 'discussion' }), store.dispatch, store.getState);
|
||||
|
||||
@@ -92,22 +92,25 @@ describe('Post comments view api tests', () => {
|
||||
thread_id: threadId,
|
||||
raw_body: content,
|
||||
rendered_body: content,
|
||||
parent_id: 'parent_id',
|
||||
enable_in_context_sidebar: true,
|
||||
captcha_token: 'recaptcha-token',
|
||||
}));
|
||||
await executeThunk(addComment(content, threadId, null), store.dispatch, store.getState);
|
||||
await executeThunk(addComment(content, threadId, 'parent_id', true, 'recaptcha-token'), 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);
|
||||
await executeThunk(addComment(content, threadId, 'parent_id', false, 'recaptcha-token'), 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);
|
||||
await executeThunk(addComment(content, threadId, 'parent_id', false, 'recaptcha-token'), store.dispatch, store.getState);
|
||||
|
||||
expect(store.getState().comments.postStatus).toEqual('denied');
|
||||
});
|
||||
@@ -164,4 +167,76 @@ describe('Post comments view api tests', () => {
|
||||
|
||||
expect(store.getState().comments.postStatus).toEqual('denied');
|
||||
});
|
||||
|
||||
it('successfully added comment with default parentId', 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,
|
||||
parent_id: null, // Explicitly expect null in response
|
||||
}));
|
||||
await executeThunk(addComment(content, threadId, null, false, ''), store.dispatch, store.getState);
|
||||
|
||||
expect(store.getState().comments.postStatus).toEqual('successful');
|
||||
});
|
||||
|
||||
it('successfully added comment with explicit enableInContextSidebar false', 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, false, 'recaptcha-token'), store.dispatch, store.getState);
|
||||
|
||||
expect(store.getState().comments.postStatus).toEqual('successful');
|
||||
});
|
||||
|
||||
it('successfully added comment with empty recaptchaToken', 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, true, ''), store.dispatch, store.getState);
|
||||
|
||||
expect(store.getState().comments.postStatus).toEqual('successful');
|
||||
});
|
||||
|
||||
it('successfully added comment with undefined parentId', 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,
|
||||
parent_id: null, // Expect null as the function should handle undefined as null
|
||||
}));
|
||||
await executeThunk(addComment(content, threadId, undefined, false, ''), store.dispatch, store.getState);
|
||||
|
||||
expect(store.getState().comments.postStatus).toEqual('successful');
|
||||
});
|
||||
|
||||
it('successfully added comment with null recaptchaToken', 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, false, null), store.dispatch, store.getState);
|
||||
|
||||
expect(store.getState().comments.postStatus).toEqual('successful');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -141,15 +141,16 @@ export function editComment(commentId, comment) {
|
||||
};
|
||||
}
|
||||
|
||||
export function addComment(comment, threadId, parentId = null, enableInContextSidebar = false) {
|
||||
export function addComment(comment, threadId, parentId = null, enableInContextSidebar = false, recaptchaToken = '') {
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
dispatch(postCommentRequest({
|
||||
comment,
|
||||
threadId,
|
||||
parentId,
|
||||
recaptchaToken,
|
||||
}));
|
||||
const data = await postComment(comment, threadId, parentId, enableInContextSidebar);
|
||||
const data = await postComment(comment, threadId, parentId, enableInContextSidebar, recaptchaToken);
|
||||
dispatch(postCommentSuccess(camelCaseObject(data)));
|
||||
} catch (error) {
|
||||
if (getHttpErrorStatus(error) === 403) {
|
||||
|
||||
@@ -221,6 +221,16 @@ const messages = defineMessages({
|
||||
}`,
|
||||
description: 'sort message showing current sorting',
|
||||
},
|
||||
verifyHumanLabel: {
|
||||
id: 'discussions.verify.human.label',
|
||||
defaultMessage: 'Verify you are human',
|
||||
description: 'Verify you are human description.',
|
||||
},
|
||||
captchaVerificationLabel: {
|
||||
id: 'discussions.captcha.verification.label',
|
||||
defaultMessage: 'Please complete the CAPTCHA verification',
|
||||
description: 'Please complete the CAPTCHA to continue.',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -84,6 +84,8 @@ export const getThread = async (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 notifyAllLearners
|
||||
* @param recaptchaToken
|
||||
* @param {boolean} enableInContextSidebar
|
||||
* @returns {Promise<{}>}
|
||||
*/
|
||||
@@ -98,6 +100,8 @@ export const postThread = async (
|
||||
cohort,
|
||||
anonymous,
|
||||
anonymousToPeers,
|
||||
notifyAllLearners,
|
||||
recaptchaToken,
|
||||
} = {},
|
||||
enableInContextSidebar = false,
|
||||
) => {
|
||||
@@ -112,6 +116,8 @@ export const postThread = async (
|
||||
anonymousToPeers,
|
||||
groupId: cohort,
|
||||
enableInContextSidebar,
|
||||
notifyAllLearners,
|
||||
captchaToken: recaptchaToken,
|
||||
});
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(getThreadsApiUrl(), postData);
|
||||
@@ -198,3 +204,13 @@ export const uploadFile = async (blob, filename, courseId, threadKey) => {
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Post send Account Activation Email.
|
||||
*/
|
||||
export const sendEmailForAccountActivation = async () => {
|
||||
const url = `${getConfig().LMS_BASE_URL}/api/send_account_activation_email`;
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(url);
|
||||
return data;
|
||||
};
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { initializeMockApp } from '@edx/frontend-platform/testing';
|
||||
|
||||
import { initializeStore } from '../../../store';
|
||||
import executeThunk from '../../../test-utils';
|
||||
import { getCoursesApiUrl, uploadFile } from './api';
|
||||
import { sendAccountActivationEmail } from './thunks';
|
||||
|
||||
const courseId = 'course-v1:edX+TestX+Test_Course';
|
||||
const coursesApiUrl = getCoursesApiUrl();
|
||||
|
||||
let axiosMock = null;
|
||||
let store;
|
||||
|
||||
describe('Threads/Posts api tests', () => {
|
||||
beforeEach(() => {
|
||||
@@ -21,6 +26,7 @@ describe('Threads/Posts api tests', () => {
|
||||
},
|
||||
});
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
store = initializeStore();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -33,4 +39,31 @@ describe('Threads/Posts api tests', () => {
|
||||
const response = await uploadFile(new Blob(['sample data']), 'sample_file.jpg', courseId, 'root');
|
||||
expect(response.location).toEqual('http://test/file.jpg');
|
||||
});
|
||||
|
||||
test('successfully send email for account activation', async () => {
|
||||
axiosMock.onPost(`${getConfig().LMS_BASE_URL}/api/send_account_activation_email`)
|
||||
.reply(200, { success: true });
|
||||
|
||||
await executeThunk(sendAccountActivationEmail(), store.dispatch, store.getState);
|
||||
|
||||
expect(store.getState().threads.confirmEmailStatus).toEqual('successful');
|
||||
});
|
||||
|
||||
test('fails to send email for account activation (server error)', async () => {
|
||||
axiosMock.onPost(`${getConfig().LMS_BASE_URL}/api/send_account_activation_email`)
|
||||
.reply(500);
|
||||
|
||||
await executeThunk(sendAccountActivationEmail(), store.dispatch, store.getState);
|
||||
|
||||
expect(store.getState().threads.confirmEmailStatus).toEqual('failed');
|
||||
});
|
||||
|
||||
test('denied sending email for account activation (unauthorized)', async () => {
|
||||
axiosMock.onPost(`${getConfig().LMS_BASE_URL}/api/send_account_activation_email`)
|
||||
.reply(403);
|
||||
|
||||
await executeThunk(sendAccountActivationEmail(), store.dispatch, store.getState);
|
||||
|
||||
expect(store.getState().threads.confirmEmailStatus).toEqual('denied');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,9 +11,9 @@ const usePostList = (ids) => {
|
||||
|
||||
const sortedIds = useMemo(() => {
|
||||
posts.forEach((post) => {
|
||||
if (post.pinned) {
|
||||
if (post && post.pinned) {
|
||||
pinnedPostsIds.push(post.id);
|
||||
} else {
|
||||
} else if (post) {
|
||||
unpinnedPostsIds.push(post.id);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import camelCase from 'lodash/camelCase';
|
||||
|
||||
const selectThreads = state => state.threads.threadsById;
|
||||
|
||||
@@ -56,3 +57,7 @@ export const selectThreadSorting = () => state => state.threads.sortedBy;
|
||||
export const selectThreadFilters = () => state => state.threads.filters;
|
||||
|
||||
export const selectThreadNextPage = () => state => state.threads.nextPage;
|
||||
|
||||
export const selectAuthorAvatar = author => state => (
|
||||
state.threads.avatars?.[camelCase(author)]?.profile.image
|
||||
);
|
||||
|
||||
@@ -54,6 +54,7 @@ const threadsSlice = createSlice({
|
||||
postEditorVisible: false,
|
||||
redirectToThread: null,
|
||||
sortedBy: ThreadOrdering.BY_LAST_ACTIVITY,
|
||||
confirmEmailStatus: RequestStatus.IDLE,
|
||||
},
|
||||
reducers: {
|
||||
fetchLearnerThreadsRequest: (state, { payload }) => (
|
||||
@@ -376,6 +377,28 @@ const threadsSlice = createSlice({
|
||||
pages: [],
|
||||
}
|
||||
),
|
||||
sendAccountActivationEmailRequest: (state) => (
|
||||
{
|
||||
...state,
|
||||
confirmEmailStatus: RequestStatus.IN_PROGRESS,
|
||||
}
|
||||
),
|
||||
sendAccountActivationEmailSuccess: (state) => ({
|
||||
...state,
|
||||
confirmEmailStatus: RequestStatus.SUCCESSFUL,
|
||||
}),
|
||||
sendAccountActivationEmailFailed: (state) => (
|
||||
{
|
||||
...state,
|
||||
confirmEmailStatus: RequestStatus.FAILED,
|
||||
}
|
||||
),
|
||||
sendAccountActivationEmailDenied: (state) => (
|
||||
{
|
||||
...state,
|
||||
confirmEmailStatus: RequestStatus.DENIED,
|
||||
}
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -414,6 +437,10 @@ export const {
|
||||
clearPostsPages,
|
||||
clearFilter,
|
||||
clearSort,
|
||||
sendAccountActivationEmailDenied,
|
||||
sendAccountActivationEmailFailed,
|
||||
sendAccountActivationEmailRequest,
|
||||
sendAccountActivationEmailSuccess,
|
||||
} = threadsSlice.actions;
|
||||
|
||||
export const threadsReducer = threadsSlice.reducer;
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
} from '../../../data/constants';
|
||||
import { getHttpErrorStatus } from '../../utils';
|
||||
import {
|
||||
deleteThread, getThread, getThreads, postThread, updateThread,
|
||||
deleteThread, getThread, getThreads, postThread, sendEmailForAccountActivation, updateThread,
|
||||
} from './api';
|
||||
import {
|
||||
deleteThreadDenied,
|
||||
@@ -26,6 +26,10 @@ import {
|
||||
postThreadFailed,
|
||||
postThreadRequest,
|
||||
postThreadSuccess,
|
||||
sendAccountActivationEmailDenied,
|
||||
sendAccountActivationEmailFailed,
|
||||
sendAccountActivationEmailRequest,
|
||||
sendAccountActivationEmailSuccess,
|
||||
updateThreadAsRead,
|
||||
updateThreadDenied,
|
||||
updateThreadFailed,
|
||||
@@ -204,6 +208,8 @@ export function createNewThread({
|
||||
anonymousToPeers,
|
||||
cohort,
|
||||
enableInContextSidebar,
|
||||
notifyAllLearners,
|
||||
recaptchaToken,
|
||||
}) {
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
@@ -217,12 +223,16 @@ export function createNewThread({
|
||||
anonymous,
|
||||
anonymousToPeers,
|
||||
cohort,
|
||||
notifyAllLearners,
|
||||
recaptchaToken,
|
||||
}));
|
||||
const data = await postThread(courseId, topicId, type, title, content, {
|
||||
cohort,
|
||||
following,
|
||||
anonymous,
|
||||
anonymousToPeers,
|
||||
notifyAllLearners,
|
||||
recaptchaToken,
|
||||
}, enableInContextSidebar);
|
||||
dispatch(postThreadSuccess(camelCaseObject(data)));
|
||||
} catch (error) {
|
||||
@@ -298,3 +308,20 @@ export function removeThread(threadId) {
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function sendAccountActivationEmail() {
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
dispatch(sendAccountActivationEmailRequest());
|
||||
const data = await sendEmailForAccountActivation();
|
||||
dispatch(sendAccountActivationEmailSuccess(camelCaseObject(data)));
|
||||
} catch (error) {
|
||||
if (getHttpErrorStatus(error) === 403) {
|
||||
dispatch(sendAccountActivationEmailDenied());
|
||||
} else {
|
||||
dispatch(sendAccountActivationEmailFailed());
|
||||
}
|
||||
logError(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useCallback, useContext } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import {
|
||||
Button, Icon, IconButton,
|
||||
@@ -12,8 +13,13 @@ import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import Search from '../../../components/Search';
|
||||
import { RequestStatus } from '../../../data/constants';
|
||||
import DiscussionContext from '../../common/context';
|
||||
import withEmailConfirmation from '../../common/withEmailConfirmation';
|
||||
import { useUserPostingEnabled } from '../../data/hooks';
|
||||
import { selectConfigLoadingStatus, selectEnableInContext } from '../../data/selectors';
|
||||
import {
|
||||
selectConfigLoadingStatus,
|
||||
selectEnableInContext,
|
||||
selectShouldShowEmailConfirmation,
|
||||
} from '../../data/selectors';
|
||||
import { TopicSearchBar as IncontextSearch } from '../../in-context-topics/topic-search';
|
||||
import { postMessageToParent } from '../../utils';
|
||||
import { showPostEditor } from '../data';
|
||||
@@ -21,11 +27,12 @@ import messages from './messages';
|
||||
|
||||
import './actionBar.scss';
|
||||
|
||||
const PostActionsBar = () => {
|
||||
const PostActionsBar = ({ openEmailConfirmation }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
const loadingStatus = useSelector(selectConfigLoadingStatus);
|
||||
const enableInContext = useSelector(selectEnableInContext);
|
||||
const shouldShowEmailConfirmation = useSelector(selectShouldShowEmailConfirmation);
|
||||
const isUserPrivilegedInPostingRestriction = useUserPostingEnabled();
|
||||
const { enableInContextSidebar, page } = useContext(DiscussionContext);
|
||||
|
||||
@@ -34,8 +41,8 @@ const PostActionsBar = () => {
|
||||
}, []);
|
||||
|
||||
const handleAddPost = useCallback(() => {
|
||||
dispatch(showPostEditor());
|
||||
}, []);
|
||||
if (shouldShowEmailConfirmation) { openEmailConfirmation(); } else { dispatch(showPostEditor()); }
|
||||
}, [shouldShowEmailConfirmation, openEmailConfirmation]);
|
||||
|
||||
return (
|
||||
<div className={classNames('d-flex justify-content-end flex-grow-1', { 'py-1': !enableInContextSidebar })}>
|
||||
@@ -55,7 +62,7 @@ const PostActionsBar = () => {
|
||||
<Button
|
||||
variant={enableInContextSidebar ? 'plain' : 'brand'}
|
||||
className={classNames(
|
||||
'my-0 font-style border-0 line-height-24',
|
||||
'my-0 font-style line-height-24',
|
||||
{ 'px-3 py-10px border-0': enableInContextSidebar },
|
||||
)}
|
||||
onClick={handleAddPost}
|
||||
@@ -83,4 +90,8 @@ const PostActionsBar = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default PostActionsBar;
|
||||
PostActionsBar.propTypes = {
|
||||
openEmailConfirmation: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default React.memo(withEmailConfirmation(PostActionsBar));
|
||||
|
||||
@@ -51,6 +51,26 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Close',
|
||||
description: 'Alt description for close icon button for closing in-context sidebar.',
|
||||
},
|
||||
confirmEmailTitle: {
|
||||
id: 'discussion.posts.confirm.email.title',
|
||||
defaultMessage: 'Confirm your email',
|
||||
description: 'Confirm email title for unverified users.',
|
||||
},
|
||||
confirmEmailDescription: {
|
||||
id: 'discussion.posts.confirm.email.description',
|
||||
defaultMessage: 'You’ll need to confirm your email before you can participate in discussions. Click the button below to receive an email with a confirmation link. Open it, then refresh this page to start contributing.\n\nCan’t find it? Check your spam folder or resend the email.',
|
||||
description: 'Confirm email description for unverified users.',
|
||||
},
|
||||
confirmEmailButton: {
|
||||
id: 'discussion.posts.confirm.email.button',
|
||||
defaultMessage: 'Send confirmation link',
|
||||
description: 'Confirmation link email button.',
|
||||
},
|
||||
closeButton: {
|
||||
id: 'discussion.posts.close.button',
|
||||
defaultMessage: 'Close',
|
||||
description: 'Close button.',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { Help, Post } from '@openedx/paragon/icons';
|
||||
import { Formik } from 'formik';
|
||||
import { isEmpty } from 'lodash';
|
||||
import ReCAPTCHA from 'react-google-recaptcha';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||
import * as Yup from 'yup';
|
||||
@@ -27,8 +28,11 @@ import DiscussionContext from '../../common/context';
|
||||
import { useCurrentDiscussionTopic } from '../../data/hooks';
|
||||
import {
|
||||
selectAnonymousPostingConfig,
|
||||
selectCaptchaSettings,
|
||||
selectDivisionSettings,
|
||||
selectEnableInContext,
|
||||
selectIsNotifyAllLearnersEnabled,
|
||||
selectIsUserLearner,
|
||||
selectModerationSettings,
|
||||
selectUserHasModerationPrivileges,
|
||||
selectUserIsGroupTa,
|
||||
@@ -42,6 +46,7 @@ import {
|
||||
selectNonCoursewareTopics as inContextNonCourseware,
|
||||
} from '../../in-context-topics/data/selectors';
|
||||
import { selectCoursewareTopics, selectNonCoursewareIds, selectNonCoursewareTopics } from '../../topics/data/selectors';
|
||||
import { updateUserDiscussionsTourByName } from '../../tours/data';
|
||||
import {
|
||||
discussionsPath, formikCompatibleHandler, isFormikFieldInvalid, useCommentsPagePath,
|
||||
} from '../../utils';
|
||||
@@ -59,6 +64,7 @@ const PostEditor = ({
|
||||
const location = useLocation();
|
||||
const dispatch = useDispatch();
|
||||
const editorRef = useRef(null);
|
||||
const recaptchaRef = useRef(null);
|
||||
const { courseId, postId } = useParams();
|
||||
const { authenticatedUser } = useContext(AppContext);
|
||||
const { category, enableInContextSidebar } = useContext(DiscussionContext);
|
||||
@@ -79,6 +85,9 @@ const PostEditor = ({
|
||||
const userIsStaff = useSelector(selectUserIsStaff);
|
||||
const archivedTopics = useSelector(selectArchivedTopics);
|
||||
const postEditorId = `post-editor-${editExisting ? postId : 'new'}`;
|
||||
const isNotifyAllLearnersEnabled = useSelector(selectIsNotifyAllLearnersEnabled);
|
||||
const captchaSettings = useSelector(selectCaptchaSettings);
|
||||
const isUserLearner = useSelector(selectIsUserLearner);
|
||||
|
||||
const canDisplayEditReason = (editExisting
|
||||
&& (userHasModerationPrivileges || userIsGroupTa || userIsStaff)
|
||||
@@ -89,6 +98,34 @@ const PostEditor = ({
|
||||
editReasonCode: Yup.string().required(intl.formatMessage(messages.editReasonCodeError)),
|
||||
};
|
||||
|
||||
const shouldRequireCaptcha = !postId && captchaSettings.enabled && isUserLearner;
|
||||
const captchaValidation = {
|
||||
recaptchaToken: Yup.string().required(intl.formatMessage(messages.captchaVerificationLabel)),
|
||||
};
|
||||
|
||||
const enableNotifyAllLearnersTour = useCallback((enabled) => {
|
||||
const data = {
|
||||
enabled,
|
||||
tourName: 'notify_all_learners',
|
||||
};
|
||||
dispatch(updateUserDiscussionsTourByName(data));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
enableNotifyAllLearnersTour(true);
|
||||
return () => {
|
||||
enableNotifyAllLearnersTour(false);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleCaptchaChange = useCallback((token, setFieldValue) => {
|
||||
setFieldValue('recaptchaToken', token || '');
|
||||
}, []);
|
||||
|
||||
const handleCaptchaExpired = useCallback((setFieldValue) => {
|
||||
setFieldValue('recaptchaToken', '');
|
||||
}, []);
|
||||
|
||||
const canSelectCohort = useCallback((tId) => {
|
||||
// If the user isn't privileged, they can't edit the cohort.
|
||||
// If the topic is being edited the cohort can't be changed.
|
||||
@@ -108,16 +145,22 @@ const PostEditor = ({
|
||||
title: post?.title || '',
|
||||
comment: post?.rawBody || '',
|
||||
follow: isEmpty(post?.following) ? true : post?.following,
|
||||
notifyAllLearners: false,
|
||||
anonymous: allowAnonymous ? false : undefined,
|
||||
anonymousToPeers: allowAnonymousToPeers ? false : undefined,
|
||||
cohort: post?.cohort || 'default',
|
||||
editReasonCode: post?.lastEdit?.reasonCode || (
|
||||
userIsStaff && canDisplayEditReason ? 'violates-guidelines' : undefined
|
||||
),
|
||||
recaptchaToken: '',
|
||||
};
|
||||
|
||||
const hideEditor = useCallback((resetForm) => {
|
||||
resetForm({ values: initialValues });
|
||||
// Reset CAPTCHA when hiding editor
|
||||
if (recaptchaRef.current) {
|
||||
recaptchaRef.current.reset();
|
||||
}
|
||||
if (editExisting) {
|
||||
const newLocation = discussionsPath(commentsPagePath, {
|
||||
courseId,
|
||||
@@ -149,7 +192,7 @@ const PostEditor = ({
|
||||
}));
|
||||
} else {
|
||||
const cohort = canSelectCohort(values.topic) ? selectedCohort(values.cohort) : undefined;
|
||||
// if not allowed to set cohort, always undefined, so no value is sent to backend
|
||||
// Include CAPTCHA token in the request for new posts
|
||||
await dispatchSubmit(createNewThread({
|
||||
courseId,
|
||||
topicId: values.topic,
|
||||
@@ -161,16 +204,18 @@ const PostEditor = ({
|
||||
anonymousToPeers: allowAnonymousToPeers ? values.anonymousToPeers : undefined,
|
||||
cohort,
|
||||
enableInContextSidebar,
|
||||
notifyAllLearners: values.notifyAllLearners,
|
||||
...(shouldRequireCaptcha ? { recaptchaToken: values.recaptchaToken } : {}),
|
||||
}));
|
||||
}
|
||||
/* istanbul ignore if: TinyMCE is mocked so this cannot be easily tested */
|
||||
|
||||
if (editorRef.current) {
|
||||
editorRef.current.plugins.autosave.removeDraft();
|
||||
}
|
||||
hideEditor(resetForm);
|
||||
}, [
|
||||
allowAnonymous, allowAnonymousToPeers, canSelectCohort, editExisting,
|
||||
enableInContextSidebar, hideEditor, postId, selectedCohort, topicId,
|
||||
enableInContextSidebar, hideEditor, postId, selectedCohort, topicId, shouldRequireCaptcha,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -216,10 +261,14 @@ const PostEditor = ({
|
||||
anonymousToPeers: Yup.bool()
|
||||
.default(false)
|
||||
.nullable(),
|
||||
notifyAllLearners: Yup.bool()
|
||||
.default(false),
|
||||
cohort: Yup.string()
|
||||
.nullable()
|
||||
.default(null),
|
||||
...(shouldRequireCaptcha ? { recaptchaToken: Yup.string().required() } : { }),
|
||||
...editReasonCodeValidation,
|
||||
...(shouldRequireCaptcha ? captchaValidation : {}),
|
||||
});
|
||||
|
||||
const handleInContextSelectLabel = (section, subsection) => (
|
||||
@@ -240,6 +289,7 @@ const PostEditor = ({
|
||||
handleBlur,
|
||||
handleChange,
|
||||
resetForm,
|
||||
setFieldValue,
|
||||
}) => (
|
||||
<Form className="m-4 card p-4 post-form" onSubmit={handleSubmit}>
|
||||
<h4 className="mb-4 font-style" style={{ lineHeight: '16px' }}>
|
||||
@@ -280,6 +330,7 @@ const PostEditor = ({
|
||||
aria-describedby="topicAreaInput"
|
||||
floatingLabel={intl.formatMessage(messages.topicArea)}
|
||||
disabled={enableInContextSidebar}
|
||||
data-testid="topic-select"
|
||||
>
|
||||
{nonCoursewareTopics.map(topic => (
|
||||
<option
|
||||
@@ -367,6 +418,7 @@ const PostEditor = ({
|
||||
aria-describedby="titleInput"
|
||||
floatingLabel={intl.formatMessage(messages.postTitle)}
|
||||
value={values.title}
|
||||
data-testid="post-title-input"
|
||||
/>
|
||||
<FormikErrorFeedback name="title" />
|
||||
</Form.Group>
|
||||
@@ -417,6 +469,22 @@ const PostEditor = ({
|
||||
<div className="d-flex flex-row mt-n4 w-75 text-primary font-style">
|
||||
{!editExisting && (
|
||||
<>
|
||||
{isNotifyAllLearnersEnabled && (
|
||||
<Form.Group>
|
||||
<Form.Checkbox
|
||||
name="notifyAllLearners"
|
||||
id="notify-learners"
|
||||
checked={values.notifyAllLearners}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
className="mr-4.5"
|
||||
>
|
||||
<span>
|
||||
{intl.formatMessage(messages.notifyAllLearners)}
|
||||
</span>
|
||||
</Form.Checkbox>
|
||||
</Form.Group>
|
||||
)}
|
||||
<Form.Group>
|
||||
<Form.Checkbox
|
||||
name="follow"
|
||||
@@ -447,6 +515,31 @@ const PostEditor = ({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{/* CAPTCHA Section - Only show for new posts for non-staff users */}
|
||||
{shouldRequireCaptcha && captchaSettings.siteKey && (
|
||||
<div className="mb-3">
|
||||
<Form.Group
|
||||
isInvalid={isFormikFieldInvalid('recaptchaToken', {
|
||||
errors,
|
||||
touched,
|
||||
})}
|
||||
>
|
||||
<Form.Label className="h6">
|
||||
{intl.formatMessage(messages.verifyHumanLabel)}
|
||||
</Form.Label>
|
||||
<div className="d-flex justify-content-start">
|
||||
<ReCAPTCHA
|
||||
ref={recaptchaRef}
|
||||
sitekey={captchaSettings.siteKey}
|
||||
onChange={(token) => handleCaptchaChange(token, setFieldValue)}
|
||||
onExpired={() => handleCaptchaExpired(setFieldValue)}
|
||||
onError={() => handleCaptchaExpired(setFieldValue)}
|
||||
/>
|
||||
</div>
|
||||
<FormikErrorFeedback name="recaptchaToken" />
|
||||
</Form.Group>
|
||||
</div>
|
||||
)}
|
||||
<div className="d-flex justify-content-end">
|
||||
<Button
|
||||
variant="outline-primary"
|
||||
|
||||
@@ -14,28 +14,39 @@ import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
|
||||
import { getCourseMetadataApiUrl } from '../../../components/NavigationBar/data/api';
|
||||
import fetchTab from '../../../components/NavigationBar/data/thunks';
|
||||
import { getApiBaseUrl, Routes as ROUTES } from '../../../data/constants';
|
||||
import { initializeStore } from '../../../store';
|
||||
import executeThunk from '../../../test-utils';
|
||||
import { getCohortsApiUrl } from '../../cohorts/data/api';
|
||||
import DiscussionContext from '../../common/context';
|
||||
import { getCourseConfigApiUrl } from '../../data/api';
|
||||
import fetchCourseConfig from '../../data/thunks';
|
||||
import fetchCourseTopics from '../../topics/data/thunks';
|
||||
import { getThreadsApiUrl } from '../data/api';
|
||||
import { fetchThread } from '../data/thunks';
|
||||
import MockReCAPTCHA, {
|
||||
mockOnChange, mockOnError, mockOnExpired,
|
||||
} from './mocksData/react-google-recaptcha';
|
||||
import PostEditor from './PostEditor';
|
||||
|
||||
import '../../cohorts/data/__factories__';
|
||||
import '../../data/__factories__';
|
||||
import '../../topics/data/__factories__';
|
||||
import '../data/__factories__';
|
||||
import '../../../components/NavigationBar/data/__factories__';
|
||||
|
||||
const courseId = 'course-v1:edX+DemoX+Demo_Course';
|
||||
const topicsApiUrl = `${getApiBaseUrl()}/api/discussion/v1/course_topics/${courseId}`;
|
||||
const courseConfigApiUrl = getCourseConfigApiUrl();
|
||||
const threadsApiUrl = getThreadsApiUrl();
|
||||
let store;
|
||||
let axiosMock;
|
||||
let container;
|
||||
|
||||
jest.mock('react-google-recaptcha', () => MockReCAPTCHA);
|
||||
|
||||
async function renderComponent(editExisting = false, location = `/${courseId}/posts/`) {
|
||||
const paths = editExisting ? ROUTES.POSTS.EDIT_POST : [ROUTES.POSTS.NEW_POST];
|
||||
const wrapper = await render(
|
||||
@@ -58,6 +69,116 @@ async function renderComponent(editExisting = false, location = `/${courseId}/po
|
||||
container = wrapper.container;
|
||||
}
|
||||
|
||||
describe('PostEditor submit Form', () => {
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
|
||||
Factory.resetAll();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
const cwtopics = Factory.buildList('category', 2);
|
||||
Factory.reset('topic');
|
||||
axiosMock.onGet(topicsApiUrl).reply(200, {
|
||||
courseware_topics: cwtopics,
|
||||
non_courseware_topics: Factory.buildList('topic', 3, {}, { topicPrefix: 'ncw-' }),
|
||||
});
|
||||
axiosMock.onGet(`${getCourseMetadataApiUrl(courseId)}`).reply(200, (Factory.build('navigationBar', 1, { isEnrolled: true })));
|
||||
|
||||
store = initializeStore({
|
||||
config: {
|
||||
provider: 'legacy',
|
||||
allowAnonymous: true,
|
||||
allowAnonymousToPeers: true,
|
||||
hasModerationPrivileges: false,
|
||||
settings: {
|
||||
dividedInlineDiscussions: ['category-1-topic-2'],
|
||||
dividedCourseWideDiscussions: ['ncw-topic-2'],
|
||||
},
|
||||
captchaSettings: {
|
||||
enabled: true,
|
||||
siteKey: 'test-key',
|
||||
},
|
||||
},
|
||||
});
|
||||
await executeThunk(fetchTab(courseId, 'outline'), store.dispatch, store.getState);
|
||||
await executeThunk(fetchCourseTopics(courseId), store.dispatch, store.getState);
|
||||
axiosMock.onGet(getCohortsApiUrl(courseId)).reply(200, Factory.buildList('cohort', 3));
|
||||
});
|
||||
|
||||
test('successfully submits a new post with CAPTCHA', async () => {
|
||||
const newThread = Factory.build('thread', { id: 'new-thread-1' });
|
||||
axiosMock.onPost(threadsApiUrl).reply(200, newThread);
|
||||
|
||||
await renderComponent();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.change(screen.getByTestId('topic-select'), {
|
||||
target: { value: 'ncw-topic-1' },
|
||||
});
|
||||
const postTitle = await screen.findByTestId('post-title-input');
|
||||
const tinymceEditor = await screen.findByTestId('tinymce-editor');
|
||||
const solveButton = screen.getByText('Solve CAPTCHA');
|
||||
|
||||
fireEvent.change(postTitle, { target: { value: 'Test Post Title' } });
|
||||
fireEvent.change(tinymceEditor, { target: { value: 'Test Post Content' } });
|
||||
fireEvent.click(solveButton);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /submit/i }));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(axiosMock.history.post).toHaveLength(1);
|
||||
expect(JSON.parse(axiosMock.history.post[0].data)).toMatchObject({
|
||||
course_id: 'course-v1:edX+DemoX+Demo_Course',
|
||||
topic_id: 'ncw-topic-1',
|
||||
type: 'discussion',
|
||||
title: 'Test Post Title',
|
||||
raw_body: 'Test Post Content',
|
||||
following: true,
|
||||
anonymous: false,
|
||||
anonymous_to_peers: false,
|
||||
enable_in_context_sidebar: false,
|
||||
notify_all_learners: false,
|
||||
captcha_token: 'mock-token',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('fails to submit a new post with CAPTCHA if token is missing', async () => {
|
||||
const newThread = Factory.build('thread', { id: 'new-thread-1' });
|
||||
axiosMock.onPost(threadsApiUrl).reply(200, newThread);
|
||||
|
||||
await renderComponent();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.change(screen.getByTestId('topic-select'), {
|
||||
target: { value: 'ncw-topic-1' },
|
||||
});
|
||||
const postTitle = await screen.findByTestId('post-title-input');
|
||||
const tinymceEditor = await screen.findByTestId('tinymce-editor');
|
||||
|
||||
fireEvent.change(postTitle, { target: { value: 'Test Post Title' } });
|
||||
fireEvent.change(tinymceEditor, { target: { value: 'Test Post Content' } });
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /submit/i }));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Please complete the CAPTCHA verification')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('PostEditor', () => {
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
@@ -107,16 +228,20 @@ describe('PostEditor', () => {
|
||||
allowAnonymous,
|
||||
allowAnonymousToPeers,
|
||||
moderationSettings: {},
|
||||
captchaSettings: {
|
||||
enabled: false,
|
||||
siteKey: '',
|
||||
},
|
||||
},
|
||||
});
|
||||
await executeThunk(fetchCourseTopics(courseId), store.dispatch, store.getState);
|
||||
await executeThunk(fetchTab(courseId, 'outline'), store.dispatch, store.getState);
|
||||
});
|
||||
|
||||
test(`new post when anonymous posts are ${allowAnonymous ? '' : 'not'} allowed and anonymous posts to peers are ${
|
||||
allowAnonymousToPeers ? '' : 'not'} allowed`, async () => {
|
||||
await renderComponent();
|
||||
|
||||
expect(screen.queryByRole('heading')).toHaveTextContent('Add a post');
|
||||
expect(screen.queryAllByRole('radio')).toHaveLength(2);
|
||||
// 2 categories with 4 subcategories each
|
||||
expect(screen.queryAllByText(/category-\d-topic \d/)).toHaveLength(8);
|
||||
@@ -143,11 +268,52 @@ describe('PostEditor', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe.each([
|
||||
{
|
||||
isNotifyAllLearnersEnabled: true,
|
||||
description: 'when "Notify All Learners" is enabled',
|
||||
},
|
||||
{
|
||||
isNotifyAllLearnersEnabled: false,
|
||||
description: 'when "Notify All Learners" is disabled',
|
||||
},
|
||||
])('$description', ({ isNotifyAllLearnersEnabled }) => {
|
||||
beforeEach(async () => {
|
||||
store = initializeStore({
|
||||
config: {
|
||||
provider: 'legacy',
|
||||
is_notify_all_learners_enabled: isNotifyAllLearnersEnabled,
|
||||
moderationSettings: {},
|
||||
captchaSettings: {
|
||||
enabled: false,
|
||||
siteKey: '',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
axiosMock
|
||||
.onGet(`${courseConfigApiUrl}${courseId}/`)
|
||||
.reply(200, { is_notify_all_learners_enabled: isNotifyAllLearnersEnabled });
|
||||
|
||||
await store.dispatch(fetchCourseConfig(courseId));
|
||||
renderComponent();
|
||||
});
|
||||
|
||||
test(`should ${isNotifyAllLearnersEnabled ? 'show' : 'not show'} the "Notify All Learners" option`, async () => {
|
||||
if (isNotifyAllLearnersEnabled) {
|
||||
await waitFor(() => expect(screen.queryByText('Notify All Learners')).toBeInTheDocument());
|
||||
} else {
|
||||
await waitFor(() => expect(screen.queryByText('Notify All Learners')).not.toBeInTheDocument());
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('cohorting', () => {
|
||||
const dividedncw = ['ncw-topic-2'];
|
||||
const dividedcw = ['category-1-topic-2', 'category-2-topic-1', 'category-2-topic-2'];
|
||||
|
||||
beforeEach(async () => {
|
||||
axiosMock.onGet(`${getCourseMetadataApiUrl(courseId)}`).reply(200, (Factory.build('navigationBar', 1, { isEnrolled: true })));
|
||||
axiosMock.onGet(getCohortsApiUrl(courseId)).reply(200, Factory.buildList('cohort', 3));
|
||||
});
|
||||
|
||||
@@ -162,12 +328,69 @@ describe('PostEditor', () => {
|
||||
dividedCourseWideDiscussions: dividedncw,
|
||||
...settings,
|
||||
},
|
||||
captchaSettings: {
|
||||
enabled: false,
|
||||
siteKey: '',
|
||||
},
|
||||
...config,
|
||||
},
|
||||
});
|
||||
await executeThunk(fetchCourseTopics(courseId), store.dispatch, store.getState);
|
||||
await executeThunk(fetchTab(courseId, 'outline'), store.dispatch, store.getState);
|
||||
}
|
||||
|
||||
test('renders the mocked ReCAPTCHA.', async () => {
|
||||
await setupData({
|
||||
captchaSettings: {
|
||||
enabled: true,
|
||||
siteKey: 'test-key',
|
||||
},
|
||||
hasModerationPrivileges: false,
|
||||
});
|
||||
await renderComponent();
|
||||
expect(screen.getByTestId('mocked-recaptcha')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('successfully calls onTokenChange when Solve CAPTCHA button is clicked', async () => {
|
||||
await setupData({
|
||||
captchaSettings: {
|
||||
enabled: true,
|
||||
siteKey: 'test-key',
|
||||
},
|
||||
hasModerationPrivileges: false,
|
||||
});
|
||||
await renderComponent();
|
||||
const solveButton = screen.getByText('Solve CAPTCHA');
|
||||
fireEvent.click(solveButton);
|
||||
expect(mockOnChange).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('successfully calls onExpired handler when CAPTCHA expires', async () => {
|
||||
await setupData({
|
||||
captchaSettings: {
|
||||
enabled: true,
|
||||
siteKey: 'test-key',
|
||||
},
|
||||
hasModerationPrivileges: false,
|
||||
});
|
||||
await renderComponent();
|
||||
fireEvent.click(screen.getByText('Expire CAPTCHA'));
|
||||
expect(mockOnExpired).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('successfully calls onError handler when CAPTCHA errors', async () => {
|
||||
await setupData({
|
||||
captchaSettings: {
|
||||
enabled: true,
|
||||
siteKey: 'test-key',
|
||||
},
|
||||
hasModerationPrivileges: false,
|
||||
});
|
||||
await renderComponent();
|
||||
fireEvent.click(screen.getByText('Error CAPTCHA'));
|
||||
expect(mockOnError).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('test privileged user', async () => {
|
||||
await setupData();
|
||||
await renderComponent();
|
||||
@@ -319,6 +542,10 @@ describe('PostEditor', () => {
|
||||
dividedInlineDiscussions: dividedcw,
|
||||
dividedCourseWideDiscussions: dividedncw,
|
||||
},
|
||||
captchaSettings: {
|
||||
enabled: false,
|
||||
siteKey: '',
|
||||
},
|
||||
},
|
||||
});
|
||||
await executeThunk(fetchCourseTopics(courseId), store.dispatch, store.getState);
|
||||
|
||||
@@ -89,6 +89,10 @@ const messages = defineMessages({
|
||||
id: 'discussions.post.editor.anonymousToPeersPost',
|
||||
defaultMessage: 'Post anonymously to peers',
|
||||
},
|
||||
notifyAllLearners: {
|
||||
id: 'discussions.post.editor.notifyAllLearners',
|
||||
defaultMessage: 'Notify All Learners',
|
||||
},
|
||||
submit: {
|
||||
id: 'discussions.editor.submit',
|
||||
defaultMessage: 'Submit',
|
||||
@@ -171,6 +175,16 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Archived',
|
||||
description: 'Heading for displaying topics that are archived.',
|
||||
},
|
||||
captchaVerificationLabel: {
|
||||
id: 'discussions.captcha.verification.label',
|
||||
defaultMessage: 'Please complete the CAPTCHA verification',
|
||||
description: 'Please complete the CAPTCHA to continue.',
|
||||
},
|
||||
verifyHumanLabel: {
|
||||
id: 'discussions.verify.human.label',
|
||||
defaultMessage: 'Verify you are human',
|
||||
description: 'Verify you are human description.',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
// Define mock functions
|
||||
export const mockOnChange = jest.fn();
|
||||
export const mockOnExpired = jest.fn();
|
||||
export const mockOnError = jest.fn();
|
||||
export const mockReset = jest.fn();
|
||||
|
||||
const MockReCAPTCHA = React.forwardRef((props, ref) => {
|
||||
const { onChange, onExpired, onError } = props;
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
reset: mockReset,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div data-testid="mocked-recaptcha" ref={ref}>
|
||||
<button type="button" onClick={() => { mockOnChange(); onChange?.('mock-token'); }}>
|
||||
Solve CAPTCHA
|
||||
</button>
|
||||
<button type="button" onClick={() => { mockOnExpired(); onExpired?.(); }}>
|
||||
Expire CAPTCHA
|
||||
</button>
|
||||
<button type="button" onClick={() => { mockOnError(); onError?.(); }}>
|
||||
Error CAPTCHA
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
MockReCAPTCHA.propTypes = {
|
||||
onChange: PropTypes.func,
|
||||
onExpired: PropTypes.func,
|
||||
onError: PropTypes.func,
|
||||
};
|
||||
MockReCAPTCHA.defaultProps = {
|
||||
onChange: () => {},
|
||||
onExpired: () => {},
|
||||
onError: () => {},
|
||||
};
|
||||
|
||||
export default MockReCAPTCHA;
|
||||
@@ -16,8 +16,9 @@ import { selectorForUnitSubsection, selectTopicContext } from '../../../data/sel
|
||||
import { AlertBanner, Confirmation } from '../../common';
|
||||
import DiscussionContext from '../../common/context';
|
||||
import HoverCard from '../../common/HoverCard';
|
||||
import withEmailConfirmation from '../../common/withEmailConfirmation';
|
||||
import { ContentTypes } from '../../data/constants';
|
||||
import { selectUserHasModerationPrivileges } from '../../data/selectors';
|
||||
import { selectShouldShowEmailConfirmation, selectUserHasModerationPrivileges } from '../../data/selectors';
|
||||
import { selectTopic } from '../../topics/data/selectors';
|
||||
import { truncatePath } from '../../utils';
|
||||
import { selectThread } from '../data/selectors';
|
||||
@@ -27,7 +28,7 @@ import messages from './messages';
|
||||
import PostFooter from './PostFooter';
|
||||
import PostHeader from './PostHeader';
|
||||
|
||||
const Post = ({ handleAddResponseButton }) => {
|
||||
const Post = ({ handleAddResponseButton, openEmailConfirmation }) => {
|
||||
const { enableInContextSidebar, postId } = useContext(DiscussionContext);
|
||||
const {
|
||||
topicId, abuseFlagged, closed, pinned, voted, hasEndorsed, following, closedBy, voteCount, groupId, groupName,
|
||||
@@ -46,6 +47,8 @@ const Post = ({ handleAddResponseButton }) => {
|
||||
const [isReporting, showReportConfirmation, hideReportConfirmation] = useToggle(false);
|
||||
const [isClosing, showClosePostModal, hideClosePostModal] = useToggle(false);
|
||||
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
|
||||
const shouldShowEmailConfirmation = useSelector(selectShouldShowEmailConfirmation);
|
||||
|
||||
const displayPostFooter = following || voteCount || closed || (groupId && userHasModerationPrivileges);
|
||||
|
||||
const handleDeleteConfirmation = useCallback(async () => {
|
||||
@@ -155,7 +158,7 @@ const Post = ({ handleAddResponseButton }) => {
|
||||
id={postId}
|
||||
contentType={ContentTypes.POST}
|
||||
actionHandlers={actionHandlers}
|
||||
handleResponseCommentButton={handleAddResponseButton}
|
||||
handleResponseCommentButton={shouldShowEmailConfirmation ? openEmailConfirmation : handleAddResponseButton}
|
||||
addResponseCommentButtonMessage={intl.formatMessage(messages.addResponse)}
|
||||
onLike={handlePostLike}
|
||||
onFollow={handlePostFollow}
|
||||
@@ -235,6 +238,7 @@ const Post = ({ handleAddResponseButton }) => {
|
||||
|
||||
Post.propTypes = {
|
||||
handleAddResponseButton: PropTypes.func.isRequired,
|
||||
openEmailConfirmation: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default React.memo(Post);
|
||||
export default React.memo(withEmailConfirmation(Post));
|
||||
|
||||
@@ -4,18 +4,21 @@ import PropTypes from 'prop-types';
|
||||
import { Avatar, Badge, Icon } from '@openedx/paragon';
|
||||
import { Question } from '@openedx/paragon/icons';
|
||||
import classNames from 'classnames';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { AvatarOutlineAndLabelColors, ThreadType } from '../../../data/constants';
|
||||
import { AuthorLabel } from '../../common';
|
||||
import { useAlertBannerVisible } from '../../data/hooks';
|
||||
import { selectAuthorAvatar } from '../data/selectors';
|
||||
import messages from './messages';
|
||||
|
||||
export const PostAvatar = React.memo(({
|
||||
author, postType, authorLabel, fromPostLink, read,
|
||||
}) => {
|
||||
const outlineColor = AvatarOutlineAndLabelColors[authorLabel];
|
||||
const authorAvatars = useSelector(selectAuthorAvatar(author));
|
||||
|
||||
const avatarSize = useMemo(() => {
|
||||
let size = '2rem';
|
||||
@@ -60,6 +63,7 @@ export const PostAvatar = React.memo(({
|
||||
width: avatarSize,
|
||||
}}
|
||||
alt={author}
|
||||
src={authorAvatars?.imageUrlSmall}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -7,11 +7,11 @@ import messages from './messages';
|
||||
*/
|
||||
export default function tourCheckpoints(intl) {
|
||||
return {
|
||||
EXAMPLE_TOUR: [
|
||||
notify_all_learners: [
|
||||
{
|
||||
title: intl.formatMessage(messages.exampleTourTitle),
|
||||
body: intl.formatMessage(messages.exampleTourBody),
|
||||
target: '#example-tour-target',
|
||||
title: intl.formatMessage(messages.notifyAllLearnersTourTitle),
|
||||
body: intl.formatMessage(messages.notifyAllLearnersTourBody),
|
||||
target: '#notify-learners',
|
||||
placement: 'bottom',
|
||||
},
|
||||
],
|
||||
|
||||
@@ -16,15 +16,15 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Okay',
|
||||
description: 'Action to end current tour',
|
||||
},
|
||||
exampleTourTitle: {
|
||||
id: 'tour.example.title',
|
||||
defaultMessage: 'Example Tour',
|
||||
description: 'Title for example tour',
|
||||
notifyAllLearnersTourTitle: {
|
||||
id: 'tour.title.notifyAllLearners',
|
||||
defaultMessage: 'Let your learners know.',
|
||||
description: 'Title of the tour to notify all learners',
|
||||
},
|
||||
exampleTourBody: {
|
||||
id: 'tour.example.body',
|
||||
defaultMessage: 'This is an example tour',
|
||||
description: 'Body for example tour',
|
||||
notifyAllLearnersTourBody: {
|
||||
id: 'tour.body.notifyAllLearners',
|
||||
defaultMessage: 'Check this box to notify all learners.',
|
||||
description: 'Body of the tour to notify all learners',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import 'core-js/stable';
|
||||
import 'regenerator-runtime/runtime';
|
||||
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import React, { StrictMode } from 'react';
|
||||
|
||||
// eslint-disable-next-line import/no-unresolved
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import {
|
||||
APP_INIT_ERROR, APP_READY, initialize, mergeConfig,
|
||||
@@ -17,18 +19,20 @@ import store from './store';
|
||||
|
||||
import './index.scss';
|
||||
|
||||
const rootNode = createRoot(document.getElementById('root'));
|
||||
subscribe(APP_READY, () => {
|
||||
ReactDOM.render(
|
||||
<AppProvider store={store}>
|
||||
<Head />
|
||||
<DiscussionsHome />
|
||||
</AppProvider>,
|
||||
document.getElementById('root'),
|
||||
rootNode.render(
|
||||
<StrictMode>
|
||||
<AppProvider store={store}>
|
||||
<Head />
|
||||
<DiscussionsHome />
|
||||
</AppProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
});
|
||||
|
||||
subscribe(APP_INIT_ERROR, (error) => {
|
||||
ReactDOM.render(<ErrorPage message={error.message} />, document.getElementById('root'));
|
||||
rootNode.render(<ErrorPage message={error.message} />);
|
||||
});
|
||||
|
||||
initialize({
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
@import "~@edx/brand/paragon/fonts.scss";
|
||||
@import "~@edx/brand/paragon/variables.scss";
|
||||
@import "~@openedx/paragon/scss/core/core.scss";
|
||||
@import "~@edx/brand/paragon/overrides.scss";
|
||||
@use "@openedx/paragon/styles/css/core/custom-media-breakpoints" as paragonCustomMediaBreakpoints;
|
||||
|
||||
|
||||
@import "~@edx/frontend-component-footer/dist/footer";
|
||||
@import "~@edx/frontend-component-header/dist/index";
|
||||
|
||||
$fa-font-path: "~font-awesome/fonts";
|
||||
@import "~font-awesome/scss/font-awesome";
|
||||
|
||||
body,
|
||||
#main
|
||||
@@ -28,6 +24,10 @@ body,
|
||||
font-size: 16px !important;
|
||||
}
|
||||
|
||||
.btn-plain {
|
||||
background-color: var(--pgn-color-card-bg-base) !important;
|
||||
}
|
||||
|
||||
#post,
|
||||
#comment,
|
||||
#reply,
|
||||
@@ -41,23 +41,23 @@ body,
|
||||
}
|
||||
|
||||
.text-staff-color {
|
||||
color: $warning-700;
|
||||
color: var(--pgn-color-warning-700);
|
||||
}
|
||||
|
||||
.outline-staff-color {
|
||||
outline: $warning-700 solid 2px;
|
||||
outline: var(--pgn-color-warning-700) solid 2px;
|
||||
}
|
||||
|
||||
.text-TA-color {
|
||||
color: $success-700;
|
||||
color: var(--pgn-color-success-700);
|
||||
}
|
||||
|
||||
.outline-TA-color {
|
||||
outline: $success-700 solid 2px;
|
||||
outline: var(--pgn-color-success-700) solid 2px;
|
||||
}
|
||||
|
||||
.outline-anonymous {
|
||||
outline: $light-400 solid 2px;
|
||||
outline: var(--pgn-color-light-400) solid 2px;
|
||||
}
|
||||
|
||||
.font-size-8 {
|
||||
@@ -173,7 +173,7 @@ body,
|
||||
}
|
||||
|
||||
.learner > a:hover {
|
||||
background-color: $light-300;
|
||||
background-color: var(--pgn-color-light-300);
|
||||
}
|
||||
|
||||
.py-10px {
|
||||
@@ -252,12 +252,12 @@ header {
|
||||
}
|
||||
|
||||
.border-light-400-2 {
|
||||
border: 2px solid $light-400 !important;
|
||||
border: 2px solid var(--pgn-color-light-400) !important;
|
||||
border-width: 2px !important;
|
||||
}
|
||||
|
||||
.border-primary-500-2 {
|
||||
border: 2px solid $primary-500 !important;
|
||||
border: 2px solid var(--pgn-color-primary-500) !important;
|
||||
border-width: 2px !important;
|
||||
}
|
||||
|
||||
@@ -383,8 +383,8 @@ header {
|
||||
}
|
||||
|
||||
.btn-icon.btn-icon-primary:hover {
|
||||
background-color: $light-300 !important;
|
||||
color: $primary-500 !important
|
||||
background-color: var(--pgn-color-light-300) !important;
|
||||
color: var(--pgn-color-primary-500) !important
|
||||
}
|
||||
|
||||
|
||||
@@ -427,38 +427,38 @@ header {
|
||||
}
|
||||
|
||||
.hover-button:hover {
|
||||
background-color: $light-300 !important;
|
||||
background-color: var(--pgn-color-light-300) !important;
|
||||
height: 36px !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.btn-tertiary:hover {
|
||||
background-color: $light-300 !important;
|
||||
background-color: var(--pgn-color-light-300) !important;
|
||||
}
|
||||
|
||||
.nav-button-group {
|
||||
.nav-link {
|
||||
&:hover {
|
||||
background-color: $light-300 !important;
|
||||
background-color: var(--pgn-color-light-300) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-link.active,
|
||||
.show>.nav-link {
|
||||
background-color: $primary-500 !important;
|
||||
background-color: var(--pgn-color-primary-500) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.course-tabs-navigation {
|
||||
.nav a {
|
||||
&:hover {
|
||||
background-color: $light-300 !important;;
|
||||
background-color: var(--pgn-color-light-300) !important;;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.btn-tertiary:disabled {
|
||||
color: $gray-700 !important;
|
||||
color: var(--pgn-color-gray-700) !important;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
@@ -535,14 +535,14 @@ code {
|
||||
.post-preview,
|
||||
.discussion-comments {
|
||||
blockquote {
|
||||
border-left: 2px solid $gray-200;
|
||||
border-left: 2px solid var(--pgn-color-gray-200);
|
||||
margin-left: 1.5rem;
|
||||
padding-left: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.add-comment-btn {
|
||||
border: 1px solid $light-300 !important;
|
||||
border: 1px solid var(--pgn-color-light-300) !important;
|
||||
}
|
||||
|
||||
.icon-size-24 {
|
||||
@@ -553,7 +553,7 @@ code {
|
||||
.actions-dropdown-item {
|
||||
padding: 12px 16px;
|
||||
height: 48px !important;
|
||||
width: 195px !important
|
||||
min-width: 195px !important
|
||||
}
|
||||
|
||||
.font-xl {
|
||||
@@ -588,7 +588,7 @@ code {
|
||||
}
|
||||
|
||||
th, td {
|
||||
border: 1px dashed $gray-200;
|
||||
border: 1px dashed var(--pgn-color-gray-200);
|
||||
padding: 0.4rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
# Footer Slot
|
||||
|
||||
### Slot ID: `footer_slot`
|
||||
### Slot ID: `org.openedx.frontend.layout.footer.v1`
|
||||
|
||||
## Description
|
||||
### Slot ID Aliases
|
||||
* `footer_slot`
|
||||
|
||||
This slot is used to replace/modify/hide the footer.
|
||||
|
||||
The implementation of the `FooterSlot` component lives in [the `frontend-slot-footer` repository](https://github.com/openedx/frontend-slot-footer/).
|
||||
The implementation of the `FooterSlot` component lives in [the `frontend-component-footer` repository](https://github.com/openedx/frontend-component-footer/).
|
||||
|
||||
## Example
|
||||
|
||||
@@ -23,7 +24,7 @@ import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-frame
|
||||
|
||||
const config = {
|
||||
pluginSlots: {
|
||||
footer_slot: {
|
||||
'org.openedx.frontend.layout.footer.v1': {
|
||||
plugins: [
|
||||
{
|
||||
// Hide the default footer
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
# `frontend-app-discussions` Plugin Slots
|
||||
|
||||
* [`footer_slot`](./FooterSlot/)
|
||||
* [`org.openedx.frontend.layout.footer.v1`](./FooterSlot/)
|
||||
|
||||
Reference in New Issue
Block a user