diff --git a/assets/react/v3/@types/index.d.ts b/assets/react/v3/@types/index.d.ts index 4cc884236..315d2fd27 100644 --- a/assets/react/v3/@types/index.d.ts +++ b/assets/react/v3/@types/index.d.ts @@ -1,4 +1,46 @@ -export type {}; +import { type InjectedField } from '@CourseBuilderContexts/CourseBuilderSlotContext'; +import { type InjectionSlots } from '@TutorShared/utils/types'; + +export type { }; + +interface Tutor { + readonly CourseBuilder: { + readonly Basic: { + readonly registerField: (section: InjectionSlots['Basic'], fields: InjectedField | InjectedField[]) => void; + readonly registerContent: (section: InjectionSlots['Basic'], contents: InjectedContent) => void; + }; + readonly Curriculum: { + readonly Lesson: { + readonly registerField: ( + section: InjectionSlots['Curriculum']['Lesson'], + fields: InjectedField | InjectedField[], + ) => void; + readonly registerContent: (section: InjectionSlots['Curriculum']['Lesson'], contents: InjectedContent) => void; + }; + readonly Quiz: { + readonly registerField: ( + section: InjectionSlots['Curriculum']['Quiz'], + fields: InjectedField | InjectedField[], + ) => void; + readonly registerContent: (section: InjectionSlots['Curriculum']['Quiz'], contents: InjectedContent) => void; + }; + readonly Assignment: { + readonly registerField: ( + section: InjectionSlots['Curriculum']['Assignment'], + fields: InjectedField | InjectedField[], + ) => void; + readonly registerContent: ( + section: InjectionSlots['Curriculum']['Assignment'], + contents: InjectedContent, + ) => void; + }; + }; + readonly Additional: { + readonly registerField: (section: InjectionSlots['Additional'], fields: InjectedField | InjectedField[]) => void; + readonly registerContent: (section: InjectionSlots['Additional'], contents: InjectedContent) => void; + }; + }; +} declare module '*.png'; declare module '*.svg'; @@ -130,5 +172,6 @@ declare global { root: string; versionString: string; }; + Tutor: Tutor; } } diff --git a/assets/react/v3/entries/course-builder/components/App.tsx b/assets/react/v3/entries/course-builder/components/App.tsx index 64564b737..8e8469d6d 100644 --- a/assets/react/v3/entries/course-builder/components/App.tsx +++ b/assets/react/v3/entries/course-builder/components/App.tsx @@ -1,7 +1,8 @@ +import routes from '@CourseBuilderConfig/routes'; +import { CourseBuilderSlotProvider } from '@CourseBuilderContexts/CourseBuilderSlotContext'; import ToastProvider from '@TutorShared/atoms/Toast'; import RTLProvider from '@TutorShared/components/RTLProvider'; import { ModalProvider } from '@TutorShared/components/modals/Modal'; -import routes from '@CourseBuilderConfig/routes'; import { createGlobalCss } from '@TutorShared/utils/style-utils'; import { Global } from '@emotion/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; @@ -32,10 +33,12 @@ const App = () => { - - - {routers} - + + + + {routers} + + diff --git a/assets/react/v3/entries/course-builder/components/ContentRenderer.tsx b/assets/react/v3/entries/course-builder/components/ContentRenderer.tsx new file mode 100644 index 000000000..ea9d454df --- /dev/null +++ b/assets/react/v3/entries/course-builder/components/ContentRenderer.tsx @@ -0,0 +1,12 @@ +import ComponentErrorBoundary from '@TutorShared/components/ComponentErrorBoundary'; +import React from 'react'; + +interface ContentRendererProps { + component: React.ReactNode; +} + +const ContentRenderer = ({ component }: ContentRendererProps) => { + return {component}; +}; + +export default ContentRenderer; diff --git a/assets/react/v3/entries/course-builder/components/CourseBuilderSlot.tsx b/assets/react/v3/entries/course-builder/components/CourseBuilderSlot.tsx new file mode 100644 index 000000000..dec4f22bc --- /dev/null +++ b/assets/react/v3/entries/course-builder/components/CourseBuilderSlot.tsx @@ -0,0 +1,56 @@ +import { useCourseBuilderSlot } from '@CourseBuilderContexts/CourseBuilderSlotContext'; +import { InjectedContent, InjectedField, type SectionPath } from '@TutorShared/utils/types'; +import { type UseFormReturn } from 'react-hook-form'; +import ContentRenderer from './ContentRenderer'; +import FieldRenderer from './FieldRenderer'; + +interface CourseBuilderInjectionSlotProps { + section: SectionPath; + namePrefix?: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + form: UseFormReturn; +} + +const CourseBuilderInjectionSlot = ({ section, namePrefix, form }: CourseBuilderInjectionSlotProps) => { + const { fields, contents } = useCourseBuilderSlot(); + const getNestedFields = (): InjectedField[] => { + const parts = section.split('.'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let current: any = fields; + for (const part of parts) { + if (!current[part]) return []; + current = current[part]; + } + return Array.isArray(current) ? current : []; + }; + + const getNestedContent = (): InjectedContent[] => { + const parts = section.split('.'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let current: any = contents; + + for (const part of parts) { + if (!current[part]) return []; + current = current[part]; + } + return Array.isArray(current) ? current : []; + }; + + return ( + <> + {getNestedFields().map((props: InjectedField) => ( + + ))} + {getNestedContent().map(({ component }, index) => ( + + ))} + + ); +}; + +export default CourseBuilderInjectionSlot; diff --git a/assets/react/v3/entries/course-builder/components/FieldRenderer.tsx b/assets/react/v3/entries/course-builder/components/FieldRenderer.tsx new file mode 100644 index 000000000..bd617037f --- /dev/null +++ b/assets/react/v3/entries/course-builder/components/FieldRenderer.tsx @@ -0,0 +1,140 @@ +import Alert from '@TutorShared/atoms/Alert'; +import ComponentErrorBoundary from '@TutorShared/components/ComponentErrorBoundary'; +import FormCheckbox from '@TutorShared/components/fields/FormCheckbox'; +import FormDateInput from '@TutorShared/components/fields/FormDateInput'; +import FormFileUploader from '@TutorShared/components/fields/FormFileUploader'; +import FormImageInput from '@TutorShared/components/fields/FormImageInput'; +import FormInput from '@TutorShared/components/fields/FormInput'; +import FormRadioGroup from '@TutorShared/components/fields/FormRadioGroup'; +import FormSelectInput from '@TutorShared/components/fields/FormSelectInput'; +import FormSwitch from '@TutorShared/components/fields/FormSwitch'; +import FormTextareaInput from '@TutorShared/components/fields/FormTextareaInput'; +import FormTimeInput from '@TutorShared/components/fields/FormTimeInput'; +import FormVideoInput from '@TutorShared/components/fields/FormVideoInput'; +import FormWPEditor from '@TutorShared/components/fields/FormWPEditor'; +import { type FormControllerProps } from '@TutorShared/utils/form'; +import { FieldType, type Option } from '@TutorShared/utils/types'; +import { Controller, type RegisterOptions, type UseFormReturn } from 'react-hook-form'; + +interface FieldRendererProps { + name: string; + label?: string; + buttonText?: string; + helpText?: string; + infoText?: string; + placeholder?: string; + type: FieldType; + options?: Option[]; + defaultValue?: unknown; + rules?: Exclude; + form: UseFormReturn; +} + +const FieldRenderer = ({ + name, + label, + buttonText, + helpText, + infoText, + placeholder, + type, + options, + defaultValue, + rules, + form, +}: FieldRendererProps) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const renderField = (controllerProps: FormControllerProps) => { + const field = (() => { + switch (type) { + case 'text': + return ; + case 'number': + return ( + + ); + case 'password': + return ( + + ); + case 'textarea': + return ; + case 'select': + return ( + + ); + case 'radio': + return ; + case 'checkbox': + return ; + case 'switch': + return ; + case 'date': + return ; + case 'time': + return ; + case 'image': + return ( + + ); + case 'video': + return ( + + ); + case 'uploader': + return ; + case 'WPEditor': + return + default: + return Unsupported field type: {type}; + } + })(); + + return ( + { + console.warn(`Field ${name} failed to render:`, { error, errorInfo }); + }} + > + {field} + + ); + }; + + return ( + renderField(controllerProps)} + /> + ); +}; + +export default FieldRenderer; diff --git a/assets/react/v3/entries/course-builder/components/curriculum/QuestionConditions.tsx b/assets/react/v3/entries/course-builder/components/curriculum/QuestionConditions.tsx index 65f489697..6daa24b24 100644 --- a/assets/react/v3/entries/course-builder/components/curriculum/QuestionConditions.tsx +++ b/assets/react/v3/entries/course-builder/components/curriculum/QuestionConditions.tsx @@ -5,10 +5,7 @@ import { Controller, useFormContext } from 'react-hook-form'; import FormInput from '@TutorShared/components/fields/FormInput'; import FormSwitch from '@TutorShared/components/fields/FormSwitch'; -import SVGIcon from '@TutorShared/atoms/SVGIcon'; -import { colorTokens, spacing } from '@TutorShared/config/styles'; -import { typography } from '@TutorShared/config/typography'; -import Show from '@TutorShared/controls/Show'; +import CourseBuilderInjectionSlot from '@CourseBuilderComponents/CourseBuilderSlot'; import { useQuizModalContext } from '@CourseBuilderContexts/QuizModalContext'; import { QuizDataStatus, @@ -16,6 +13,10 @@ import { type QuizQuestionType, calculateQuizDataStatus, } from '@CourseBuilderServices/quiz'; +import SVGIcon from '@TutorShared/atoms/SVGIcon'; +import { colorTokens, spacing } from '@TutorShared/config/styles'; +import { typography } from '@TutorShared/config/typography'; +import Show from '@TutorShared/controls/Show'; import { styleUtils } from '@TutorShared/utils/style-utils'; import type { IconCollection } from '@TutorShared/utils/types'; @@ -250,6 +251,12 @@ const QuestionConditions = () => { /> )} /> + + diff --git a/assets/react/v3/entries/course-builder/components/curriculum/QuestionForm.tsx b/assets/react/v3/entries/course-builder/components/curriculum/QuestionForm.tsx index a65ef9780..439c05a13 100644 --- a/assets/react/v3/entries/course-builder/components/curriculum/QuestionForm.tsx +++ b/assets/react/v3/entries/course-builder/components/curriculum/QuestionForm.tsx @@ -30,6 +30,7 @@ import Show from '@TutorShared/controls/Show'; import { usePrevious } from '@TutorShared/hooks/usePrevious'; import { styleUtils } from '@TutorShared/utils/style-utils'; +import CourseBuilderInjectionSlot from '@CourseBuilderComponents/CourseBuilderSlot'; import emptyStateImage2x from '@SharedImages/quiz-empty-state-2x.webp'; import emptyStateImage from '@SharedImages/quiz-empty-state.webp'; @@ -146,6 +147,12 @@ const QuestionForm = () => { )} /> + + diff --git a/assets/react/v3/entries/course-builder/components/curriculum/QuizSettings.tsx b/assets/react/v3/entries/course-builder/components/curriculum/QuizSettings.tsx index 15254a0e9..fcfa41424 100644 --- a/assets/react/v3/entries/course-builder/components/curriculum/QuizSettings.tsx +++ b/assets/react/v3/entries/course-builder/components/curriculum/QuizSettings.tsx @@ -13,6 +13,7 @@ import FormSelectInput from '@TutorShared/components/fields/FormSelectInput'; import FormSwitch from '@TutorShared/components/fields/FormSwitch'; import FormTopicPrerequisites from '@TutorShared/components/fields/FormTopicPrerequisites'; +import CourseBuilderInjectionSlot from '@CourseBuilderComponents/CourseBuilderSlot'; import { useQuizModalContext } from '@CourseBuilderContexts/QuizModalContext'; import type { ContentDripType } from '@CourseBuilderServices/course'; import type { CourseTopic } from '@CourseBuilderServices/curriculum'; @@ -360,6 +361,8 @@ const QuizSettings = ({ contentDripType }: QuizSettingsProps) => { + + ); }; diff --git a/assets/react/v3/entries/course-builder/components/curriculum/TopicContent.tsx b/assets/react/v3/entries/course-builder/components/curriculum/TopicContent.tsx index ed83947f7..67251de65 100644 --- a/assets/react/v3/entries/course-builder/components/curriculum/TopicContent.tsx +++ b/assets/react/v3/entries/course-builder/components/curriculum/TopicContent.tsx @@ -159,19 +159,19 @@ const TopicContent = ({ type, topic, content, onCopy, onDelete, isOverlay = fals const exportQuizMutation = useExportQuizMutation(); const handleShowModalOrPopover = () => { - const isContentType = type as keyof typeof modalComponent; - if (modalComponent[isContentType]) { + const contentType = type as keyof typeof modalComponent; + if (modalComponent[contentType]) { showModal({ - component: modalComponent[isContentType], + component: modalComponent[contentType], props: { contentDripType: form.watch('contentDripType'), topicId: topicId, lessonId: contentId, assignmentId: contentId, quizId: contentId, - title: modalTitle[isContentType], + title: modalTitle[contentType], subtitle: sprintf(__('Topic: %s', 'tutor'), topic.title), - icon: , + icon: , ...(type === 'tutor_h5p_quiz' && { contentType: 'tutor_h5p_quiz', }), diff --git a/assets/react/v3/entries/course-builder/components/layouts/Layout.tsx b/assets/react/v3/entries/course-builder/components/layouts/Layout.tsx index 9e61a65a4..44b4cb68a 100644 --- a/assets/react/v3/entries/course-builder/components/layouts/Layout.tsx +++ b/assets/react/v3/entries/course-builder/components/layouts/Layout.tsx @@ -3,7 +3,6 @@ import { useEffect } from 'react'; import { FormProvider } from 'react-hook-form'; import { Outlet } from 'react-router-dom'; -import { Breakpoint, colorTokens, containerMaxWidth, headerHeight, spacing } from '@TutorShared/config/styles'; import Header from '@CourseBuilderComponents/layouts/header/Header'; import { CourseNavigatorProvider } from '@CourseBuilderContexts/CourseNavigatorContext'; import { @@ -13,11 +12,15 @@ import { useCourseDetailsQuery, } from '@CourseBuilderServices/course'; import { getCourseId } from '@CourseBuilderUtils/utils'; +import { Breakpoint, colorTokens, containerMaxWidth, headerHeight, spacing } from '@TutorShared/config/styles'; import { useFormWithGlobalError } from '@TutorShared/hooks/useFormWithGlobalError'; +import { useCourseBuilderSlot } from '@CourseBuilderContexts/CourseBuilderSlotContext'; +import { findSlotFields } from '@TutorShared/utils/util'; import Notebook from './Notebook'; const Layout = () => { + const { fields } = useCourseBuilderSlot(); const courseId = getCourseId(); const form = useFormWithGlobalError({ @@ -31,7 +34,10 @@ const Layout = () => { useEffect(() => { if (courseDetailsQuery.data) { const dirtyFields = Object.keys(form.formState.dirtyFields); - const convertedCourseData = convertCourseDataToFormData(courseDetailsQuery.data); + const convertedCourseData = convertCourseDataToFormData( + courseDetailsQuery.data, + findSlotFields({ fields: fields.Basic }, { fields: fields.Additional }), + ); const formValues = form.getValues(); const updatedCourseData = Object.entries(convertedCourseData).reduce>( diff --git a/assets/react/v3/entries/course-builder/components/layouts/header/HeaderActions.tsx b/assets/react/v3/entries/course-builder/components/layouts/header/HeaderActions.tsx index 546a307d6..45aa17e0b 100644 --- a/assets/react/v3/entries/course-builder/components/layouts/header/HeaderActions.tsx +++ b/assets/react/v3/entries/course-builder/components/layouts/header/HeaderActions.tsx @@ -26,8 +26,9 @@ import { spacing } from '@TutorShared/config/styles'; import Show from '@TutorShared/controls/Show'; import { styleUtils } from '@TutorShared/utils/style-utils'; import { isDefined, type WPPostStatus } from '@TutorShared/utils/types'; -import { convertToGMT, determinePostStatus, noop } from '@TutorShared/utils/util'; +import { convertToGMT, determinePostStatus, findSlotFields, noop } from '@TutorShared/utils/util'; +import { useCourseBuilderSlot } from '@CourseBuilderContexts/CourseBuilderSlotContext'; import reviewSubmitted2x from '@SharedImages/review-submitted-2x.webp'; import reviewSubmitted from '@SharedImages/review-submitted.webp'; import { useQueryClient } from '@tanstack/react-query'; @@ -35,6 +36,7 @@ import { useQueryClient } from '@tanstack/react-query'; const courseId = getCourseId(); const HeaderActions = () => { + const { fields } = useCourseBuilderSlot(); const form = useFormContext(); const navigate = useNavigate(); const { showModal } = useModal(); @@ -115,7 +117,10 @@ const HeaderActions = () => { } } - const payload = convertCourseDataToPayload(data); + const payload = convertCourseDataToPayload( + data, + findSlotFields({ fields: fields.Basic }, { fields: fields.Additional }), + ); setLocalPostStatus(postStatus); if (courseId) { diff --git a/assets/react/v3/entries/course-builder/components/modals/AssignmentModal.tsx b/assets/react/v3/entries/course-builder/components/modals/AssignmentModal.tsx index 3358f9d96..749e77aa0 100644 --- a/assets/react/v3/entries/course-builder/components/modals/AssignmentModal.tsx +++ b/assets/react/v3/entries/course-builder/components/modals/AssignmentModal.tsx @@ -18,12 +18,15 @@ import FormWPEditor from '@TutorShared/components/fields/FormWPEditor'; import type { ModalProps } from '@TutorShared/components/modals/Modal'; import ModalWrapper from '@TutorShared/components/modals/ModalWrapper'; -import type { ContentDripType } from '@CourseBuilderServices/course'; +import CourseBuilderInjectionSlot from '@CourseBuilderComponents/CourseBuilderSlot'; +import { useCourseBuilderSlot } from '@CourseBuilderContexts/CourseBuilderSlotContext'; +import { type ContentDripType } from '@CourseBuilderServices/course'; import { - type CourseTopic, + Assignment, convertAssignmentDataToPayload, useAssignmentDetailsQuery, useSaveAssignmentMutation, + type CourseTopic, } from '@CourseBuilderServices/curriculum'; import { getCourseId } from '@CourseBuilderUtils/utils'; import { tutorConfig } from '@TutorShared/config/config'; @@ -34,7 +37,7 @@ import Show from '@TutorShared/controls/Show'; import { useFormWithGlobalError } from '@TutorShared/hooks/useFormWithGlobalError'; import { type WPMedia } from '@TutorShared/hooks/useWpMedia'; import { type ID } from '@TutorShared/utils/types'; -import { isAddonEnabled, normalizeLineEndings } from '@TutorShared/utils/util'; +import { findSlotFields, isAddonEnabled, normalizeLineEndings } from '@TutorShared/utils/util'; import { maxLimitRule } from '@TutorShared/utils/validation'; interface AssignmentModalProps extends ModalProps { @@ -94,6 +97,7 @@ const AssignmentModal = ({ subtitle, contentDripType, }: AssignmentModalProps) => { + const { fields } = useCourseBuilderSlot(); const isTutorPro = !!tutorConfig.tutor_pro_url; const isOpenAiEnabled = tutorConfig.settings?.chatgpt_enable === 'on'; const getAssignmentDetailsQuery = useAssignmentDetailsQuery(assignmentId, topicId); @@ -148,6 +152,12 @@ const AssignmentModal = ({ after_xdays_of_enroll: assignmentDetails?.content_drip_settings?.after_xdays_of_enroll || '', prerequisites: assignmentDetails?.content_drip_settings?.prerequisites || [], }, + ...Object.fromEntries( + findSlotFields({ fields: fields.Curriculum.Lesson }).map((key) => [ + key, + assignmentDetails[key as keyof Assignment], + ]), + ), }, { keepDirty: false, @@ -166,7 +176,13 @@ const AssignmentModal = ({ }, [assignmentDetails]); const onSubmit = async (data: AssignmentForm) => { - const payload = convertAssignmentDataToPayload(data, assignmentId, topicId, contentDripType); + const payload = convertAssignmentDataToPayload( + data, + assignmentId, + topicId, + contentDripType, + findSlotFields({ fields: fields.Curriculum.Assignment }), + ); const response = await saveAssignmentMutation.mutateAsync(payload); if (response.status_code === 200 || response.status_code === 201) { @@ -244,6 +260,8 @@ const AssignmentModal = ({ /> )} /> + + @@ -440,6 +458,8 @@ const AssignmentModal = ({ /> )} /> + + diff --git a/assets/react/v3/entries/course-builder/components/modals/LessonModal.tsx b/assets/react/v3/entries/course-builder/components/modals/LessonModal.tsx index 145df90de..ce978c1e1 100644 --- a/assets/react/v3/entries/course-builder/components/modals/LessonModal.tsx +++ b/assets/react/v3/entries/course-builder/components/modals/LessonModal.tsx @@ -22,10 +22,13 @@ import FormWPEditor from '@TutorShared/components/fields/FormWPEditor'; import { type ModalProps, useModal } from '@TutorShared/components/modals/Modal'; import ModalWrapper from '@TutorShared/components/modals/ModalWrapper'; -import type { ContentDripType } from '@CourseBuilderServices/course'; +import CourseBuilderInjectionSlot from '@CourseBuilderComponents/CourseBuilderSlot'; +import { useCourseBuilderSlot } from '@CourseBuilderContexts/CourseBuilderSlotContext'; +import { type ContentDripType } from '@CourseBuilderServices/course'; import { - type CourseTopic, convertLessonDataToPayload, + type CourseTopic, + Lesson, useLessonDetailsQuery, useSaveLessonMutation, } from '@CourseBuilderServices/curriculum'; @@ -40,7 +43,7 @@ import { useFormWithGlobalError } from '@TutorShared/hooks/useFormWithGlobalErro import { type WPMedia } from '@TutorShared/hooks/useWpMedia'; import { styleUtils } from '@TutorShared/utils/style-utils'; import { type ID } from '@TutorShared/utils/types'; -import { isAddonEnabled, normalizeLineEndings } from '@TutorShared/utils/util'; +import { findSlotFields, isAddonEnabled, normalizeLineEndings } from '@TutorShared/utils/util'; import { maxLimitRule } from '@TutorShared/utils/validation'; import H5PContentListModal from './H5PContentListModal'; @@ -98,6 +101,8 @@ const LessonModal = ({ const { data: lessonDetails, isLoading } = getLessonDetailsQuery; const topics = queryClient.getQueryData(['Topic', courseId]) as CourseTopic[]; + const { fields } = useCourseBuilderSlot(); + const form = useFormWithGlobalError({ defaultValues: { title: '', @@ -146,6 +151,9 @@ const LessonModal = ({ after_xdays_of_enroll: lessonDetails?.content_drip_settings?.after_xdays_of_enroll || '', prerequisites: lessonDetails?.content_drip_settings?.prerequisites || [], }, + ...Object.fromEntries( + findSlotFields({ fields: fields.Curriculum.Lesson }).map((key) => [key, lessonDetails[key as keyof Lesson]]), + ), }); } @@ -166,7 +174,13 @@ const LessonModal = ({ }, [lessonDetails, isLoading]); const onSubmit = async (data: LessonForm) => { - const payload = convertLessonDataToPayload(data, lessonId, topicId, contentDripType); + const payload = convertLessonDataToPayload( + data, + lessonId, + topicId, + contentDripType, + findSlotFields({ fields: fields.Curriculum.Lesson }), + ); const response = await saveLessonMutation.mutateAsync(payload); if (response.data) { @@ -331,6 +345,8 @@ const LessonModal = ({ + + @@ -541,6 +557,8 @@ const LessonModal = ({ + + diff --git a/assets/react/v3/entries/course-builder/components/modals/QuizModal.tsx b/assets/react/v3/entries/course-builder/components/modals/QuizModal.tsx index 3e6f2169a..cf717a6d9 100644 --- a/assets/react/v3/entries/course-builder/components/modals/QuizModal.tsx +++ b/assets/react/v3/entries/course-builder/components/modals/QuizModal.tsx @@ -35,12 +35,14 @@ import { typography } from '@TutorShared/config/typography'; import Show from '@TutorShared/controls/Show'; import { styleUtils } from '@TutorShared/utils/style-utils'; -import type { ContentDripType } from '@CourseBuilderServices/course'; +import { useCourseBuilderSlot } from '@CourseBuilderContexts/CourseBuilderSlotContext'; +import { type ContentDripType } from '@CourseBuilderServices/course'; import type { ContentType } from '@CourseBuilderServices/curriculum'; import { getCourseId, validateQuizQuestion } from '@CourseBuilderUtils/utils'; import { AnimationType } from '@TutorShared/hooks/useAnimation'; import { useFormWithGlobalError } from '@TutorShared/hooks/useFormWithGlobalError'; import { type ID, isDefined } from '@TutorShared/utils/types'; +import { findSlotFields } from '@TutorShared/utils/util'; interface QuizModalProps extends ModalProps { quizId?: ID; @@ -69,6 +71,7 @@ const QuizModal = ({ contentDripType, contentType, }: QuizModalProps) => { + const { fields } = useCourseBuilderSlot(); const [isConfirmationOpen, setIsConfirmationOpen] = useState(false); const [activeTab, setActiveTab] = useState('details'); const [isEdit, setIsEdit] = useState(!isDefined(quizId)); @@ -133,7 +136,10 @@ const QuizModal = ({ return; } - const convertedData = convertQuizResponseToFormData(getQuizDetailsQuery.data); + const convertedData = convertQuizResponseToFormData( + getQuizDetailsQuery.data, + findSlotFields({ fields: fields.Curriculum.Quiz }), + ); form.reset(convertedData); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -179,7 +185,17 @@ const QuizModal = ({ } setIsEdit(false); - const payload = convertQuizFormDataToPayload(data, topicId, contentDripType, courseId); + const payload = convertQuizFormDataToPayload( + data, + topicId, + contentDripType, + courseId, + findSlotFields( + { fields: fields.Curriculum.Quiz, slotKey: 'after_question_description' }, + { fields: fields.Curriculum.Quiz, slotKey: 'bottom_of_question_sidebar' }, + ), + findSlotFields({ fields: fields.Curriculum.Quiz, slotKey: 'bottom_of_settings' }), + ); const response = await saveQuizMutation.mutateAsync(payload); diff --git a/assets/react/v3/entries/course-builder/contexts/CourseBuilderSlotContext.tsx b/assets/react/v3/entries/course-builder/contexts/CourseBuilderSlotContext.tsx new file mode 100644 index 000000000..a842ff58d --- /dev/null +++ b/assets/react/v3/entries/course-builder/contexts/CourseBuilderSlotContext.tsx @@ -0,0 +1,186 @@ +import { InjectedContent, InjectedField, type InjectionSlots, type SectionPath } from '@TutorShared/utils/types'; +import { produce } from 'immer'; +import React, { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useState } from 'react'; + +type CurriculumType = 'Lesson' | 'Quiz' | 'Assignment'; + +type SectionData = Record; +interface CurriculumData { + Lesson: SectionData; + Quiz: SectionData; + Assignment: SectionData; +} + +export interface CourseBuilderData { + Basic: SectionData; + Curriculum: CurriculumData; + Additional: SectionData; +} + +const defaultCourseBuilderState = { + fields: { + Basic: { + after_description: [], + after_settings: [], + }, + Curriculum: { + Lesson: { + after_description: [], + bottom_of_sidebar: [], + }, + Quiz: { + after_question_description: [], + bottom_of_question_sidebar: [], + bottom_of_settings: [], + }, + Assignment: { + after_description: [], + bottom_of_sidebar: [], + }, + }, + Additional: { + after_certificates: [], + bottom_of_sidebar: [], + }, + }, + contents: { + Basic: { + after_description: [], + after_settings: [], + }, + Curriculum: { + Lesson: { + after_description: [], + bottom_of_sidebar: [], + }, + Quiz: { + after_question_description: [], + bottom_of_question_sidebar: [], + bottom_of_settings: [], + }, + Assignment: { + after_description: [], + bottom_of_sidebar: [], + }, + }, + Additional: { + after_certificates: [], + bottom_of_sidebar: [], + }, + }, +}; + +type CourseBuilderContextType = { + fields: CourseBuilderData; + contents: CourseBuilderData; + registerField: (section: SectionPath, fields: InjectedField | InjectedField[]) => void; + registerContent: (section: SectionPath, content: InjectedContent) => void; +}; + +const updateSection = ( + currentState: CourseBuilderData, + section: SectionPath, + items: T[], +): CourseBuilderData => { + return produce(currentState, (draft) => { + const sectionPath = section.split('.') as [keyof InjectionSlots, CurriculumType | undefined, string]; + const [root, sub, slot] = + sectionPath.length > 2 ? sectionPath : [sectionPath[0], undefined, sectionPath[sectionPath.length - 1]]; + + const target = sub ? draft[root][sub] : draft[root]; + + // @ts-ignore + if (slot && target[slot]) { + // @ts-ignore + target[slot] = [...target[slot], ...items].sort((a, b) => (a.priority ?? 10) - (b.priority ?? 10)); + } + }); +}; + +const registerField = ( + previousFields: CourseBuilderData, + section: SectionPath, + fields: InjectedField | InjectedField[], +): CourseBuilderData => { + const items = Array.isArray(fields) ? fields : [fields]; + return updateSection(previousFields, section, items); +}; + +const registerContent = ( + previousContents: CourseBuilderData, + section: SectionPath, + content: InjectedContent, +): CourseBuilderData => { + return updateSection(previousContents, section, [content]); +}; + +const CourseBuilderSlotContext = createContext({ + fields: defaultCourseBuilderState.fields, + contents: defaultCourseBuilderState.contents, + registerField: () => {}, + registerContent: () => {}, +}); + +export const CourseBuilderSlotProvider: React.FC<{ children: ReactNode }> = ({ children }) => { + const [fields, setFields] = useState>(defaultCourseBuilderState.fields); + const [contents, setContents] = useState>(defaultCourseBuilderState.contents); + + const handleRegisterField = useCallback((section: SectionPath, fields: InjectedField | InjectedField[]) => { + setFields((prev) => registerField(prev, section, fields)); + }, []); + + const handleRegisterContent = useCallback((section: SectionPath, content: InjectedContent) => { + setContents((prev) => registerContent(prev, section, content)); + }, []); + + useEffect(() => { + const createCurriculumAPI = (type: CurriculumType) => ({ + registerField: (slot: InjectionSlots['Curriculum'][typeof type], fields: InjectedField | InjectedField[]) => + handleRegisterField(`Curriculum.${type}.${slot}` as 'Curriculum.Lesson.after_description', fields), + registerContent: (slot: InjectionSlots['Curriculum'][typeof type], content: InjectedContent) => + handleRegisterContent(`Curriculum.${type}.${slot}` as 'Curriculum.Lesson.after_description', content), + }); + + window.Tutor = { + CourseBuilder: { + Basic: { + registerField: (slot: InjectionSlots['Basic'], fields) => handleRegisterField(`Basic.${slot}`, fields), + registerContent: (slot: InjectionSlots['Basic'], contents) => + handleRegisterContent(`Basic.${slot}`, contents), + }, + Curriculum: { + Lesson: createCurriculumAPI('Lesson'), + Quiz: createCurriculumAPI('Quiz'), + Assignment: createCurriculumAPI('Assignment'), + }, + Additional: { + registerField: (slot: InjectionSlots['Additional'], fields) => + handleRegisterField(`Additional.${slot}`, fields), + registerContent: (slot: InjectionSlots['Additional'], contents) => + handleRegisterContent(`Additional.${slot}`, contents), + }, + }, + }; + }, [handleRegisterField, handleRegisterContent]); + + const contextValue = useMemo( + () => ({ + fields, + contents, + registerField: handleRegisterField, + registerContent: handleRegisterContent, + }), + [fields, contents, handleRegisterField, handleRegisterContent], + ); + + return {children}; +}; +export const useCourseBuilderSlot = () => { + const context = useContext(CourseBuilderSlotContext); + + if (!context) { + throw new Error('useCourseBuilderSlot must be used within CourseBuilderSlotProvider'); + } + + return context; +}; diff --git a/assets/react/v3/entries/course-builder/contexts/QuizModalContext.tsx b/assets/react/v3/entries/course-builder/contexts/QuizModalContext.tsx index 955d6c6d2..37d82eb42 100644 --- a/assets/react/v3/entries/course-builder/contexts/QuizModalContext.tsx +++ b/assets/react/v3/entries/course-builder/contexts/QuizModalContext.tsx @@ -78,7 +78,6 @@ export const QuizModalContextProvider = ({ const activeQuestionIndex = questions.findIndex((question) => question.question_id === activeQuestionId); - // biome-ignore lint/correctness/useExhaustiveDependencies: useEffect(() => { if (questions.length === 0) { setActiveQuestionId(''); @@ -95,6 +94,7 @@ export const QuizModalContextProvider = ({ } previousQuestions.current = questions; + // eslint-disable-next-line react-hooks/exhaustive-deps }, [questions.length]); useEffect(() => { diff --git a/assets/react/v3/entries/course-builder/pages/Additional.tsx b/assets/react/v3/entries/course-builder/pages/Additional.tsx index fb7173528..5cb880d6b 100644 --- a/assets/react/v3/entries/course-builder/pages/Additional.tsx +++ b/assets/react/v3/entries/course-builder/pages/Additional.tsx @@ -30,6 +30,8 @@ import Show from '@TutorShared/controls/Show'; import { styleUtils } from '@TutorShared/utils/style-utils'; import { isAddonEnabled } from '@TutorShared/utils/util'; + +import CourseBuilderInjectionSlot from '@CourseBuilderComponents/CourseBuilderSlot'; import attachmentsPro2x from '@SharedImages/pro-placeholders/attachments-2x.webp'; import attachmentsPro from '@SharedImages/pro-placeholders/attachments.webp'; import { LoadingSection } from '@TutorShared/atoms/LoadingSpinner'; @@ -225,6 +227,8 @@ const Additional = () => { + + @@ -298,6 +302,8 @@ const Additional = () => { + + diff --git a/assets/react/v3/entries/course-builder/pages/CourseBasic.tsx b/assets/react/v3/entries/course-builder/pages/CourseBasic.tsx index 14e7e3633..520b4da2e 100644 --- a/assets/react/v3/entries/course-builder/pages/CourseBasic.tsx +++ b/assets/react/v3/entries/course-builder/pages/CourseBasic.tsx @@ -11,6 +11,8 @@ import FormEditableAlias from '@TutorShared/components/fields/FormEditableAlias' import FormInput from '@TutorShared/components/fields/FormInput'; import FormWPEditor from '@TutorShared/components/fields/FormWPEditor'; +import CourseBuilderInjectionSlot from '@CourseBuilderComponents/CourseBuilderSlot'; +import { useCourseBuilderSlot } from '@CourseBuilderContexts/CourseBuilderSlotContext'; import { type CourseDetailsResponse, type CourseFormData, @@ -25,13 +27,14 @@ import { typography } from '@TutorShared/config/typography'; import Show from '@TutorShared/controls/Show'; import { useUnlinkPageBuilderMutation } from '@TutorShared/services/course'; import { styleUtils } from '@TutorShared/utils/style-utils'; -import { convertToSlug, determinePostStatus } from '@TutorShared/utils/util'; +import { convertToSlug, determinePostStatus, findSlotFields } from '@TutorShared/utils/util'; import { maxLimitRule, requiredRule } from '@TutorShared/utils/validation'; const courseId = getCourseId(); let hasAliasChanged = false; const CourseBasic = () => { + const { fields } = useCourseBuilderSlot(); const form = useFormContext(); const queryClient = useQueryClient(); const isCourseDetailsFetching = useIsFetching({ @@ -111,8 +114,10 @@ const CourseBasic = () => { editors={courseDetails?.editors} onCustomEditorButtonClick={() => { return form.handleSubmit((data) => { - const payload = convertCourseDataToPayload(data); - + const payload = convertCourseDataToPayload( + data, + findSlotFields({ fields: fields.Basic }, { fields: fields.Additional }), + ); return updateCourseMutation.mutateAsync({ course_id: courseId, ...payload, @@ -145,7 +150,11 @@ const CourseBasic = () => { )} /> + + + + diff --git a/assets/react/v3/entries/course-builder/services/course.ts b/assets/react/v3/entries/course-builder/services/course.ts index d28352e72..c85d841e9 100644 --- a/assets/react/v3/entries/course-builder/services/course.ts +++ b/assets/react/v3/entries/course-builder/services/course.ts @@ -463,7 +463,7 @@ interface GoogleMeetMeetingDeletePayload { 'event-id': string; } -export const convertCourseDataToPayload = (data: CourseFormData): CoursePayload => { +export const convertCourseDataToPayload = (data: CourseFormData, slot_fields: string[]): CoursePayload => { return { ...(data.isScheduleEnabled && { post_date: format( @@ -555,10 +555,18 @@ export const convertCourseDataToPayload = (data: CourseFormData): CoursePayload ) : '', 'course_settings[pause_enrollment]': data.pause_enrollment ? 'yes' : 'no', + ...Object.fromEntries( + slot_fields.map((key) => { + return [key, data[key as keyof CourseFormData]]; + }), + ), }; }; -export const convertCourseDataToFormData = (courseDetails: CourseDetailsResponse): CourseFormData => { +export const convertCourseDataToFormData = ( + courseDetails: CourseDetailsResponse, + slotFields: string[], +): CourseFormData => { return { post_date: courseDetails.post_date, post_title: courseDetails.post_title, @@ -683,6 +691,11 @@ export const convertCourseDataToFormData = (courseDetails: CourseDetailsResponse ? format(convertGMTtoLocalDate(courseDetails.course_settings.enrollment_ends_at), DateFormats.hoursMinutes) : '', pause_enrollment: courseDetails.course_settings.pause_enrollment === 'yes', + ...Object.fromEntries( + slotFields.map((key) => { + return [key, courseDetails[key as keyof CourseDetailsResponse]]; + }), + ), }; }; diff --git a/assets/react/v3/entries/course-builder/services/curriculum.ts b/assets/react/v3/entries/course-builder/services/curriculum.ts index 27ae7764b..59f284ecd 100644 --- a/assets/react/v3/entries/course-builder/services/curriculum.ts +++ b/assets/react/v3/entries/course-builder/services/curriculum.ts @@ -168,6 +168,7 @@ export const convertLessonDataToPayload = ( lessonId: ID, topicId: ID, contentDripType: ContentDripType, + slotFields: string[], ): LessonPayload => { return { ...(lessonId && { lesson_id: lessonId }), @@ -200,6 +201,11 @@ export const convertLessonDataToPayload = ( contentDripType === 'after_finishing_prerequisites' && { 'content_drip_settings[prerequisites]': data.content_drip_settings.prerequisites || [], }), + ...Object.fromEntries( + slotFields.map((key) => { + return [key, data[key as keyof LessonForm] || '']; + }), + ), }; }; @@ -208,6 +214,7 @@ export const convertAssignmentDataToPayload = ( assignmentId: ID, topicId: ID, contentDripType: ContentDripType, + slotFields: string[], ): AssignmentPayload => { return { ...(assignmentId && { assignment_id: assignmentId }), @@ -234,6 +241,11 @@ export const convertAssignmentDataToPayload = ( contentDripType === 'after_finishing_prerequisites' && { 'content_drip_settings[prerequisites]': data.content_drip_settings.prerequisites || [], }), + ...Object.fromEntries( + slotFields.map((key) => { + return [key, data[key as keyof AssignmentForm] || '']; + }), + ), }; }; diff --git a/assets/react/v3/entries/course-builder/services/quiz.ts b/assets/react/v3/entries/course-builder/services/quiz.ts index c5376e2cc..a849a372c 100644 --- a/assets/react/v3/entries/course-builder/services/quiz.ts +++ b/assets/react/v3/entries/course-builder/services/quiz.ts @@ -207,7 +207,7 @@ export interface H5PContentResponse { output: H5PContent[]; } -export const convertQuizResponseToFormData = (quiz: QuizDetailsResponse): QuizForm => { +export const convertQuizResponseToFormData = (quiz: QuizDetailsResponse, slotFields: string[]): QuizForm => { const calculateQuizDataStatus = (answer: QuizQuestionOption) => { if (answer.image_url) { return answer.answer_view_format === 'text_image' ? QuizDataStatus.NO_CHANGE : QuizDataStatus.UPDATE; @@ -332,6 +332,7 @@ export const convertQuizResponseToFormData = (quiz: QuizDetailsResponse): QuizFo questions: (quiz.questions || []).map((question) => convertedQuestion(question)), deleted_question_ids: [], deleted_answer_ids: [], + ...Object.fromEntries(slotFields.map((key) => [key, quiz[key as keyof QuizDetailsResponse]])), }; }; @@ -340,6 +341,8 @@ export const convertQuizFormDataToPayload = ( topicId: ID, contentDripType: ContentDripType, courseId: ID, + questionsSlotFields: string[], + settingsSlotFields: string[], ): QuizPayload => { return { course_id: courseId, @@ -440,6 +443,7 @@ export const convertQuizFormDataToPayload = ( answer_order: answer.answer_order, }) as QuizQuestionOption, ), + ...Object.fromEntries(questionsSlotFields.map((key) => [key, question[key as keyof QuizQuestion]])), }; }), }, @@ -458,6 +462,7 @@ export const convertQuizFormDataToPayload = ( contentDripType === 'after_finishing_prerequisites' && { 'content_drip_settings[prerequisites]': formData.quiz_option.content_drip_settings.prerequisites, }), + ...Object.fromEntries(settingsSlotFields.map((key) => [key, formData[key as keyof QuizForm]])), }; }; diff --git a/assets/react/v3/shared/components/ComponentErrorBoundary.tsx b/assets/react/v3/shared/components/ComponentErrorBoundary.tsx new file mode 100644 index 000000000..6bf50af3a --- /dev/null +++ b/assets/react/v3/shared/components/ComponentErrorBoundary.tsx @@ -0,0 +1,54 @@ +import Alert from '@TutorShared/atoms/Alert'; +import { Component, type ErrorInfo, type ReactNode } from 'react'; + +interface ComponentErrorBoundaryProps { + children: ReactNode; + componentName?: string; + fallback?: ReactNode; + showError?: boolean; + onError?: (error: Error, errorInfo: ErrorInfo) => void; +} + +interface ComponentErrorBoundaryState { + hasError: boolean; + error: Error | null; +} + +class ComponentErrorBoundary extends Component { + state: ComponentErrorBoundaryState = { hasError: false, error: null }; + + static defaultProps = { + showError: true, + componentName: 'Component', + }; + + static getDerivedStateFromError(error: Error) { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo) { + console.error(`Error rendering ${this.props.componentName}:`, error, errorInfo); + this.props.onError?.(error, errorInfo); + } + + render() { + const { children, fallback, showError } = this.props; + const { hasError, error } = this.state; + + if (hasError) { + if (fallback) { + return fallback; + } + + return showError ? ( + + Error rendering {this.props.componentName}: {error?.message || error?.toString()} + + ) : null; + } + + return children; + } +} + +export default ComponentErrorBoundary; diff --git a/assets/react/v3/shared/utils/types.ts b/assets/react/v3/shared/utils/types.ts index 3da84441f..cdaf75411 100644 --- a/assets/react/v3/shared/utils/types.ts +++ b/assets/react/v3/shared/utils/types.ts @@ -1,6 +1,7 @@ import type collection from '@TutorShared/config/icon-list'; import type { AxiosError, AxiosResponse } from 'axios'; import type { ReactNode } from 'react'; +import { RegisterOptions } from 'react-hook-form'; export type CourseProgressSteps = 'basic' | 'curriculum' | 'additional' | 'certificate'; @@ -149,6 +150,61 @@ export interface TutorCategory { filter: string; } +export type InjectionSlots = { + Basic: 'after_description' | 'after_settings'; + Curriculum: { + Lesson: 'after_description' | 'bottom_of_sidebar'; + Quiz: 'after_question_description' | 'bottom_of_question_sidebar' | 'bottom_of_settings'; + Assignment: 'after_description' | 'bottom_of_sidebar'; + }; + Additional: 'after_certificates' | 'bottom_of_sidebar'; +}; + +export type SectionStructure = { + [K in keyof InjectionSlots]: K extends 'Curriculum' + ? { [C in keyof InjectionSlots[K]]: `${C & string}.${InjectionSlots[K][C] & string}` } + : `${K}.${InjectionSlots[K] & string}`; +}; + +type Path = T extends object + ? { + [K in keyof T]: T[K] extends object ? `${string & K}.${Path & string}` : T[K]; + }[keyof T] + : never; + +export type SectionPath = Path; + +export type FieldType = + | 'text' + | 'number' + | 'password' + | 'textarea' + | 'select' + | 'radio' + | 'checkbox' + | 'switch' + | 'date' + | 'time' + | 'image' + | 'video' + | 'uploader' + | 'WPEditor'; + +export interface InjectedField { + name: string; + type: FieldType; + options?: Array<{ label: string; value: string }>; + label?: string; + placeholder?: string; + rules?: Exclude; + priority?: number; +} + +export interface InjectedContent { + component: ReactNode; + priority?: number; +} + export interface Editor { label: string; link: string; diff --git a/assets/react/v3/shared/utils/util.ts b/assets/react/v3/shared/utils/util.ts index d5262455a..bc6527ddb 100644 --- a/assets/react/v3/shared/utils/util.ts +++ b/assets/react/v3/shared/utils/util.ts @@ -24,6 +24,7 @@ import { type Addons, DateFormats } from '@TutorShared/config/constants'; import type { ErrorResponse } from '@TutorShared/utils/form'; import { type IconCollection, + InjectedField, type PaginatedParams, type WPPostStatus, isDefined, @@ -433,6 +434,25 @@ export const convertToSlug = (value: string): string => { .replace(/^-+|-+$/g, ''); // Trim leading and trailing dashes }; +export const findSlotFields = (...fieldArgs: { fields: Record; slotKey?: string }[]) => { + const slotFields: string[] = []; + fieldArgs.forEach((arg) => { + if (arg.slotKey) { + arg.fields[arg.slotKey].forEach((i) => { + slotFields.push(i.name); + }); + } else { + Object.keys(arg.fields).forEach((i) => { + arg.fields[i].forEach((j) => { + slotFields.push(j.name); + }); + }); + } + }); + + return slotFields; +} + type DateFnsLocaleKey = keyof typeof dateFnsLocales; export const convertWordPressLocaleToDateFns = (wpLocale: string): (typeof dateFnsLocales)[DateFnsLocaleKey] => { diff --git a/classes/Quiz.php b/classes/Quiz.php index a4d2f3717..d8708dc31 100644 --- a/classes/Quiz.php +++ b/classes/Quiz.php @@ -1133,6 +1133,8 @@ public function ajax_quiz_details() { $data = QuizModel::get_quiz_details( $quiz_id ); + $data = apply_filters( 'tutor_quiz_details_response', $data, $quiz_id ); + $this->json_response( __( 'Quiz data fetched successfully', 'tutor' ), $data