diff --git a/src/discussions/common/Confirmation.jsx b/src/discussions/common/Confirmation.jsx index c5e7f253..b663c87d 100644 --- a/src/discussions/common/Confirmation.jsx +++ b/src/discussions/common/Confirmation.jsx @@ -53,6 +53,7 @@ const Confirmation = ({ {closeButtonText || intl.formatMessage(messages.confirmationCancel)} + {confirmAction && ( + )} diff --git a/src/discussions/common/withEmailConfirmation.jsx b/src/discussions/common/withPostingRestrictions.jsx similarity index 61% rename from src/discussions/common/withEmailConfirmation.jsx rename to src/discussions/common/withPostingRestrictions.jsx index 370fd252..96993948 100644 --- a/src/discussions/common/withEmailConfirmation.jsx +++ b/src/discussions/common/withPostingRestrictions.jsx @@ -1,21 +1,26 @@ -import React, { useCallback, useMemo, useState } from 'react'; +import React, { + useCallback, useMemo, useState, +} from 'react'; +import PropTypes from 'prop-types'; import { useDispatch, useSelector } from 'react-redux'; import { useIntl } from '@edx/frontend-platform/i18n'; import { RequestStatus } from '../../data/constants'; -import { selectConfirmEmailStatus, selectShouldShowEmailConfirmation } from '../data/selectors'; +import { selectConfirmEmailStatus, selectContentCreationRateLimited, selectShouldShowEmailConfirmation } from '../data/selectors'; +import { hidePostEditor } from '../posts/data'; import { sendAccountActivationEmail } from '../posts/data/thunks'; import postMessages from '../posts/post-actions-bar/messages'; import { Confirmation } from '.'; -const withEmailConfirmation = (WrappedComponent) => { - const EnhancedComponent = (props) => { +const withPostingRestrictions = (WrappedComponent) => { + const EnhancedComponent = ({ onCloseEditor, ...rest }) => { const intl = useIntl(); const dispatch = useDispatch(); const [isConfirming, setIsConfirming] = useState(false); const shouldShowEmailConfirmation = useSelector(selectShouldShowEmailConfirmation); + const contentCreationRateLimited = useSelector(selectContentCreationRateLimited); const confirmEmailStatus = useSelector(selectConfirmEmailStatus); const openConfirmation = useCallback(() => { @@ -24,7 +29,9 @@ const withEmailConfirmation = (WrappedComponent) => { const closeConfirmation = useCallback(() => { setIsConfirming(false); - }, []); + dispatch(hidePostEditor()); + onCloseEditor?.(); + }, [onCloseEditor]); const handleConfirmation = useCallback(() => { dispatch(sendAccountActivationEmail()); @@ -39,8 +46,9 @@ const withEmailConfirmation = (WrappedComponent) => { return ( <> {shouldShowEmailConfirmation && ( @@ -57,11 +65,26 @@ const withEmailConfirmation = (WrappedComponent) => { confirmButtonVariant="danger" /> )} + {contentCreationRateLimited + && ( + + )} ); }; + EnhancedComponent.propTypes = { + onCloseEditor: PropTypes.func, + }; + return EnhancedComponent; }; -export default withEmailConfirmation; +export default withPostingRestrictions; diff --git a/src/discussions/common/withEmailConfirmation.test.jsx b/src/discussions/common/withPostingRestrictions.test.jsx similarity index 100% rename from src/discussions/common/withEmailConfirmation.test.jsx rename to src/discussions/common/withPostingRestrictions.test.jsx diff --git a/src/discussions/data/selectors.js b/src/discussions/data/selectors.js index 22406875..d9f102a7 100644 --- a/src/discussions/data/selectors.js +++ b/src/discussions/data/selectors.js @@ -39,10 +39,14 @@ export const selectCaptchaSettings = state => state.config.captchaSettings; export const selectIsEmailVerified = state => state.config.isEmailVerified; +export const selectContentCreationRateLimited = state => state.config.contentCreationRateLimited; + export const selectOnlyVerifiedUsersCanPost = state => state.config.onlyVerifiedUsersCanPost; export const selectConfirmEmailStatus = state => state.threads.confirmEmailStatus; +export const selectPostStatus = state => state.comments.postStatus; + export const selectShouldShowEmailConfirmation = createSelector( [selectIsEmailVerified, selectOnlyVerifiedUsersCanPost], (isEmailVerified, onlyVerifiedUsersCanPost) => !isEmailVerified && onlyVerifiedUsersCanPost, diff --git a/src/discussions/data/slices.js b/src/discussions/data/slices.js index 1cdd862f..b6bc2e7f 100644 --- a/src/discussions/data/slices.js +++ b/src/discussions/data/slices.js @@ -31,6 +31,7 @@ const configSlice = createSlice({ postCloseReasons: [], enableInContext: false, isEmailVerified: false, + contentCreationRateLimited: false, }, reducers: { fetchConfigRequest: (state) => ( @@ -56,6 +57,12 @@ const configSlice = createSlice({ status: RequestStatus.DENIED, } ), + setContentCreationRateLimited: (state) => ( + { + ...state, + contentCreationRateLimited: true, + } + ), }, }); @@ -64,6 +71,7 @@ export const { fetchConfigFailed, fetchConfigRequest, fetchConfigSuccess, + setContentCreationRateLimited, } = configSlice.actions; export const configReducer = configSlice.reducer; diff --git a/src/discussions/empty-posts/EmptyPosts.jsx b/src/discussions/empty-posts/EmptyPosts.jsx index 07d8a24c..68370656 100644 --- a/src/discussions/empty-posts/EmptyPosts.jsx +++ b/src/discussions/empty-posts/EmptyPosts.jsx @@ -5,10 +5,11 @@ import { useDispatch, useSelector } from 'react-redux'; import { useIntl } from '@edx/frontend-platform/i18n'; -import withEmailConfirmation from '../common/withEmailConfirmation'; +import withPostingRestrictions from '../common/withPostingRestrictions'; import { useIsOnTablet } from '../data/hooks'; import { selectAreThreadsFiltered, + selectContentCreationRateLimited, selectPostThreadCount, selectShouldShowEmailConfirmation, } from '../data/selectors'; @@ -17,17 +18,22 @@ import { showPostEditor } from '../posts/data'; import postMessages from '../posts/post-actions-bar/messages'; import EmptyPage from './EmptyPage'; -const EmptyPosts = ({ subTitleMessage, openEmailConfirmation }) => { +const EmptyPosts = ({ subTitleMessage, openRestrictionDialogue }) => { const intl = useIntl(); const dispatch = useDispatch(); const isOnTabletorDesktop = useIsOnTablet(); const isFiltered = useSelector(selectAreThreadsFiltered); const totalThreads = useSelector(selectPostThreadCount); const shouldShowEmailConfirmation = useSelector(selectShouldShowEmailConfirmation); + const contentCreationRateLimited = useSelector(selectContentCreationRateLimited); const addPost = useCallback(() => { - if (shouldShowEmailConfirmation) { openEmailConfirmation(); } else { dispatch(showPostEditor()); } - }, [shouldShowEmailConfirmation, openEmailConfirmation]); + if (shouldShowEmailConfirmation || contentCreationRateLimited) { + openRestrictionDialogue(); + } else { + dispatch(showPostEditor()); + } + }, [shouldShowEmailConfirmation, openRestrictionDialogue, contentCreationRateLimited]); let title = messages.noPostSelected; let subTitle = null; @@ -64,7 +70,7 @@ EmptyPosts.propTypes = { defaultMessage: propTypes.string, description: propTypes.string, }).isRequired, - openEmailConfirmation: propTypes.func.isRequired, + openRestrictionDialogue: propTypes.func.isRequired, }; -export default React.memo(withEmailConfirmation(EmptyPosts)); +export default React.memo(withPostingRestrictions(EmptyPosts)); diff --git a/src/discussions/empty-posts/EmptyTopics.jsx b/src/discussions/empty-posts/EmptyTopics.jsx index db33a454..990191e4 100644 --- a/src/discussions/empty-posts/EmptyTopics.jsx +++ b/src/discussions/empty-posts/EmptyTopics.jsx @@ -6,15 +6,15 @@ import { useParams } from 'react-router-dom'; import { useIntl } from '@edx/frontend-platform/i18n'; -import withEmailConfirmation from '../common/withEmailConfirmation'; +import withPostingRestrictions from '../common/withPostingRestrictions'; import { useIsOnTablet, useTotalTopicThreadCount } from '../data/hooks'; -import { selectShouldShowEmailConfirmation, selectTopicThreadCount } from '../data/selectors'; +import { selectContentCreationRateLimited, selectShouldShowEmailConfirmation, selectTopicThreadCount } from '../data/selectors'; import messages from '../messages'; import { showPostEditor } from '../posts/data'; import postMessages from '../posts/post-actions-bar/messages'; import EmptyPage from './EmptyPage'; -const EmptyTopics = ({ openEmailConfirmation }) => { +const EmptyTopics = ({ openRestrictionDialogue }) => { const intl = useIntl(); const { topicId } = useParams(); const dispatch = useDispatch(); @@ -22,10 +22,15 @@ const EmptyTopics = ({ openEmailConfirmation }) => { const hasGlobalThreads = useTotalTopicThreadCount() > 0; const topicThreadCount = useSelector(selectTopicThreadCount(topicId)); const shouldShowEmailConfirmation = useSelector(selectShouldShowEmailConfirmation); + const contentCreationRateLimited = useSelector(selectContentCreationRateLimited); const addPost = useCallback(() => { - if (shouldShowEmailConfirmation) { openEmailConfirmation(); } else { dispatch(showPostEditor()); } - }, [shouldShowEmailConfirmation, openEmailConfirmation]); + if (shouldShowEmailConfirmation || contentCreationRateLimited) { + openRestrictionDialogue(); + } else { + dispatch(showPostEditor()); + } + }, [shouldShowEmailConfirmation, openRestrictionDialogue, contentCreationRateLimited]); let title = messages.emptyTitle; let fullWidth = false; @@ -67,7 +72,7 @@ const EmptyTopics = ({ openEmailConfirmation }) => { }; EmptyTopics.propTypes = { - openEmailConfirmation: propTypes.func.isRequired, + openRestrictionDialogue: propTypes.func.isRequired, }; -export default React.memo(withEmailConfirmation(EmptyTopics)); +export default React.memo(withPostingRestrictions(EmptyTopics)); diff --git a/src/discussions/empty-posts/EmptyTopics.test.jsx b/src/discussions/empty-posts/EmptyTopics.test.jsx index f063c9e5..9dd97622 100644 --- a/src/discussions/empty-posts/EmptyTopics.test.jsx +++ b/src/discussions/empty-posts/EmptyTopics.test.jsx @@ -13,7 +13,9 @@ import { AppProvider } from '@edx/frontend-platform/react'; import { getApiBaseUrl, Routes as ROUTES } from '../../data/constants'; import { initializeStore } from '../../store'; import executeThunk from '../../test-utils'; +import * as selectors from '../data/selectors'; import messages from '../messages'; +import { showPostEditor } from '../posts/data'; import fetchCourseTopics from '../topics/data/thunks'; import EmptyTopics from './EmptyTopics'; @@ -85,4 +87,17 @@ describe('EmptyTopics', () => { expect(screen.queryByText('Send confirmation link')).toBeInTheDocument(); }); + + it('should dispatch showPostEditor when email confirmation is not required and user clicks "Add a post"', async () => { + jest.spyOn(selectors, 'selectShouldShowEmailConfirmation').mockReturnValue(false); + + const dispatchSpy = jest.spyOn(store, 'dispatch'); + + renderComponent(`/${courseId}/topics/ncwtopic-1/`); + + const addPostButton = await screen.findByRole('button', { name: 'Add a post' }); + await userEvent.click(addPostButton); + + expect(dispatchSpy).toHaveBeenCalledWith(showPostEditor()); + }); }); diff --git a/src/discussions/in-context-topics/TopicPostsView.test.jsx b/src/discussions/in-context-topics/TopicPostsView.test.jsx index f9912322..89032fc8 100644 --- a/src/discussions/in-context-topics/TopicPostsView.test.jsx +++ b/src/discussions/in-context-topics/TopicPostsView.test.jsx @@ -1,6 +1,7 @@ import { fireEvent, render, screen, waitFor, within, } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import MockAdapter from 'axios-mock-adapter'; import { act } from 'react-dom/test-utils'; import { IntlProvider } from 'react-intl'; @@ -18,6 +19,7 @@ import { Routes as ROUTES } from '../../data/constants'; import { initializeStore } from '../../store'; import executeThunk from '../../test-utils'; import DiscussionContext from '../common/context'; +import * as selectors from '../data/selectors'; import { getThreadsApiUrl } from '../posts/data/api'; import { fetchThreads } from '../posts/data/thunks'; import { getCourseTopicsApiUrl } from './data/api'; @@ -302,4 +304,18 @@ describe('InContext Topic Posts View', () => { expect(container.querySelectorAll('.discussion-topic')).toHaveLength(3); }); }); + + it('should dispatch showPostEditor when email confirmation is not required and user clicks "Add a post"', async () => { + jest.spyOn(selectors, 'selectShouldShowEmailConfirmation').mockReturnValue(true); + jest.spyOn(selectors, 'selectConfigLoadingStatus').mockReturnValue('successful'); + + await setupTopicsMockResponse(); + await renderComponent(); + + const addPostButton = await screen.findByText('Add a post'); + await userEvent.click(addPostButton); + + const confirmationText = await screen.findByText(/send confirmation link/i); + expect(confirmationText).toBeInTheDocument(); + }); }); diff --git a/src/discussions/in-context-topics/TopicsView.test.jsx b/src/discussions/in-context-topics/TopicsView.test.jsx index fcbbb9e1..2883c5c9 100644 --- a/src/discussions/in-context-topics/TopicsView.test.jsx +++ b/src/discussions/in-context-topics/TopicsView.test.jsx @@ -19,6 +19,8 @@ import { AppProvider } from '@edx/frontend-platform/react'; import { initializeStore } from '../../store'; import executeThunk from '../../test-utils'; import DiscussionContext from '../common/context'; +import * as selectors from '../data/selectors'; +import { showPostEditor } from '../posts'; import EmptyTopics from './components/EmptyTopics'; import { getCourseTopicsApiUrl } from './data/api'; import { selectCoursewareTopics, selectNonCoursewareTopics } from './data/selectors'; @@ -270,4 +272,17 @@ describe('InContext Topics View', () => { const confirmationText = await screen.findByText(/send confirmation link/i); expect(confirmationText).toBeInTheDocument(); }); + + it('should dispatch showPostEditor when email confirmation is not required and user clicks "Add a post"', async () => { + jest.spyOn(selectors, 'selectShouldShowEmailConfirmation').mockReturnValue(false); + + const dispatchSpy = jest.spyOn(store, 'dispatch'); + + renderEmptyTopicComponent(); + + const addPostButton = await screen.findByRole('button', { name: 'Add a post' }); + await userEvent.click(addPostButton); + + expect(dispatchSpy).toHaveBeenCalledWith(showPostEditor()); + }); }); diff --git a/src/discussions/in-context-topics/components/EmptyTopics.jsx b/src/discussions/in-context-topics/components/EmptyTopics.jsx index 38b38a88..66d9c1cc 100644 --- a/src/discussions/in-context-topics/components/EmptyTopics.jsx +++ b/src/discussions/in-context-topics/components/EmptyTopics.jsx @@ -7,15 +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 withPostingRestrictions from '../../common/withPostingRestrictions'; import { useIsOnTablet } from '../../data/hooks'; -import { selectPostThreadCount, selectShouldShowEmailConfirmation } from '../../data/selectors'; +import { selectContentCreationRateLimited, selectPostThreadCount, selectShouldShowEmailConfirmation } from '../../data/selectors'; import EmptyPage from '../../empty-posts/EmptyPage'; import messages from '../../messages'; import { messages as postMessages, showPostEditor } from '../../posts'; import { selectCourseWareThreadsCount, selectTotalTopicsThreadsCount } from '../data/selectors'; -const EmptyTopics = ({ openEmailConfirmation }) => { +const EmptyTopics = ({ openRestrictionDialogue }) => { const intl = useIntl(); const { category, topicId } = useParams(); const dispatch = useDispatch(); @@ -26,10 +26,15 @@ const EmptyTopics = ({ openEmailConfirmation }) => { // hasGlobalThreads is used to determine if there are any post available in courseware and non-courseware topics const hasGlobalThreads = useSelector(selectTotalTopicsThreadsCount) > 0; const shouldShowEmailConfirmation = useSelector(selectShouldShowEmailConfirmation); + const contentCreationRateLimited = useSelector(selectContentCreationRateLimited); const addPost = useCallback(() => { - if (shouldShowEmailConfirmation) { openEmailConfirmation(); } else { dispatch(showPostEditor()); } - }, [shouldShowEmailConfirmation, openEmailConfirmation]); + if (shouldShowEmailConfirmation || contentCreationRateLimited) { + openRestrictionDialogue(); + } else { + dispatch(showPostEditor()); + } + }, [shouldShowEmailConfirmation, openRestrictionDialogue, contentCreationRateLimited]); let title = messages.emptyTitle; let fullWidth = false; @@ -79,7 +84,7 @@ const EmptyTopics = ({ openEmailConfirmation }) => { }; EmptyTopics.propTypes = { - openEmailConfirmation: PropTypes.func.isRequired, + openRestrictionDialogue: PropTypes.func.isRequired, }; -export default React.memo(withEmailConfirmation(EmptyTopics)); +export default React.memo(withPostingRestrictions(EmptyTopics)); diff --git a/src/discussions/post-comments/PostCommentsView.test.jsx b/src/discussions/post-comments/PostCommentsView.test.jsx index ace41d77..8009e592 100644 --- a/src/discussions/post-comments/PostCommentsView.test.jsx +++ b/src/discussions/post-comments/PostCommentsView.test.jsx @@ -39,6 +39,7 @@ import selectTours from '../tours/data/selectors'; import { fetchDiscussionTours } from '../tours/data/thunks'; import discussionTourFactory from '../tours/data/tours.factory'; import { getCommentsApiUrl } from './data/api'; +import * as selectors from './data/selectors'; import { fetchCommentResponses, removeComment } from './data/thunks'; import '../posts/data/__factories__'; @@ -110,6 +111,7 @@ async function setupCourseConfig( isEmailVerified = true, onlyVerifiedUsersCanPost = false, hasModerationPrivileges = true, + contentCreationRateLimited = false, ) { axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`).reply(200, { hasModerationPrivileges, @@ -124,6 +126,7 @@ async function setupCourseConfig( ], isEmailVerified, onlyVerifiedUsersCanPost, + contentCreationRateLimited, }); axiosMock.onGet(`${courseSettingsApiUrl}${courseId}/settings`).reply(200, {}); await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState); @@ -1105,6 +1108,58 @@ describe('ThreadView', () => { expect(screen.queryByText('Send confirmation link')).toBeInTheDocument(); }); + + it('should open the comment editor by clicking on add response button.', async () => { + await setupCourseConfig(true, true); + await waitFor(() => renderComponent(discussionPostId)); + + const addResponseButton = screen.getByTestId('add-response'); + + await act(async () => { fireEvent.click(addResponseButton); }); + + expect(screen.queryByTestId('tinymce-editor')).toBeInTheDocument(); + }); + + it('should open the rate limit dialogue.', async () => { + await setupCourseConfig(true, true); + axiosMock.onPost(commentsApiUrl).reply(429); + + await waitFor(() => renderComponent(discussionPostId)); + + const addResponseButton = screen.getByTestId('add-response'); + + await act(async () => { fireEvent.click(addResponseButton); }); + + await act(async () => { fireEvent.change(screen.getByTestId('tinymce-editor'), { target: { value: 'New response' } }); }); + await act(async () => { fireEvent.click(screen.getByText(/submit/i)); }); + + expect(screen.queryByText('Post limit reached')).toBeInTheDocument(); + }); + + it('should open the dialogue Post limit reached by clicking on add response button.', async () => { + await setupCourseConfig(true, true, true, true); + await waitFor(() => renderComponent(discussionPostId)); + + const addResponseButton = screen.getByTestId('add-response'); + + await act(async () => { fireEvent.click(addResponseButton); }); + + expect(screen.queryByText('Post limit reached')).toBeInTheDocument(); + }); + + it('should open the editor by clicking on add comment button.', async () => { + jest.spyOn(selectors, 'selectCommentResponses').mockImplementation(() => () => ( + ['reply-1', 'reply-2', 'reply-3', 'reply-4', 'reply-5'] + )); + + await waitFor(() => renderComponent(discussionPostId)); + + const addCommentButton = screen.getByTestId('add-comment-2'); + + await act(async () => { fireEvent.click(addCommentButton); }); + + expect(screen.queryByTestId('tinymce-editor')).toBeInTheDocument(); + }); }); describe('MockReCAPTCHA', () => { diff --git a/src/discussions/post-comments/comments/CommentsView.jsx b/src/discussions/post-comments/comments/CommentsView.jsx index 5487cb6a..b9d8b25a 100644 --- a/src/discussions/post-comments/comments/CommentsView.jsx +++ b/src/discussions/post-comments/comments/CommentsView.jsx @@ -7,21 +7,22 @@ import { useSelector } from 'react-redux'; import { useIntl } from '@edx/frontend-platform/i18n'; import { ThreadType } from '../../../data/constants'; -import withEmailConfirmation from '../../common/withEmailConfirmation'; +import withPostingRestrictions from '../../common/withPostingRestrictions'; import { useUserPostingEnabled } from '../../data/hooks'; -import { selectShouldShowEmailConfirmation } from '../../data/selectors'; +import { selectContentCreationRateLimited, selectShouldShowEmailConfirmation } from '../../data/selectors'; import { isLastElementOfList } from '../../utils'; import { usePostComments } from '../data/hooks'; import messages from '../messages'; import PostCommentsContext from '../postCommentsContext'; import { Comment, ResponseEditor } from './comment'; -const CommentsView = ({ threadType, openEmailConfirmation }) => { +const CommentsView = ({ threadType, openRestrictionDialogue }) => { const intl = useIntl(); const [addingResponse, setAddingResponse] = useState(false); const { isClosed } = useContext(PostCommentsContext); const isUserPrivilegedInPostingRestriction = useUserPostingEnabled(); const shouldShowEmailConfirmation = useSelector(selectShouldShowEmailConfirmation); + const contentCreationRateLimited = useSelector(selectContentCreationRateLimited); const { endorsedCommentsIds, @@ -32,8 +33,12 @@ const CommentsView = ({ threadType, openEmailConfirmation }) => { } = usePostComments(threadType); const handleAddResponse = useCallback(() => { - if (shouldShowEmailConfirmation) { openEmailConfirmation(); } else { setAddingResponse(true); } - }, [shouldShowEmailConfirmation, openEmailConfirmation]); + if (shouldShowEmailConfirmation || contentCreationRateLimited) { + openRestrictionDialogue(); + } else { + setAddingResponse(true); + } + }, [shouldShowEmailConfirmation, openRestrictionDialogue, contentCreationRateLimited]); const handleCloseResponseEditor = useCallback(() => { setAddingResponse(false); @@ -119,7 +124,7 @@ CommentsView.propTypes = { threadType: PropTypes.oneOf([ ThreadType.DISCUSSION, ThreadType.QUESTION, ]).isRequired, - openEmailConfirmation: PropTypes.func.isRequired, + openRestrictionDialogue: PropTypes.func.isRequired, }; -export default React.memo(withEmailConfirmation(CommentsView)); +export default React.memo(withPostingRestrictions(CommentsView)); diff --git a/src/discussions/post-comments/comments/comment/Comment.jsx b/src/discussions/post-comments/comments/comment/Comment.jsx index 99b403d4..2f03ad6b 100644 --- a/src/discussions/post-comments/comments/comment/Comment.jsx +++ b/src/discussions/post-comments/comments/comment/Comment.jsx @@ -14,10 +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 withPostingRestrictions from '../../../common/withPostingRestrictions'; import { ContentTypes } from '../../../data/constants'; import { useUserPostingEnabled } from '../../../data/hooks'; -import { selectShouldShowEmailConfirmation } from '../../../data/selectors'; +import { selectContentCreationRateLimited, selectShouldShowEmailConfirmation } from '../../../data/selectors'; import { fetchThread } from '../../../posts/data/thunks'; import LikeButton from '../../../posts/post/LikeButton'; import { useActions } from '../../../utils'; @@ -40,7 +40,7 @@ const Comment = ({ commentId, marginBottom, showFullThread = true, - openEmailConfirmation, + openRestrictionDialogue, }) => { const comment = useSelector(selectCommentOrResponseById(commentId)); const { @@ -66,6 +66,7 @@ const Comment = ({ const actions = useActions(ContentTypes.COMMENT, id); const isUserPrivilegedInPostingRestriction = useUserPostingEnabled(); const shouldShowEmailConfirmation = useSelector(selectShouldShowEmailConfirmation); + const contentCreationRateLimited = useSelector(selectContentCreationRateLimited); useEffect(() => { // If the comment has a parent comment, it won't have any children, so don't fetch them. @@ -183,7 +184,8 @@ const Comment = ({ id={id} contentType={ContentTypes.COMMENT} actionHandlers={actionHandlers} - handleResponseCommentButton={shouldShowEmailConfirmation ? openEmailConfirmation : handleAddCommentButton} + handleResponseCommentButton={shouldShowEmailConfirmation || contentCreationRateLimited + ? openRestrictionDialogue : handleAddCommentButton} addResponseCommentButtonMessage={intl.formatMessage(messages.addComment)} onLike={handleCommentLike} voted={voted} @@ -274,7 +276,9 @@ 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={shouldShowEmailConfirmation ? openEmailConfirmation : handleAddCommentReply} + data-testid="add-comment-2" + onClick={shouldShowEmailConfirmation || contentCreationRateLimited + ? openRestrictionDialogue : handleAddCommentReply} > {intl.formatMessage(messages.addComment)} @@ -291,7 +295,7 @@ Comment.propTypes = { commentId: PropTypes.string.isRequired, marginBottom: PropTypes.bool, showFullThread: PropTypes.bool, - openEmailConfirmation: PropTypes.func.isRequired, + openRestrictionDialogue: PropTypes.func.isRequired, }; Comment.defaultProps = { @@ -299,4 +303,4 @@ Comment.defaultProps = { showFullThread: true, }; -export default React.memo(withEmailConfirmation(Comment)); +export default React.memo(withPostingRestrictions(Comment)); diff --git a/src/discussions/post-comments/comments/comment/CommentEditor.jsx b/src/discussions/post-comments/comments/comment/CommentEditor.jsx index 33a87d55..ecd7c59a 100644 --- a/src/discussions/post-comments/comments/comment/CommentEditor.jsx +++ b/src/discussions/post-comments/comments/comment/CommentEditor.jsx @@ -15,12 +15,16 @@ import { AppContext } from '@edx/frontend-platform/react'; import { TinyMCEEditor } from '../../../../components'; import FormikErrorFeedback from '../../../../components/FormikErrorFeedback'; import PostPreviewPanel from '../../../../components/PostPreviewPanel'; +import { RequestStatus } from '../../../../data/constants'; import useDispatchWithState from '../../../../data/hooks'; import DiscussionContext from '../../../common/context'; +import withPostingRestrictions from '../../../common/withPostingRestrictions'; import { selectCaptchaSettings, + selectContentCreationRateLimited, selectIsUserLearner, selectModerationSettings, + selectPostStatus, selectUserHasModerationPrivileges, selectUserIsGroupTa, selectUserIsStaff, @@ -36,11 +40,13 @@ const CommentEditor = ({ edit, formClasses, onCloseEditor, + openRestrictionDialogue, }) => { const { id, threadId, parentId, rawBody, author, lastEdit, } = comment; const intl = useIntl(); + const [isSubmitting, setIsSubmitting] = useState(false); const editorRef = useRef(null); const formRef = useRef(null); const recaptchaRef = useRef(null); @@ -55,6 +61,8 @@ const CommentEditor = ({ const { addDraftContent, getDraftContent, removeDraftContent } = useDraftContent(); const captchaSettings = useSelector(selectCaptchaSettings); const isUserLearner = useSelector(selectIsUserLearner); + const contentCreationRateLimited = useSelector(selectContentCreationRateLimited); + const postStatus = useSelector(selectPostStatus); const shouldRequireCaptcha = !id && captchaSettings.enabled && isUserLearner; @@ -85,6 +93,12 @@ const CommentEditor = ({ recaptchaToken: '', }; + useEffect(() => { + if (contentCreationRateLimited) { + openRestrictionDialogue(); + } + }, [contentCreationRateLimited]); + const handleCaptchaChange = useCallback((token, setFieldValue) => { setFieldValue('recaptchaToken', token || ''); }, []); @@ -99,8 +113,7 @@ const CommentEditor = ({ if (recaptchaRef.current) { recaptchaRef.current.reset(); } - onCloseEditor(); - }, [onCloseEditor, initialValues]); + }, [initialValues]); const deleteEditorContent = useCallback(async () => { const { updatedResponses, updatedComments } = removeDraftContent(parentId, id, threadId); @@ -111,7 +124,14 @@ const CommentEditor = ({ } }, [parentId, id, threadId, setDraftComments, setDraftResponses]); + useEffect(() => { + if (postStatus === RequestStatus.SUCCESSFUL && isSubmitting) { + onCloseEditor(); + } + }, [postStatus, isSubmitting]); + const saveUpdatedComment = useCallback(async (values, { resetForm }) => { + setIsSubmitting(true); if (id) { const payload = { ...values, @@ -259,7 +279,10 @@ const CommentEditor = ({
@@ -294,6 +317,7 @@ CommentEditor.propTypes = { edit: PropTypes.bool, formClasses: PropTypes.string, onCloseEditor: PropTypes.func.isRequired, + openRestrictionDialogue: PropTypes.func.isRequired, }; CommentEditor.defaultProps = { @@ -308,4 +332,4 @@ CommentEditor.defaultProps = { formClasses: '', }; -export default React.memo(CommentEditor); +export default React.memo(withPostingRestrictions(CommentEditor)); diff --git a/src/discussions/post-comments/data/redux.test.js b/src/discussions/post-comments/data/redux.test.js index 2e419942..efd41e6c 100644 --- a/src/discussions/post-comments/data/redux.test.js +++ b/src/discussions/post-comments/data/redux.test.js @@ -7,6 +7,7 @@ import { initializeMockApp } from '@edx/frontend-platform/testing'; import { ThreadType } from '../../../data/constants'; import { initializeStore } from '../../../store'; import executeThunk from '../../../test-utils'; +import { setContentCreationRateLimited } from '../../data/slices'; import { getCommentsApiUrl } from './api'; import { addComment, editComment, fetchCommentResponses, fetchThreadComments, removeComment, @@ -155,6 +156,17 @@ describe('Comments/Responses data layer tests', () => { .toEqual(parentId); }); + test('successfully dispatches rate limit action on 429 error', async () => { + axiosMock.onPost(/comments/).reply(429); + + const dispatch = jest.fn(); + const thunk = addComment('Test comment', 'thread-123', null, false, 'recaptchaToken'); + + await thunk(dispatch); + + expect(dispatch).toHaveBeenCalledWith(setContentCreationRateLimited()); + }); + test('successfully handles comment edits', async () => { const threadId = 'test-thread'; const commentId = 'comment-1'; diff --git a/src/discussions/post-comments/data/slices.js b/src/discussions/post-comments/data/slices.js index 67d77e3c..9c6a831e 100644 --- a/src/discussions/post-comments/data/slices.js +++ b/src/discussions/post-comments/data/slices.js @@ -208,6 +208,7 @@ const commentsSlice = createSlice({ [payload.id]: payload, }, commentDraft: null, + postStatus: RequestStatus.SUCCESSFUL, }), deleteCommentRequest: (state) => ( { diff --git a/src/discussions/post-comments/data/thunks.js b/src/discussions/post-comments/data/thunks.js index 28c772b0..1650e7e5 100644 --- a/src/discussions/post-comments/data/thunks.js +++ b/src/discussions/post-comments/data/thunks.js @@ -1,6 +1,7 @@ import { camelCaseObject } from '@edx/frontend-platform'; import { logError } from '@edx/frontend-platform/logging'; +import { setContentCreationRateLimited } from '../../data/slices'; import { getHttpErrorStatus } from '../../utils'; import { deleteComment, getCommentResponses, getThreadComments, postComment, updateComment, @@ -155,6 +156,8 @@ export function addComment(comment, threadId, parentId = null, enableInContextSi } catch (error) { if (getHttpErrorStatus(error) === 403) { dispatch(postCommentDenied()); + } else if (getHttpErrorStatus(error) === 429) { + dispatch(setContentCreationRateLimited()); } else { dispatch(postCommentFailed()); } diff --git a/src/discussions/posts/data/redux.test.js b/src/discussions/posts/data/redux.test.js index 17393693..b3d81dc0 100644 --- a/src/discussions/posts/data/redux.test.js +++ b/src/discussions/posts/data/redux.test.js @@ -6,6 +6,7 @@ import { initializeMockApp } from '@edx/frontend-platform/testing'; import { initializeStore } from '../../../store'; import executeThunk from '../../../test-utils'; +import { setContentCreationRateLimited } from '../../data/slices'; import { getThreadsApiUrl } from './api'; import { createNewThread, fetchThread, fetchThreads, removeThread, updateExistingThread, @@ -152,6 +153,23 @@ describe('Threads/Posts data layer tests', () => { .toEqual(['thread-1']); }); + test('successfully handles 429 rate limit error when creating a thread', async () => { + axiosMock.onPost(threadsApiUrl).reply(429); + + const dispatch = jest.fn(); + const thunk = createNewThread({ + courseId, + topicId: 'test-topic', + type: 'discussion', + title: 'Rate Limited Thread', + content: 'This should trigger rate limit', + }); + + await thunk(dispatch); + + expect(dispatch).toHaveBeenCalledWith(setContentCreationRateLimited()); + }); + test('successfully handles thread updates', async () => { const threadId = 'thread-2'; axiosMock.onGet(threadsApiUrl) diff --git a/src/discussions/posts/data/thunks.js b/src/discussions/posts/data/thunks.js index 7044a6a2..d905502b 100644 --- a/src/discussions/posts/data/thunks.js +++ b/src/discussions/posts/data/thunks.js @@ -4,6 +4,7 @@ import { logError } from '@edx/frontend-platform/logging'; import { PostsStatusFilter, ThreadType, } from '../../../data/constants'; +import { setContentCreationRateLimited } from '../../data/slices'; import { getHttpErrorStatus } from '../../utils'; import { deleteThread, getThread, getThreads, postThread, sendEmailForAccountActivation, updateThread, @@ -22,6 +23,7 @@ import { fetchThreadsRequest, fetchThreadsSuccess, fetchThreadSuccess, + hidePostEditor, postThreadDenied, postThreadFailed, postThreadRequest, @@ -235,9 +237,12 @@ export function createNewThread({ recaptchaToken, }, enableInContextSidebar); dispatch(postThreadSuccess(camelCaseObject(data))); + dispatch(hidePostEditor()); } catch (error) { if (getHttpErrorStatus(error) === 403) { dispatch(postThreadDenied()); + } else if (getHttpErrorStatus(error) === 429) { + dispatch(setContentCreationRateLimited()); } else { dispatch(postThreadFailed()); } diff --git a/src/discussions/posts/post-actions-bar/PostActionsBar.jsx b/src/discussions/posts/post-actions-bar/PostActionsBar.jsx index b86d4fb8..11454b81 100644 --- a/src/discussions/posts/post-actions-bar/PostActionsBar.jsx +++ b/src/discussions/posts/post-actions-bar/PostActionsBar.jsx @@ -13,10 +13,11 @@ 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 withPostingRestrictions from '../../common/withPostingRestrictions'; import { useUserPostingEnabled } from '../../data/hooks'; import { selectConfigLoadingStatus, + selectContentCreationRateLimited, selectEnableInContext, selectShouldShowEmailConfirmation, } from '../../data/selectors'; @@ -27,12 +28,13 @@ import messages from './messages'; import './actionBar.scss'; -const PostActionsBar = ({ openEmailConfirmation }) => { +const PostActionsBar = ({ openRestrictionDialogue }) => { const intl = useIntl(); const dispatch = useDispatch(); const loadingStatus = useSelector(selectConfigLoadingStatus); const enableInContext = useSelector(selectEnableInContext); const shouldShowEmailConfirmation = useSelector(selectShouldShowEmailConfirmation); + const contentCreationRateLimited = useSelector(selectContentCreationRateLimited); const isUserPrivilegedInPostingRestriction = useUserPostingEnabled(); const { enableInContextSidebar, page } = useContext(DiscussionContext); @@ -41,8 +43,12 @@ const PostActionsBar = ({ openEmailConfirmation }) => { }, []); const handleAddPost = useCallback(() => { - if (shouldShowEmailConfirmation) { openEmailConfirmation(); } else { dispatch(showPostEditor()); } - }, [shouldShowEmailConfirmation, openEmailConfirmation]); + if (shouldShowEmailConfirmation || contentCreationRateLimited) { + openRestrictionDialogue(); + } else { + dispatch(showPostEditor()); + } + }, [shouldShowEmailConfirmation, openRestrictionDialogue, contentCreationRateLimited]); return (
@@ -91,7 +97,7 @@ const PostActionsBar = ({ openEmailConfirmation }) => { }; PostActionsBar.propTypes = { - openEmailConfirmation: PropTypes.func.isRequired, + openRestrictionDialogue: PropTypes.func.isRequired, }; -export default React.memo(withEmailConfirmation(PostActionsBar)); +export default React.memo(withPostingRestrictions(PostActionsBar)); diff --git a/src/discussions/posts/post-actions-bar/messages.js b/src/discussions/posts/post-actions-bar/messages.js index dc8fe3be..06c21b0e 100644 --- a/src/discussions/posts/post-actions-bar/messages.js +++ b/src/discussions/posts/post-actions-bar/messages.js @@ -71,6 +71,16 @@ const messages = defineMessages({ defaultMessage: 'Close', description: 'Close button.', }, + postLimitTitle: { + id: 'discussion.posts.limit.title', + defaultMessage: 'Post limit reached', + description: 'Confirm email title for unverified users.', + }, + postLimitDescription: { + id: 'discussion.posts.limit.description', + defaultMessage: 'You’ve reached the current post limit. Please try again later.', + description: 'Confirm email description for unverified users.', + }, }); export default messages; diff --git a/src/discussions/posts/post-editor/PostEditor.jsx b/src/discussions/posts/post-editor/PostEditor.jsx index fd927378..0f3d8b2b 100644 --- a/src/discussions/posts/post-editor/PostEditor.jsx +++ b/src/discussions/posts/post-editor/PostEditor.jsx @@ -25,10 +25,12 @@ import useDispatchWithState from '../../../data/hooks'; import selectCourseCohorts from '../../cohorts/data/selectors'; import fetchCourseCohorts from '../../cohorts/data/thunks'; import DiscussionContext from '../../common/context'; +import withPostingRestrictions from '../../common/withPostingRestrictions'; import { useCurrentDiscussionTopic } from '../../data/hooks'; import { selectAnonymousPostingConfig, selectCaptchaSettings, + selectContentCreationRateLimited, selectDivisionSettings, selectEnableInContext, selectIsNotifyAllLearnersEnabled, @@ -57,7 +59,7 @@ import messages from './messages'; import PostTypeCard from './PostTypeCard'; const PostEditor = ({ - editExisting, + editExisting, openRestrictionDialogue, }) => { const intl = useIntl(); const navigate = useNavigate(); @@ -88,6 +90,7 @@ const PostEditor = ({ const isNotifyAllLearnersEnabled = useSelector(selectIsNotifyAllLearnersEnabled); const captchaSettings = useSelector(selectCaptchaSettings); const isUserLearner = useSelector(selectIsUserLearner); + const contentCreationRateLimited = useSelector(selectContentCreationRateLimited); const canDisplayEditReason = (editExisting && (userHasModerationPrivileges || userIsGroupTa || userIsStaff) @@ -171,9 +174,14 @@ const PostEditor = ({ })(location); navigate({ ...newLocation }); } - dispatch(hidePostEditor()); }, [postId, topicId, post?.author, category, editExisting, commentsPagePath, location]); + useEffect(() => { + if (contentCreationRateLimited) { + openRestrictionDialogue(); + } + }, [contentCreationRateLimited]); + // null stands for no cohort restriction ("All learners" option) const selectedCohort = useCallback( (cohort) => ( @@ -543,7 +551,10 @@ const PostEditor = ({
@@ -566,10 +577,11 @@ const PostEditor = ({ PostEditor.propTypes = { editExisting: PropTypes.bool, + openRestrictionDialogue: PropTypes.func.isRequired, }; PostEditor.defaultProps = { editExisting: false, }; -export default React.memo(PostEditor); +export default React.memo(withPostingRestrictions(PostEditor)); diff --git a/src/discussions/posts/post-editor/PostEditor.test.jsx b/src/discussions/posts/post-editor/PostEditor.test.jsx index 9ab098ff..2781b32b 100644 --- a/src/discussions/posts/post-editor/PostEditor.test.jsx +++ b/src/discussions/posts/post-editor/PostEditor.test.jsx @@ -624,5 +624,28 @@ describe('PostEditor', () => { expect(container.querySelector('[data-testid="hide-help-button"]')).not.toBeInTheDocument(); }); }); + + it('should open the rate limit dialogue.', async () => { + axiosMock.onPost(threadsApiUrl).reply(429); + + await renderComponent(); + + await act(async () => { + fireEvent.change(screen.getByTestId('topic-select'), { + target: { value: 'ncw-topic-1' }, + }); + const postTitle = await screen.findByTestId('post-title-input'); + const tinymceEditor = await screen.findByTestId('tinymce-editor'); + + fireEvent.change(postTitle, { target: { value: 'Test Post Title' } }); + fireEvent.change(tinymceEditor, { target: { value: 'Test Post Content' } }); + }); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /submit/i })); + }); + + expect(screen.queryByText('Post limit reached')).toBeInTheDocument(); + }); }); }); diff --git a/src/discussions/posts/post/Post.jsx b/src/discussions/posts/post/Post.jsx index 3653f56b..29f38459 100644 --- a/src/discussions/posts/post/Post.jsx +++ b/src/discussions/posts/post/Post.jsx @@ -16,9 +16,9 @@ import { selectorForUnitSubsection, selectTopicContext } from '../../../data/sel import { AlertBanner, Confirmation } from '../../common'; import DiscussionContext from '../../common/context'; import HoverCard from '../../common/HoverCard'; -import withEmailConfirmation from '../../common/withEmailConfirmation'; +import withPostingRestrictions from '../../common/withPostingRestrictions'; import { ContentTypes } from '../../data/constants'; -import { selectShouldShowEmailConfirmation, selectUserHasModerationPrivileges } from '../../data/selectors'; +import { selectContentCreationRateLimited, selectShouldShowEmailConfirmation, selectUserHasModerationPrivileges } from '../../data/selectors'; import { selectTopic } from '../../topics/data/selectors'; import { truncatePath } from '../../utils'; import { selectThread } from '../data/selectors'; @@ -28,7 +28,7 @@ import messages from './messages'; import PostFooter from './PostFooter'; import PostHeader from './PostHeader'; -const Post = ({ handleAddResponseButton, openEmailConfirmation }) => { +const Post = ({ handleAddResponseButton, openRestrictionDialogue }) => { const { enableInContextSidebar, postId } = useContext(DiscussionContext); const { topicId, abuseFlagged, closed, pinned, voted, hasEndorsed, following, closedBy, voteCount, groupId, groupName, @@ -48,6 +48,7 @@ const Post = ({ handleAddResponseButton, openEmailConfirmation }) => { const [isClosing, showClosePostModal, hideClosePostModal] = useToggle(false); const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges); const shouldShowEmailConfirmation = useSelector(selectShouldShowEmailConfirmation); + const contentCreationRateLimited = useSelector(selectContentCreationRateLimited); const displayPostFooter = following || voteCount || closed || (groupId && userHasModerationPrivileges); @@ -158,7 +159,8 @@ const Post = ({ handleAddResponseButton, openEmailConfirmation }) => { id={postId} contentType={ContentTypes.POST} actionHandlers={actionHandlers} - handleResponseCommentButton={shouldShowEmailConfirmation ? openEmailConfirmation : handleAddResponseButton} + handleResponseCommentButton={shouldShowEmailConfirmation || contentCreationRateLimited + ? openRestrictionDialogue : handleAddResponseButton} addResponseCommentButtonMessage={intl.formatMessage(messages.addResponse)} onLike={handlePostLike} onFollow={handlePostFollow} @@ -238,7 +240,7 @@ const Post = ({ handleAddResponseButton, openEmailConfirmation }) => { Post.propTypes = { handleAddResponseButton: PropTypes.func.isRequired, - openEmailConfirmation: PropTypes.func.isRequired, + openRestrictionDialogue: PropTypes.func.isRequired, }; -export default React.memo(withEmailConfirmation(Post)); +export default React.memo(withPostingRestrictions(Post));