Files
frontend-app-discussions/src/discussions/posts/post-editor/PostEditor.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

564 lines
20 KiB
JavaScript

import React, {
useCallback, useContext, useEffect, useRef, useState,
} from 'react';
import PropTypes from 'prop-types';
import {
Button, Form, Spinner, StatefulButton,
} from '@openedx/paragon';
import { Help, Post } from '@openedx/paragon/icons';
import { Formik } from 'formik';
import { isEmpty } from 'lodash';
import { useGoogleReCaptcha } from 'react-google-recaptcha-v3';
import { useDispatch, useSelector } from 'react-redux';
import { useLocation, useNavigate, useParams } from 'react-router-dom';
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 PostHelpPanel from '../../../components/PostHelpPanel';
import PostPreviewPanel from '../../../components/PostPreviewPanel';
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,
selectIsUserLearner,
selectModerationSettings,
selectUserHasModerationPrivileges,
selectUserIsGroupTa,
selectUserIsStaff,
} from '../../data/selectors';
import EmptyPage from '../../empty-posts/EmptyPage';
import {
selectArchivedTopics,
selectCoursewareTopics as inContextCourseware,
selectNonCoursewareIds as inContextCoursewareIds,
selectNonCoursewareTopics as inContextNonCourseware,
} from '../../in-context-topics/data/selectors';
import { selectCoursewareTopics, selectNonCoursewareIds, selectNonCoursewareTopics } from '../../topics/data/selectors';
import { updateUserDiscussionsTourByName } from '../../tours/data';
import {
discussionsPath, formikCompatibleHandler, isFormikFieldInvalid, useCommentsPagePath,
} from '../../utils';
import { hidePostEditor } from '../data';
import { selectThread } from '../data/selectors';
import { createNewThread, fetchThread, updateExistingThread } from '../data/thunks';
import messages from './messages';
import PostTypeCard from './PostTypeCard';
const PostEditor = ({
editExisting, openRestrictionDialogue,
}) => {
const intl = useIntl();
const navigate = useNavigate();
const location = useLocation();
const dispatch = useDispatch();
const editorRef = useRef(null);
const { executeRecaptcha } = useGoogleReCaptcha();
const { courseId, postId } = useParams();
const { authenticatedUser } = useContext(AppContext);
const { category, enableInContextSidebar } = useContext(DiscussionContext);
const topicId = useCurrentDiscussionTopic();
const commentsPagePath = useCommentsPagePath();
const [submitting, dispatchSubmit] = useDispatchWithState();
const [captchaError, setCaptchaError] = useState('');
const enableInContext = useSelector(selectEnableInContext);
const nonCoursewareTopics = useSelector(enableInContext ? inContextNonCourseware : selectNonCoursewareTopics);
const nonCoursewareIds = useSelector(enableInContext ? inContextCoursewareIds : selectNonCoursewareIds);
const coursewareTopics = useSelector(enableInContext ? inContextCourseware : selectCoursewareTopics);
const cohorts = useSelector(selectCourseCohorts);
const post = useSelector(editExisting ? selectThread(postId) : () => ({}));
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
const userIsGroupTa = useSelector(selectUserIsGroupTa);
const settings = useSelector(selectDivisionSettings);
const { allowAnonymous, allowAnonymousToPeers } = useSelector(selectAnonymousPostingConfig);
const { editReasons } = useSelector(selectModerationSettings);
const userIsStaff = useSelector(selectUserIsStaff);
const archivedTopics = useSelector(selectArchivedTopics);
const postEditorId = `post-editor-${editExisting ? postId : 'new'}`;
const isNotifyAllLearnersEnabled = useSelector(selectIsNotifyAllLearnersEnabled);
const captchaSettings = useSelector(selectCaptchaSettings);
const isUserLearner = useSelector(selectIsUserLearner);
const contentCreationRateLimited = useSelector(selectContentCreationRateLimited);
const shouldRequireCaptcha = !postId && captchaSettings.enabled && isUserLearner;
const canDisplayEditReason = (editExisting
&& (userHasModerationPrivileges || userIsGroupTa || userIsStaff)
&& post?.author !== authenticatedUser.username
);
const editReasonCodeValidation = canDisplayEditReason && {
editReasonCode: Yup.string().required(intl.formatMessage(messages.editReasonCodeError)),
};
const enableNotifyAllLearnersTour = useCallback((enabled) => {
const data = {
enabled,
tourName: 'notify_all_learners',
};
dispatch(updateUserDiscussionsTourByName(data));
}, []);
useEffect(() => {
enableNotifyAllLearnersTour(true);
return () => {
enableNotifyAllLearnersTour(false);
};
}, [enableNotifyAllLearnersTour]);
const canSelectCohort = useCallback((tId) => {
// If the user isn't privileged, they can't edit the cohort.
// If the topic is being edited the cohort can't be changed.
if (!userHasModerationPrivileges) {
return false;
}
if (nonCoursewareIds.includes(tId)) {
return settings.dividedCourseWideDiscussions.includes(tId);
}
const isCohorting = settings.alwaysDivideInlineDiscussions || settings.dividedInlineDiscussions.includes(tId);
return isCohorting;
}, [nonCoursewareIds, settings, userHasModerationPrivileges]);
const initialValues = {
postType: post?.type || 'discussion',
topic: post?.topicId || topicId || nonCoursewareTopics?.[0]?.id,
title: post?.title || '',
comment: post?.rawBody || '',
follow: isEmpty(post?.following) ? true : post?.following,
notifyAllLearners: false,
anonymous: allowAnonymous ? false : undefined,
anonymousToPeers: allowAnonymousToPeers ? false : undefined,
cohort: post?.cohort || 'default',
editReasonCode: post?.lastEdit?.reasonCode || (
userIsStaff && canDisplayEditReason ? 'violates-guidelines' : undefined
),
};
const hideEditor = useCallback((resetForm) => {
resetForm({ values: initialValues });
if (editExisting) {
const newLocation = discussionsPath(commentsPagePath, {
courseId,
topicId,
postId,
learnerUsername: post?.author,
category,
})(location);
navigate({ ...newLocation });
}
}, [postId, topicId, post?.author, category, editExisting, commentsPagePath, location]);
useEffect(() => {
if (contentCreationRateLimited && !postId) {
openRestrictionDialogue();
}
}, [contentCreationRateLimited, postId]);
// null stands for no cohort restriction ("All learners" option)
const selectedCohort = useCallback(
(cohort) => (
cohort === 'default' ? null : cohort),
[],
);
const submitForm = useCallback(async (values, { resetForm }) => {
let recaptchaToken;
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('');
}
if (editExisting) {
await dispatchSubmit(updateExistingThread(postId, {
topicId: values.topic,
type: values.postType,
title: values.title,
content: values.comment,
editReasonCode: values.editReasonCode || undefined,
}));
} else {
const cohort = canSelectCohort(values.topic) ? selectedCohort(values.cohort) : undefined;
await dispatchSubmit(createNewThread({
courseId,
topicId: values.topic,
type: values.postType,
title: values.title,
content: values.comment,
following: values.follow,
anonymous: allowAnonymous ? values.anonymous : undefined,
anonymousToPeers: allowAnonymousToPeers ? values.anonymousToPeers : undefined,
cohort,
enableInContextSidebar,
notifyAllLearners: values.notifyAllLearners,
...(shouldRequireCaptcha && recaptchaToken ? { recaptchaToken } : {}),
}));
}
if (editorRef.current) {
editorRef.current.plugins.autosave.removeDraft();
}
hideEditor(resetForm);
}, [
allowAnonymous, allowAnonymousToPeers, canSelectCohort, editExisting,
enableInContextSidebar, hideEditor, postId, selectedCohort, topicId, shouldRequireCaptcha, executeRecaptcha,
]);
useEffect(() => {
if (userHasModerationPrivileges && isEmpty(cohorts)) {
dispatch(fetchCourseCohorts(courseId));
}
if (editExisting) {
dispatchSubmit(fetchThread(postId, courseId));
}
}, [courseId, editExisting]);
if (editExisting && !post) {
if (submitting) {
return (
<div className="m-4 card p-4 align-items-center">
<Spinner animation="border" variant="primary" />
</div>
);
}
if (!submitting) {
return (
<EmptyPage
title={intl.formatMessage(messages.noThreadFound)}
/>
);
}
}
const validationSchema = Yup.object().shape({
postType: Yup.mixed()
.oneOf(['discussion', 'question']),
topic: Yup.string()
.required(),
title: Yup.string()
.required(intl.formatMessage(messages.titleError)),
comment: Yup.string()
.required(intl.formatMessage(messages.commentError)),
follow: Yup.bool()
.default(true),
anonymous: Yup.bool()
.default(false)
.nullable(),
anonymousToPeers: Yup.bool()
.default(false)
.nullable(),
notifyAllLearners: Yup.bool()
.default(false),
cohort: Yup.string()
.nullable()
.default(null),
...editReasonCodeValidation,
});
const handleInContextSelectLabel = (section, subsection) => (
`${section.displayName} / ${subsection.displayName}` || intl.formatMessage(messages.unnamedTopics)
);
return (
<Formik
enableReinitialize
initialValues={initialValues}
validationSchema={validationSchema}
onSubmit={submitForm}
>{({
values,
errors,
touched,
handleSubmit,
handleBlur,
handleChange,
resetForm,
}) => (
<Form className="m-4 card p-4 post-form" onSubmit={handleSubmit}>
<h4 className="mb-4 font-style" style={{ lineHeight: '16px' }}>
{editExisting
? intl.formatMessage(messages.editPostHeading)
: intl.formatMessage(messages.addPostHeading)}
</h4>
<Form.RadioSet
name="postType"
className="d-flex flex-row flex-wrap"
value={values.postType}
onChange={handleChange}
onBlur={handleBlur}
aria-label={intl.formatMessage(messages.postTitle)}
>
<PostTypeCard
value="discussion"
selected={values.postType === 'discussion'}
type={intl.formatMessage(messages.discussionType)}
icon={<Post />}
/>
<PostTypeCard
value="question"
selected={values.postType === 'question'}
type={intl.formatMessage(messages.questionType)}
icon={<Help />}
/>
</Form.RadioSet>
<div className="d-flex flex-row my-4.5 justify-content-between">
<Form.Group className="w-100 m-0">
<Form.Control
className="m-0"
name="topic"
as="select"
value={values.topic}
onChange={handleChange}
onBlur={handleBlur}
aria-describedby="topicAreaInput"
floatingLabel={intl.formatMessage(messages.topicArea)}
disabled={enableInContextSidebar}
data-testid="topic-select"
>
{nonCoursewareTopics.map(topic => (
<option
key={topic.id}
value={topic.id}
>{topic.name || intl.formatMessage(messages.unnamedSubTopics)}
</option>
))}
{enableInContext ? (
<>
{coursewareTopics?.map(section => (
section?.children?.map(subsection => (
<optgroup
label={handleInContextSelectLabel(section, subsection)}
key={subsection.id}
>
{subsection?.children?.map(unit => (
<option key={unit.id} value={unit.id}>
{unit.name || intl.formatMessage(messages.unnamedSubTopics)}
</option>
))}
</optgroup>
))
))}
{(userIsStaff || userIsGroupTa || userHasModerationPrivileges) && (
<optgroup label={intl.formatMessage(messages.archivedTopics)}>
{archivedTopics.map(topic => (
<option key={topic.id} value={topic.id}>
{topic.name || intl.formatMessage(messages.unnamedSubTopics)}
</option>
))}
</optgroup>
)}
</>
) : (
coursewareTopics.map(categoryObj => (
<optgroup
label={categoryObj.name || intl.formatMessage(messages.unnamedTopics)}
key={categoryObj.id}
>
{categoryObj.topics.map(subtopic => (
<option key={subtopic.id} value={subtopic.id}>
{subtopic.name || intl.formatMessage(messages.unnamedSubTopics)}
</option>
))}
</optgroup>
))
)}
</Form.Control>
</Form.Group>
{canSelectCohort(values.topic) && (
<Form.Group className="w-100 ml-3 mb-0">
<Form.Control
className="m-0"
name="cohort"
as="select"
value={values.cohort}
onChange={handleChange}
onBlur={handleBlur}
aria-describedby="cohortAreaInput"
floatingLabel={intl.formatMessage(messages.cohortVisibility)}
>
<option value="default">{intl.formatMessage(messages.cohortVisibilityAllLearners)}</option>
{cohorts.map(cohort => (
<option key={cohort.id} value={cohort.id}>{cohort.name}</option>
))}
</Form.Control>
</Form.Group>
)}
</div>
<div className="d-flex flex-row mb-4.5 justify-content-between">
<Form.Group
className="w-100 m-0"
isInvalid={isFormikFieldInvalid('title', {
errors,
touched,
})}
>
<Form.Control
className="m-0"
name="title"
type="text"
onChange={handleChange}
onBlur={handleBlur}
aria-describedby="titleInput"
floatingLabel={intl.formatMessage(messages.postTitle)}
value={values.title}
data-testid="post-title-input"
/>
<FormikErrorFeedback name="title" />
</Form.Group>
{canDisplayEditReason && (
<Form.Group
className="w-100 ml-4 mb-0"
isInvalid={isFormikFieldInvalid('editReasonCode', {
errors,
touched,
})}
>
<Form.Control
name="editReasonCode"
className="m-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>
)}
</div>
<div className="mb-3">
<TinyMCEEditor
onInit={
/* istanbul ignore next: TinyMCE is mocked so this cannot be easily tested */
(_, editor) => {
editorRef.current = editor;
}
}
id={postEditorId}
value={values.comment}
onEditorChange={formikCompatibleHandler(handleChange, 'comment')}
onBlur={formikCompatibleHandler(handleBlur, 'comment')}
/>
<PostHelpPanel />
<FormikErrorFeedback name="comment" />
</div>
<PostPreviewPanel htmlNode={values.comment} isPost editExisting={editExisting} />
<div className="d-flex flex-row mt-n4 w-75 text-primary font-style">
{!editExisting && (
<>
{isNotifyAllLearnersEnabled && (
<Form.Group>
<Form.Checkbox
name="notifyAllLearners"
id="notify-learners"
checked={values.notifyAllLearners}
onChange={handleChange}
onBlur={handleBlur}
className="mr-4.5"
>
<span>
{intl.formatMessage(messages.notifyAllLearners)}
</span>
</Form.Checkbox>
</Form.Group>
)}
<Form.Group>
<Form.Checkbox
name="follow"
checked={values.follow}
onChange={handleChange}
onBlur={handleBlur}
className="mr-4.5"
>
<span>
{intl.formatMessage(messages.followPost)}
</span>
</Form.Checkbox>
</Form.Group>
{allowAnonymousToPeers && (
<Form.Group>
<Form.Checkbox
name="anonymousToPeers"
checked={values.anonymousToPeers}
onChange={handleChange}
onBlur={handleBlur}
>
<span>
{intl.formatMessage(messages.anonymousToPeersPost)}
</span>
</Form.Checkbox>
</Form.Group>
)}
</>
)}
</div>
{ shouldRequireCaptcha && captchaSettings.siteKey && captchaError && (
<div className="mb-3 pgn__form-text-invalid pgn__form-text">
{captchaError}
</div>
)}
<div className="d-flex justify-content-end">
<Button
variant="outline-primary"
onClick={() => {
dispatch(hidePostEditor());
hideEditor(resetForm);
}}
>
{intl.formatMessage(messages.cancel)}
</Button>
<StatefulButton
labels={{
default: intl.formatMessage(messages.submit),
pending: intl.formatMessage(messages.submitting),
}}
state={submitting ? 'pending' : 'default'}
className="ml-2"
variant="primary"
onClick={handleSubmit}
/>
</div>
</Form>
)}
</Formik>
);
};
PostEditor.propTypes = {
editExisting: PropTypes.bool,
openRestrictionDialogue: PropTypes.func.isRequired,
};
PostEditor.defaultProps = {
editExisting: false,
};
export default React.memo(withPostingRestrictions(PostEditor));