From 76da74ae20e007d71fa7b964e4e57bab88af87ed Mon Sep 17 00:00:00 2001 From: sundasnoreen12 <72802712+sundasnoreen12@users.noreply.github.com> Date: Wed, 23 Jul 2025 17:24:57 +0500 Subject: [PATCH] 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 --- src/discussions/common/Confirmation.jsx | 12 ++- .../common/withEmailConfirmation.jsx | 67 +++++++++++++++ .../common/withEmailConfirmation.test.jsx | 82 +++++++++++++++++++ src/discussions/data/selectors.js | 6 ++ src/discussions/data/slices.js | 1 + .../discussions-home/DiscussionsHome.test.jsx | 5 +- src/discussions/empty-posts/EmptyPosts.jsx | 19 +++-- src/discussions/empty-posts/EmptyTopics.jsx | 21 +++-- .../components/EmptyTopics.jsx | 19 +++-- .../post-comments/PostCommentsView.test.jsx | 14 ++++ .../post-comments/comments/CommentsView.jsx | 13 ++- .../comments/comment/Comment.jsx | 11 ++- src/discussions/posts/data/api.js | 10 +++ src/discussions/posts/data/api.test.js | 33 ++++++++ src/discussions/posts/data/slices.js | 27 ++++++ src/discussions/posts/data/thunks.js | 23 +++++- .../posts/post-actions-bar/PostActionsBar.jsx | 21 +++-- .../posts/post-actions-bar/messages.js | 20 +++++ src/discussions/posts/post/Post.jsx | 14 +++- 19 files changed, 379 insertions(+), 39 deletions(-) create mode 100644 src/discussions/common/withEmailConfirmation.jsx create mode 100644 src/discussions/common/withEmailConfirmation.test.jsx diff --git a/src/discussions/common/Confirmation.jsx b/src/discussions/common/Confirmation.jsx index 4798e5cd..c5e7f253 100644 --- a/src/discussions/common/Confirmation.jsx +++ b/src/discussions/common/Confirmation.jsx @@ -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} - + {description} {boldDescription && <>

{boldDescription}

}
- {intl.formatMessage(messages.confirmationCancel)} + {closeButtonText || intl.formatMessage(messages.confirmationCancel)} @@ -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); diff --git a/src/discussions/common/withEmailConfirmation.jsx b/src/discussions/common/withEmailConfirmation.jsx new file mode 100644 index 00000000..3c7af0f0 --- /dev/null +++ b/src/discussions/common/withEmailConfirmation.jsx @@ -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 ( + <> + + {!onlyVerifiedUsersCanPost + && ( + + )} + + ); + }; + + return EnhancedComponent; +}; + +export default withEmailConfirmation; diff --git a/src/discussions/common/withEmailConfirmation.test.jsx b/src/discussions/common/withEmailConfirmation.test.jsx new file mode 100644 index 00000000..94475d2d --- /dev/null +++ b/src/discussions/common/withEmailConfirmation.test.jsx @@ -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( + + + + + + + + + , + ); +} + +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(); + }); +}); diff --git a/src/discussions/data/selectors.js b/src/discussions/data/selectors.js index 7cbe2ec8..f2532d3e 100644 --- a/src/discussions/data/selectors.js +++ b/src/discussions/data/selectors.js @@ -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, diff --git a/src/discussions/data/slices.js b/src/discussions/data/slices.js index ccbe6bc6..1cdd862f 100644 --- a/src/discussions/data/slices.js +++ b/src/discussions/data/slices.js @@ -30,6 +30,7 @@ const configSlice = createSlice({ editReasons: [], postCloseReasons: [], enableInContext: false, + isEmailVerified: false, }, reducers: { fetchConfigRequest: (state) => ( diff --git a/src/discussions/discussions-home/DiscussionsHome.test.jsx b/src/discussions/discussions-home/DiscussionsHome.test.jsx index c68c1e01..6a5cb0f6 100644 --- a/src/discussions/discussions-home/DiscussionsHome.test.jsx +++ b/src/discussions/discussions-home/DiscussionsHome.test.jsx @@ -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`); diff --git a/src/discussions/empty-posts/EmptyPosts.jsx b/src/discussions/empty-posts/EmptyPosts.jsx index 92da03ab..a4a0f0fc 100644 --- a/src/discussions/empty-posts/EmptyPosts.jsx +++ b/src/discussions/empty-posts/EmptyPosts.jsx @@ -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)); diff --git a/src/discussions/empty-posts/EmptyTopics.jsx b/src/discussions/empty-posts/EmptyTopics.jsx index 65afc7c5..c134e71b 100644 --- a/src/discussions/empty-posts/EmptyTopics.jsx +++ b/src/discussions/empty-posts/EmptyTopics.jsx @@ -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)); diff --git a/src/discussions/in-context-topics/components/EmptyTopics.jsx b/src/discussions/in-context-topics/components/EmptyTopics.jsx index 14d63ad3..b73602b7 100644 --- a/src/discussions/in-context-topics/components/EmptyTopics.jsx +++ b/src/discussions/in-context-topics/components/EmptyTopics.jsx @@ -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)); diff --git a/src/discussions/post-comments/PostCommentsView.test.jsx b/src/discussions/post-comments/PostCommentsView.test.jsx index 61dc02e6..5f0583b5 100644 --- a/src/discussions/post-comments/PostCommentsView.test.jsx +++ b/src/discussions/post-comments/PostCommentsView.test.jsx @@ -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 () => { diff --git a/src/discussions/post-comments/comments/CommentsView.jsx b/src/discussions/post-comments/comments/CommentsView.jsx index 64ea327e..7833f4f5 100644 --- a/src/discussions/post-comments/comments/CommentsView.jsx +++ b/src/discussions/post-comments/comments/CommentsView.jsx @@ -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)); diff --git a/src/discussions/post-comments/comments/comment/Comment.jsx b/src/discussions/post-comments/comments/comment/Comment.jsx index 290bb95a..ab5ad7c4 100644 --- a/src/discussions/post-comments/comments/comment/Comment.jsx +++ b/src/discussions/post-comments/comments/comment/Comment.jsx @@ -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)} @@ -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)); diff --git a/src/discussions/posts/data/api.js b/src/discussions/posts/data/api.js index dc86c2db..e91044bc 100644 --- a/src/discussions/posts/data/api.js +++ b/src/discussions/posts/data/api.js @@ -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; +}; diff --git a/src/discussions/posts/data/api.test.js b/src/discussions/posts/data/api.test.js index ea31b31b..5203c74d 100644 --- a/src/discussions/posts/data/api.test.js +++ b/src/discussions/posts/data/api.test.js @@ -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'); + }); }); diff --git a/src/discussions/posts/data/slices.js b/src/discussions/posts/data/slices.js index ee97cd60..d17b8a8d 100644 --- a/src/discussions/posts/data/slices.js +++ b/src/discussions/posts/data/slices.js @@ -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; diff --git a/src/discussions/posts/data/thunks.js b/src/discussions/posts/data/thunks.js index e302f991..7044a6a2 100644 --- a/src/discussions/posts/data/thunks.js +++ b/src/discussions/posts/data/thunks.js @@ -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); + } + }; +} diff --git a/src/discussions/posts/post-actions-bar/PostActionsBar.jsx b/src/discussions/posts/post-actions-bar/PostActionsBar.jsx index 1dc46ca0..f2edd1d4 100644 --- a/src/discussions/posts/post-actions-bar/PostActionsBar.jsx +++ b/src/discussions/posts/post-actions-bar/PostActionsBar.jsx @@ -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 (
@@ -83,4 +90,8 @@ const PostActionsBar = () => { ); }; -export default PostActionsBar; +PostActionsBar.propTypes = { + openEmailConfirmation: PropTypes.func.isRequired, +}; + +export default React.memo(withEmailConfirmation(PostActionsBar)); diff --git a/src/discussions/posts/post-actions-bar/messages.js b/src/discussions/posts/post-actions-bar/messages.js index 325c808a..dc8fe3be 100644 --- a/src/discussions/posts/post-actions-bar/messages.js +++ b/src/discussions/posts/post-actions-bar/messages.js @@ -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; diff --git a/src/discussions/posts/post/Post.jsx b/src/discussions/posts/post/Post.jsx index 423ffe13..2b42f2c1 100644 --- a/src/discussions/posts/post/Post.jsx +++ b/src/discussions/posts/post/Post.jsx @@ -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));