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