Files
frontend-app-discussions/src/discussions/post-comments/comments/comment/CommentEditor.jsx
Ahtisham Shahid 28b1b1973b feat: updated v2 captcha to v3 in post editor (#803)
* feat: updated v2 captcha to v3 in post editor

* feat: added google captcha v3 for comment

* test: added test cases

* test: added test case to update the post

* test: updated test case for preview node

* test: updated test case for comment error

* test: removed mock file

* fix: removed comments

---------

Co-authored-by: sundasnoreen12 <sundasnoreen12@gmail.com>
2025-08-22 16:54:52 +05:00

310 lines
10 KiB
JavaScript

import React, {
useCallback, useContext, useEffect, useRef, useState,
} from 'react';
import PropTypes from 'prop-types';
import { Button, Form, StatefulButton } from '@openedx/paragon';
import { Formik } from 'formik';
import { useGoogleReCaptcha } from 'react-google-recaptcha-v3';
import { useSelector } from 'react-redux';
import * as Yup from 'yup';
import { useIntl } from '@edx/frontend-platform/i18n';
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,
} from '../../../data/selectors';
import { extractContent, formikCompatibleHandler, isFormikFieldInvalid } from '../../../utils';
import { useDraftContent } from '../../data/hooks';
import { setDraftComments, setDraftResponses } from '../../data/slices';
import { addComment, editComment } from '../../data/thunks';
import messages from '../../messages';
const CommentEditor = ({
comment,
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 { executeRecaptcha } = useGoogleReCaptcha();
const { authenticatedUser } = useContext(AppContext);
const { enableInContextSidebar } = useContext(DiscussionContext);
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
const userIsGroupTa = useSelector(selectUserIsGroupTa);
const userIsStaff = useSelector(selectUserIsStaff);
const { editReasons } = useSelector(selectModerationSettings);
const [submitting, dispatch] = useDispatchWithState();
const [editorContent, setEditorContent] = useState();
const { addDraftContent, getDraftContent, removeDraftContent } = useDraftContent();
const captchaSettings = useSelector(selectCaptchaSettings);
const isUserLearner = useSelector(selectIsUserLearner);
const contentCreationRateLimited = useSelector(selectContentCreationRateLimited);
const postStatus = useSelector(selectPostStatus);
const [captchaError, setCaptchaError] = useState('');
const shouldRequireCaptcha = !id && captchaSettings.enabled && isUserLearner;
const canDisplayEditReason = (edit
&& (userHasModerationPrivileges || userIsGroupTa || userIsStaff)
&& author !== authenticatedUser.username
);
const editReasonCodeValidation = canDisplayEditReason && {
editReasonCode: Yup.string().required(intl.formatMessage(messages.editReasonCodeError)),
};
const validationSchema = Yup.object().shape({
comment: Yup.string()
.required(),
...editReasonCodeValidation,
});
const initialValues = {
comment: editorContent,
editReasonCode: lastEdit?.reasonCode || (userIsStaff && canDisplayEditReason ? 'violates-guidelines' : undefined),
};
useEffect(() => {
if (contentCreationRateLimited && !id) {
openRestrictionDialogue();
}
}, [contentCreationRateLimited, id]);
const handleCloseEditor = useCallback((resetForm) => {
resetForm({ values: initialValues });
}, [initialValues]);
const deleteEditorContent = useCallback(async () => {
const { updatedResponses, updatedComments } = removeDraftContent(parentId, id, threadId);
if (parentId) {
await dispatch(setDraftComments(updatedComments));
} else {
await dispatch(setDraftResponses(updatedResponses));
}
}, [parentId, id, threadId, setDraftComments, setDraftResponses]);
useEffect(() => {
if (postStatus === RequestStatus.SUCCESSFUL && isSubmitting && !captchaError) {
onCloseEditor();
}
}, [postStatus, isSubmitting]);
const saveUpdatedComment = useCallback(async (values, { resetForm }) => {
setIsSubmitting(true);
let recaptchaToken;
if (id) {
const payload = {
...values,
editReasonCode: values.editReasonCode || undefined,
};
await dispatch(editComment(id, payload));
} else {
if (shouldRequireCaptcha && executeRecaptcha) {
try {
recaptchaToken = await executeRecaptcha('submit_post');
if (!recaptchaToken) {
setCaptchaError(intl.formatMessage(messages.captchaVerificationLabel));
return;
}
} catch (error) {
setCaptchaError(intl.formatMessage(messages.captchaVerificationLabel));
return;
}
setCaptchaError('');
}
await dispatch(addComment(values.comment, threadId, parentId, enableInContextSidebar, shouldRequireCaptcha ? recaptchaToken : ''));
}
/* istanbul ignore if: TinyMCE is mocked so this cannot be easily tested */
if (editorRef.current) {
editorRef.current.plugins.autosave.removeDraft();
}
handleCloseEditor(resetForm);
deleteEditorContent();
}, [id, threadId, parentId, enableInContextSidebar, handleCloseEditor, shouldRequireCaptcha, executeRecaptcha]);
// The editorId is used to autosave contents to localstorage. This format means that the autosave is scoped to
// the current comment id, or the current comment parent or the curren thread.
const editorId = `comment-editor-${id || parentId || threadId}`;
useEffect(() => {
if (formRef.current) {
formRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, [formRef]);
useEffect(() => {
const draftHtml = getDraftContent(parentId, threadId, id) || rawBody;
setEditorContent(draftHtml);
}, [parentId, threadId, id]);
const saveDraftContent = async (content) => {
const draftDataContent = extractContent(content);
const { updatedResponses, updatedComments } = addDraftContent(
draftDataContent,
parentId,
id,
threadId,
);
if (parentId) {
await dispatch(setDraftComments(updatedComments));
} else {
await dispatch(setDraftResponses(updatedResponses));
}
};
return (
<Formik
initialValues={initialValues}
validationSchema={validationSchema}
onSubmit={saveUpdatedComment}
enableReinitialize
>
{({
values,
errors,
touched,
handleSubmit,
handleBlur,
handleChange,
resetForm,
}) => (
<Form onSubmit={handleSubmit} className={formClasses} ref={formRef}>
{canDisplayEditReason && (
<Form.Group
isInvalid={isFormikFieldInvalid('editReasonCode', {
errors,
touched,
})}
>
<Form.Control
name="editReasonCode"
className="mt-2 mr-0"
as="select"
value={values.editReasonCode}
onChange={handleChange}
onBlur={handleBlur}
aria-describedby="editReasonCodeInput"
floatingLabel={intl.formatMessage(messages.editReasonCode)}
>
<option key="empty" value="">---</option>
{editReasons.map(({
code,
label,
}) => (
<option key={code} value={code}>{label}</option>
))}
</Form.Control>
<FormikErrorFeedback name="editReasonCode" />
</Form.Group>
)}
<TinyMCEEditor
onInit={
/* istanbul ignore next: TinyMCE is mocked so this cannot be easily tested */
(_, editor) => {
editorRef.current = editor;
}
}
id={editorId}
value={values.comment}
onEditorChange={formikCompatibleHandler(handleChange, 'comment')}
onBlur={(content) => {
formikCompatibleHandler(handleChange, 'comment');
saveDraftContent(content);
}}
/>
{isFormikFieldInvalid('comment', {
errors,
touched,
})
&& (
<Form.Control.Feedback type="invalid" hasIcon={false}>
{intl.formatMessage(messages.commentError)}
</Form.Control.Feedback>
)}
<PostPreviewPanel htmlNode={values.comment} />
{ shouldRequireCaptcha && captchaSettings.siteKey && captchaError && (
<div className="mb-3 pgn__form-text-invalid pgn__form-text">
{captchaError}
</div>
)}
<div className="d-flex py-2 justify-content-end">
<Button
variant="outline-primary"
onClick={() => {
onCloseEditor();
handleCloseEditor(resetForm);
}}
>
{intl.formatMessage(messages.cancel)}
</Button>
<StatefulButton
state={submitting ? 'pending' : null}
labels={{
default: intl.formatMessage(messages.submit),
pending: intl.formatMessage(messages.submitting),
}}
className="ml-2"
variant="primary"
onClick={handleSubmit}
/>
</div>
</Form>
)}
</Formik>
);
};
CommentEditor.propTypes = {
comment: PropTypes.shape({
author: PropTypes.string,
id: PropTypes.string,
lastEdit: PropTypes.shape({
reasonCode: PropTypes.shape({}),
}),
parentId: PropTypes.string,
rawBody: PropTypes.string,
threadId: PropTypes.string.isRequired,
}),
edit: PropTypes.bool,
formClasses: PropTypes.string,
onCloseEditor: PropTypes.func.isRequired,
openRestrictionDialogue: PropTypes.func.isRequired,
};
CommentEditor.defaultProps = {
edit: true,
comment: {
author: null,
id: null,
lastEdit: null,
parentId: null,
rawBody: '',
},
formClasses: '',
};
export default React.memo(withPostingRestrictions(CommentEditor));