feat: added rate limit dialogue (#796)

* feat: added rate limit dialogue

* test: added test cases for post comment

* test: added test for content creation dialogue

* test: added test cases for empty topics

* test: added test cases

* feat: added content rate limit dialogue on post API for post and comment

* fix: fixed editor close issue on submit

* test: addd test cases
This commit is contained in:
sundasnoreen12
2025-08-05 12:31:12 +05:00
committed by GitHub
parent 3b527d9e60
commit a0269115b4
25 changed files with 341 additions and 62 deletions

View File

@@ -53,6 +53,7 @@ const Confirmation = ({
<ModalDialog.CloseButton variant={closeButtonVariant}>
{closeButtonText || intl.formatMessage(messages.confirmationCancel)}
</ModalDialog.CloseButton>
{confirmAction && (
<StatefulButton
labels={{
default: confirmButtonText || intl.formatMessage(messages.confirmationConfirm),
@@ -63,6 +64,7 @@ const Confirmation = ({
variant={confirmButtonVariant}
onClick={confirmAction}
/>
)}
</ActionRow>
</ModalDialog.Footer>
</>

View File

@@ -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 (
<>
<WrappedComponent
{...props}
openEmailConfirmation={openConfirmation}
{...rest}
onCloseEditor={onCloseEditor}
openRestrictionDialogue={openConfirmation}
/>
{shouldShowEmailConfirmation
&& (
@@ -57,11 +65,26 @@ const withEmailConfirmation = (WrappedComponent) => {
confirmButtonVariant="danger"
/>
)}
{contentCreationRateLimited
&& (
<Confirmation
isOpen={isConfirming}
title={intl.formatMessage(postMessages.postLimitTitle)}
description={intl.formatMessage(postMessages.postLimitDescription)}
onClose={closeConfirmation}
closeButtonText={intl.formatMessage(postMessages.closeButton)}
closeButtonVariant="danger"
/>
)}
</>
);
};
EnhancedComponent.propTypes = {
onCloseEditor: PropTypes.func,
};
return EnhancedComponent;
};
export default withEmailConfirmation;
export default withPostingRestrictions;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 = ({
<div className="d-flex py-2 justify-content-end">
<Button
variant="outline-primary"
onClick={() => handleCloseEditor(resetForm)}
onClick={() => {
onCloseEditor();
handleCloseEditor(resetForm);
}}
>
{intl.formatMessage(messages.cancel)}
</Button>
@@ -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));

View File

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

View File

@@ -208,6 +208,7 @@ const commentsSlice = createSlice({
[payload.id]: payload,
},
commentDraft: null,
postStatus: RequestStatus.SUCCESSFUL,
}),
deleteCommentRequest: (state) => (
{

View File

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

View File

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

View File

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

View File

@@ -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 (
<div className={classNames('d-flex justify-content-end flex-grow-1', { 'py-1': !enableInContextSidebar })}>
@@ -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));

View File

@@ -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: 'Youve reached the current post limit. Please try again later.',
description: 'Confirm email description for unverified users.',
},
});
export default messages;

View File

@@ -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 = ({
<div className="d-flex justify-content-end">
<Button
variant="outline-primary"
onClick={() => hideEditor(resetForm)}
onClick={() => {
dispatch(hidePostEditor());
hideEditor(resetForm);
}}
>
{intl.formatMessage(messages.cancel)}
</Button>
@@ -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));

View File

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

View File

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