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 (
); } if (!submitting) { return ( ); } } 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 ( {({ values, errors, touched, handleSubmit, handleBlur, handleChange, resetForm, }) => (

{editExisting ? intl.formatMessage(messages.editPostHeading) : intl.formatMessage(messages.addPostHeading)}

} /> } />
{nonCoursewareTopics.map(topic => ( ))} {enableInContext ? ( <> {coursewareTopics?.map(section => ( section?.children?.map(subsection => ( {subsection?.children?.map(unit => ( ))} )) ))} {(userIsStaff || userIsGroupTa || userHasModerationPrivileges) && ( {archivedTopics.map(topic => ( ))} )} ) : ( coursewareTopics.map(categoryObj => ( {categoryObj.topics.map(subtopic => ( ))} )) )} {canSelectCohort(values.topic) && ( {cohorts.map(cohort => ( ))} )}
{canDisplayEditReason && ( {editReasons.map(({ code, label }) => ( ))} )}
{ editorRef.current = editor; } } id={postEditorId} value={values.comment} onEditorChange={formikCompatibleHandler(handleChange, 'comment')} onBlur={formikCompatibleHandler(handleBlur, 'comment')} />
{!editExisting && ( <> {isNotifyAllLearnersEnabled && ( {intl.formatMessage(messages.notifyAllLearners)} )} {intl.formatMessage(messages.followPost)} {allowAnonymousToPeers && ( {intl.formatMessage(messages.anonymousToPeersPost)} )} )}
{ shouldRequireCaptcha && captchaSettings.siteKey && captchaError && (
{captchaError}
)}
)}
); }; PostEditor.propTypes = { editExisting: PropTypes.bool, openRestrictionDialogue: PropTypes.func.isRequired, }; PostEditor.defaultProps = { editExisting: false, }; export default React.memo(withPostingRestrictions(PostEditor));