feat: Modified discussions FE so that ONLY users with verified emails (#789)
* feat: Modified discussions FE so that ONLY users with verified emails can create content * feat: added comment and response functionality * test: fixed test cases * refactor: refactor code and added HOC * test: added test cases * test: added test cases for failed and denied * feat: added states for button * refactor: added callback function * test: added test case to close the dialogue * refactor: refactor function
This commit is contained in:
@@ -19,11 +19,13 @@ const Confirmation = ({
|
||||
onClose,
|
||||
confirmAction,
|
||||
closeButtonVariant,
|
||||
confirmButtonState,
|
||||
confirmButtonVariant,
|
||||
confirmButtonText,
|
||||
isDataLoading,
|
||||
isConfirmButtonPending,
|
||||
pendingConfirmButtonText,
|
||||
closeButtonText,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
@@ -42,14 +44,14 @@ const Confirmation = ({
|
||||
{title}
|
||||
</ModalDialog.Title>
|
||||
</ModalDialog.Header>
|
||||
<ModalDialog.Body>
|
||||
<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}>
|
||||
{intl.formatMessage(messages.confirmationCancel)}
|
||||
{closeButtonText || intl.formatMessage(messages.confirmationCancel)}
|
||||
</ModalDialog.CloseButton>
|
||||
<StatefulButton
|
||||
labels={{
|
||||
@@ -57,7 +59,7 @@ const Confirmation = ({
|
||||
pending: pendingConfirmButtonText || confirmButtonText
|
||||
|| intl.formatMessage(messages.confirmationConfirm),
|
||||
}}
|
||||
state={isConfirmButtonPending ? 'pending' : confirmButtonVariant}
|
||||
state={isConfirmButtonPending ? 'pending' : confirmButtonState}
|
||||
variant={confirmButtonVariant}
|
||||
onClick={confirmAction}
|
||||
/>
|
||||
@@ -82,6 +84,8 @@ Confirmation.propTypes = {
|
||||
isDataLoading: PropTypes.bool,
|
||||
isConfirmButtonPending: PropTypes.bool,
|
||||
pendingConfirmButtonText: PropTypes.string,
|
||||
closeButtonText: PropTypes.string,
|
||||
confirmButtonState: PropTypes.string,
|
||||
};
|
||||
|
||||
Confirmation.defaultProps = {
|
||||
@@ -92,6 +96,8 @@ Confirmation.defaultProps = {
|
||||
isDataLoading: false,
|
||||
isConfirmButtonPending: false,
|
||||
pendingConfirmButtonText: '',
|
||||
closeButtonText: '',
|
||||
confirmButtonState: 'default',
|
||||
};
|
||||
|
||||
export default React.memo(Confirmation);
|
||||
|
||||
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, selectOnlyVerifiedUsersCanPost } 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 onlyVerifiedUsersCanPost = useSelector(selectOnlyVerifiedUsersCanPost);
|
||||
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}
|
||||
/>
|
||||
{!onlyVerifiedUsersCanPost
|
||||
&& (
|
||||
<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;
|
||||
82
src/discussions/common/withEmailConfirmation.test.jsx
Normal file
82
src/discussions/common/withEmailConfirmation.test.jsx
Normal file
@@ -0,0 +1,82 @@
|
||||
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();
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -37,6 +37,12 @@ export const selectIsNotifyAllLearnersEnabled = state => state.config.isNotifyAl
|
||||
|
||||
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 selectModerationSettings = state => ({
|
||||
postCloseReasons: state.config.postCloseReasons,
|
||||
editReasons: state.config.editReasons,
|
||||
|
||||
@@ -30,6 +30,7 @@ const configSlice = createSlice({
|
||||
editReasons: [],
|
||||
postCloseReasons: [],
|
||||
enableInContext: false,
|
||||
isEmailVerified: false,
|
||||
},
|
||||
reducers: {
|
||||
fetchConfigRequest: (state) => (
|
||||
|
||||
@@ -207,6 +207,9 @@ describe('DiscussionsHome', () => {
|
||||
});
|
||||
|
||||
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`);
|
||||
|
||||
@@ -221,7 +224,7 @@ 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`);
|
||||
|
||||
@@ -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,
|
||||
selectIsEmailVerified,
|
||||
selectPostThreadCount,
|
||||
} 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 isEmailVerified = useSelector(selectIsEmailVerified);
|
||||
|
||||
const addPost = useCallback(() => (
|
||||
dispatch(showPostEditor())
|
||||
), []);
|
||||
const addPost = useCallback(() => {
|
||||
if (isEmailVerified) { dispatch(showPostEditor()); } else { openEmailConfirmation(); }
|
||||
}, [isEmailVerified, 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,33 @@
|
||||
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 {
|
||||
selectIsEmailVerified, 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 isEmailVerified = useSelector(selectIsEmailVerified);
|
||||
|
||||
const addPost = useCallback(() => (
|
||||
dispatch(showPostEditor())
|
||||
), []);
|
||||
const addPost = useCallback(() => {
|
||||
if (isEmailVerified) { dispatch(showPostEditor()); } else { openEmailConfirmation(); }
|
||||
}, [isEmailVerified, openEmailConfirmation]);
|
||||
|
||||
let title = messages.emptyTitle;
|
||||
let fullWidth = false;
|
||||
@@ -63,4 +68,8 @@ const EmptyTopics = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default EmptyTopics;
|
||||
EmptyTopics.propTypes = {
|
||||
openEmailConfirmation: propTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default React.memo(withEmailConfirmation(EmptyTopics));
|
||||
|
||||
@@ -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 { selectIsEmailVerified, selectPostThreadCount } 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 isEmailVerified = useSelector(selectIsEmailVerified);
|
||||
|
||||
const addPost = useCallback(() => (
|
||||
dispatch(showPostEditor())
|
||||
), []);
|
||||
const addPost = useCallback(() => {
|
||||
if (isEmailVerified) { dispatch(showPostEditor()); } else { openEmailConfirmation(); }
|
||||
}, [isEmailVerified, 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));
|
||||
|
||||
@@ -115,6 +115,7 @@ async function setupCourseConfig() {
|
||||
{ code: 'reason-1', label: 'reason 1' },
|
||||
{ code: 'reason-2', label: 'reason 2' },
|
||||
],
|
||||
isEmailVerified: true,
|
||||
});
|
||||
axiosMock.onGet(`${courseSettingsApiUrl}${courseId}/settings`).reply(200, {});
|
||||
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
|
||||
@@ -295,6 +296,7 @@ describe('ThreadView', () => {
|
||||
});
|
||||
|
||||
it('should show and hide the editor', async () => {
|
||||
await setupCourseConfig();
|
||||
await waitFor(() => renderComponent(discussionPostId));
|
||||
|
||||
const post = screen.getByTestId('post-thread-1');
|
||||
@@ -309,6 +311,7 @@ describe('ThreadView', () => {
|
||||
});
|
||||
|
||||
it('should allow posting a comment with CAPTCHA', async () => {
|
||||
await setupCourseConfig();
|
||||
await waitFor(() => renderComponent(discussionPostId));
|
||||
|
||||
const comment = await waitFor(() => screen.findByTestId('comment-comment-1'));
|
||||
@@ -332,6 +335,7 @@ describe('ThreadView', () => {
|
||||
});
|
||||
|
||||
it('should allow posting a comment', async () => {
|
||||
await setupCourseConfig();
|
||||
await waitFor(() => renderComponent(discussionPostId));
|
||||
|
||||
const post = await screen.findByTestId('post-thread-1');
|
||||
@@ -353,6 +357,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'));
|
||||
@@ -619,6 +624,7 @@ describe('ThreadView', () => {
|
||||
const findLoadMoreCommentsButton = () => screen.findByTestId('load-more-comments');
|
||||
|
||||
it('renders the mocked ReCAPTCHA.', async () => {
|
||||
await setupCourseConfig();
|
||||
await waitFor(() => renderComponent(discussionPostId));
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.queryByText('Add comment'));
|
||||
@@ -627,6 +633,7 @@ describe('ThreadView', () => {
|
||||
});
|
||||
|
||||
it('successfully calls onTokenChange when Solve CAPTCHA button is clicked', async () => {
|
||||
await setupCourseConfig();
|
||||
await waitFor(() => renderComponent(discussionPostId));
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.queryByText('Add comment'));
|
||||
@@ -637,6 +644,7 @@ describe('ThreadView', () => {
|
||||
});
|
||||
|
||||
it('successfully calls onExpired handler when CAPTCHA expires', async () => {
|
||||
await setupCourseConfig();
|
||||
await waitFor(() => renderComponent(discussionPostId));
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.queryByText('Add comment'));
|
||||
@@ -646,6 +654,7 @@ describe('ThreadView', () => {
|
||||
});
|
||||
|
||||
it('successfully calls onError handler when CAPTCHA errors', async () => {
|
||||
await setupCourseConfig();
|
||||
await waitFor(() => renderComponent(discussionPostId));
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.queryByText('Add comment'));
|
||||
@@ -745,6 +754,7 @@ describe('ThreadView', () => {
|
||||
});
|
||||
|
||||
it('successfully added comment in the draft.', async () => {
|
||||
await setupCourseConfig();
|
||||
await waitFor(() => renderComponent(discussionPostId));
|
||||
|
||||
await act(async () => {
|
||||
@@ -804,6 +814,7 @@ describe('ThreadView', () => {
|
||||
});
|
||||
|
||||
it('successfully removed comment from the draft.', async () => {
|
||||
await setupCourseConfig();
|
||||
await waitFor(() => renderComponent(discussionPostId));
|
||||
|
||||
await act(async () => {
|
||||
@@ -826,6 +837,7 @@ describe('ThreadView', () => {
|
||||
});
|
||||
|
||||
it('successfully added response in the draft.', async () => {
|
||||
await setupCourseConfig();
|
||||
await waitFor(() => renderComponent(discussionPostId));
|
||||
|
||||
await act(async () => {
|
||||
@@ -848,6 +860,7 @@ describe('ThreadView', () => {
|
||||
});
|
||||
|
||||
it('successfully removed response from the draft.', async () => {
|
||||
await setupCourseConfig();
|
||||
await waitFor(() => renderComponent(discussionPostId));
|
||||
|
||||
await act(async () => {
|
||||
@@ -870,6 +883,7 @@ describe('ThreadView', () => {
|
||||
});
|
||||
|
||||
it('successfully maintain response for the specific post in the draft.', async () => {
|
||||
await setupCourseConfig();
|
||||
await waitFor(() => renderComponent(discussionPostId));
|
||||
|
||||
await act(async () => {
|
||||
|
||||
@@ -2,21 +2,25 @@ 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 { selectIsEmailVerified } 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 isEmailVerified = useSelector(selectIsEmailVerified);
|
||||
const isUserPrivilegedInPostingRestriction = useUserPostingEnabled();
|
||||
|
||||
const {
|
||||
@@ -28,8 +32,8 @@ const CommentsView = ({ threadType }) => {
|
||||
} = usePostComments(threadType);
|
||||
|
||||
const handleAddResponse = useCallback(() => {
|
||||
setAddingResponse(true);
|
||||
}, []);
|
||||
if (isEmailVerified) { setAddingResponse(true); } else { openEmailConfirmation(); }
|
||||
}, [isEmailVerified, openEmailConfirmation]);
|
||||
|
||||
const handleCloseResponseEditor = useCallback(() => {
|
||||
setAddingResponse(false);
|
||||
@@ -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 { selectIsEmailVerified } 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 {
|
||||
@@ -60,6 +63,7 @@ const Comment = ({
|
||||
const hasMorePages = useSelector(selectCommentHasMorePages(id));
|
||||
const currentPage = useSelector(selectCommentCurrentPage(id));
|
||||
const sortedOrder = useSelector(selectCommentSortOrder);
|
||||
const isEmailVerified = useSelector(selectIsEmailVerified);
|
||||
const actions = useActions(ContentTypes.COMMENT, id);
|
||||
const isUserPrivilegedInPostingRestriction = useUserPostingEnabled();
|
||||
|
||||
@@ -179,7 +183,7 @@ const Comment = ({
|
||||
id={id}
|
||||
contentType={ContentTypes.COMMENT}
|
||||
actionHandlers={actionHandlers}
|
||||
handleResponseCommentButton={handleAddCommentButton}
|
||||
handleResponseCommentButton={isEmailVerified ? handleAddCommentButton : openEmailConfirmation}
|
||||
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={isEmailVerified ? handleAddCommentReply : openEmailConfirmation}
|
||||
>
|
||||
{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));
|
||||
|
||||
@@ -204,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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
@@ -304,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,
|
||||
selectIsEmailVerified,
|
||||
} 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 isEmailVerified = useSelector(selectIsEmailVerified);
|
||||
const isUserPrivilegedInPostingRestriction = useUserPostingEnabled();
|
||||
const { enableInContextSidebar, page } = useContext(DiscussionContext);
|
||||
|
||||
@@ -34,8 +41,8 @@ const PostActionsBar = () => {
|
||||
}, []);
|
||||
|
||||
const handleAddPost = useCallback(() => {
|
||||
dispatch(showPostEditor());
|
||||
}, []);
|
||||
if (isEmailVerified) { dispatch(showPostEditor()); } else { openEmailConfirmation(); }
|
||||
}, [isEmailVerified, openEmailConfirmation]);
|
||||
|
||||
return (
|
||||
<div className={classNames('d-flex justify-content-end flex-grow-1', { 'py-1': !enableInContextSidebar })}>
|
||||
@@ -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;
|
||||
|
||||
@@ -16,8 +16,11 @@ 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 {
|
||||
selectIsEmailVerified, selectUserHasModerationPrivileges,
|
||||
} from '../../data/selectors';
|
||||
import { selectTopic } from '../../topics/data/selectors';
|
||||
import { truncatePath } from '../../utils';
|
||||
import { selectThread } from '../data/selectors';
|
||||
@@ -27,7 +30,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 +49,8 @@ const Post = ({ handleAddResponseButton }) => {
|
||||
const [isReporting, showReportConfirmation, hideReportConfirmation] = useToggle(false);
|
||||
const [isClosing, showClosePostModal, hideClosePostModal] = useToggle(false);
|
||||
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
|
||||
const isEmailVerified = useSelector(selectIsEmailVerified);
|
||||
|
||||
const displayPostFooter = following || voteCount || closed || (groupId && userHasModerationPrivileges);
|
||||
|
||||
const handleDeleteConfirmation = useCallback(async () => {
|
||||
@@ -155,7 +160,7 @@ const Post = ({ handleAddResponseButton }) => {
|
||||
id={postId}
|
||||
contentType={ContentTypes.POST}
|
||||
actionHandlers={actionHandlers}
|
||||
handleResponseCommentButton={handleAddResponseButton}
|
||||
handleResponseCommentButton={isEmailVerified ? handleAddResponseButton : () => openEmailConfirmation()}
|
||||
addResponseCommentButtonMessage={intl.formatMessage(messages.addResponse)}
|
||||
onLike={handlePostLike}
|
||||
onFollow={handlePostFollow}
|
||||
@@ -235,6 +240,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));
|
||||
|
||||
Reference in New Issue
Block a user