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