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:
sundasnoreen12
2025-07-23 17:24:57 +05:00
committed by GitHub
parent 750720f648
commit 76da74ae20
19 changed files with 379 additions and 39 deletions

View File

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

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

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

View File

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

View File

@@ -30,6 +30,7 @@ const configSlice = createSlice({
editReasons: [],
postCloseReasons: [],
enableInContext: false,
isEmailVerified: false,
},
reducers: {
fetchConfigRequest: (state) => (

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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: 'Youll 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\nCant 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;

View File

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