Skip to content

Commit f0c4d4c

Browse files
Merge pull request #1579 from themeum/course-builder-extentions
Course builder extensions
2 parents 47ef700 + bb6f1e2 commit f0c4d4c

25 files changed

+736
-37
lines changed

assets/react/v3/@types/index.d.ts

+44-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,46 @@
1-
export type {};
1+
import { type InjectedField } from '@CourseBuilderContexts/CourseBuilderSlotContext';
2+
import { type InjectionSlots } from '@TutorShared/utils/types';
3+
4+
export type { };
5+
6+
interface Tutor {
7+
readonly CourseBuilder: {
8+
readonly Basic: {
9+
readonly registerField: (section: InjectionSlots['Basic'], fields: InjectedField | InjectedField[]) => void;
10+
readonly registerContent: (section: InjectionSlots['Basic'], contents: InjectedContent) => void;
11+
};
12+
readonly Curriculum: {
13+
readonly Lesson: {
14+
readonly registerField: (
15+
section: InjectionSlots['Curriculum']['Lesson'],
16+
fields: InjectedField | InjectedField[],
17+
) => void;
18+
readonly registerContent: (section: InjectionSlots['Curriculum']['Lesson'], contents: InjectedContent) => void;
19+
};
20+
readonly Quiz: {
21+
readonly registerField: (
22+
section: InjectionSlots['Curriculum']['Quiz'],
23+
fields: InjectedField | InjectedField[],
24+
) => void;
25+
readonly registerContent: (section: InjectionSlots['Curriculum']['Quiz'], contents: InjectedContent) => void;
26+
};
27+
readonly Assignment: {
28+
readonly registerField: (
29+
section: InjectionSlots['Curriculum']['Assignment'],
30+
fields: InjectedField | InjectedField[],
31+
) => void;
32+
readonly registerContent: (
33+
section: InjectionSlots['Curriculum']['Assignment'],
34+
contents: InjectedContent,
35+
) => void;
36+
};
37+
};
38+
readonly Additional: {
39+
readonly registerField: (section: InjectionSlots['Additional'], fields: InjectedField | InjectedField[]) => void;
40+
readonly registerContent: (section: InjectionSlots['Additional'], contents: InjectedContent) => void;
41+
};
42+
};
43+
}
244

345
declare module '*.png';
446
declare module '*.svg';
@@ -130,5 +172,6 @@ declare global {
130172
root: string;
131173
versionString: string;
132174
};
175+
Tutor: Tutor;
133176
}
134177
}

assets/react/v3/entries/course-builder/components/App.tsx

+8-5
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1+
import routes from '@CourseBuilderConfig/routes';
2+
import { CourseBuilderSlotProvider } from '@CourseBuilderContexts/CourseBuilderSlotContext';
13
import ToastProvider from '@TutorShared/atoms/Toast';
24
import RTLProvider from '@TutorShared/components/RTLProvider';
35
import { ModalProvider } from '@TutorShared/components/modals/Modal';
4-
import routes from '@CourseBuilderConfig/routes';
56
import { createGlobalCss } from '@TutorShared/utils/style-utils';
67
import { Global } from '@emotion/react';
78
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
@@ -32,10 +33,12 @@ const App = () => {
3233
<RTLProvider>
3334
<QueryClientProvider client={queryClient}>
3435
<ToastProvider position="bottom-center">
35-
<ModalProvider>
36-
<Global styles={createGlobalCss()} />
37-
{routers}
38-
</ModalProvider>
36+
<CourseBuilderSlotProvider>
37+
<ModalProvider>
38+
<Global styles={createGlobalCss()} />
39+
{routers}
40+
</ModalProvider>
41+
</CourseBuilderSlotProvider>
3942
</ToastProvider>
4043
</QueryClientProvider>
4144
</RTLProvider>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import ComponentErrorBoundary from '@TutorShared/components/ComponentErrorBoundary';
2+
import React from 'react';
3+
4+
interface ContentRendererProps {
5+
component: React.ReactNode;
6+
}
7+
8+
const ContentRenderer = ({ component }: ContentRendererProps) => {
9+
return <ComponentErrorBoundary componentName={'content'}>{component}</ComponentErrorBoundary>;
10+
};
11+
12+
export default ContentRenderer;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { useCourseBuilderSlot } from '@CourseBuilderContexts/CourseBuilderSlotContext';
2+
import { InjectedContent, InjectedField, type SectionPath } from '@TutorShared/utils/types';
3+
import { type UseFormReturn } from 'react-hook-form';
4+
import ContentRenderer from './ContentRenderer';
5+
import FieldRenderer from './FieldRenderer';
6+
7+
interface CourseBuilderInjectionSlotProps {
8+
section: SectionPath;
9+
namePrefix?: string;
10+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
11+
form: UseFormReturn<any>;
12+
}
13+
14+
const CourseBuilderInjectionSlot = ({ section, namePrefix, form }: CourseBuilderInjectionSlotProps) => {
15+
const { fields, contents } = useCourseBuilderSlot();
16+
const getNestedFields = (): InjectedField[] => {
17+
const parts = section.split('.');
18+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
19+
let current: any = fields;
20+
for (const part of parts) {
21+
if (!current[part]) return [];
22+
current = current[part];
23+
}
24+
return Array.isArray(current) ? current : [];
25+
};
26+
27+
const getNestedContent = (): InjectedContent[] => {
28+
const parts = section.split('.');
29+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
30+
let current: any = contents;
31+
32+
for (const part of parts) {
33+
if (!current[part]) return [];
34+
current = current[part];
35+
}
36+
return Array.isArray(current) ? current : [];
37+
};
38+
39+
return (
40+
<>
41+
{getNestedFields().map((props: InjectedField) => (
42+
<FieldRenderer
43+
key={props.name}
44+
form={form}
45+
{...props}
46+
name={namePrefix ? `${namePrefix}${props.name}` : props.name}
47+
/>
48+
))}
49+
{getNestedContent().map(({ component }, index) => (
50+
<ContentRenderer key={index} component={component} />
51+
))}
52+
</>
53+
);
54+
};
55+
56+
export default CourseBuilderInjectionSlot;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import Alert from '@TutorShared/atoms/Alert';
2+
import ComponentErrorBoundary from '@TutorShared/components/ComponentErrorBoundary';
3+
import FormCheckbox from '@TutorShared/components/fields/FormCheckbox';
4+
import FormDateInput from '@TutorShared/components/fields/FormDateInput';
5+
import FormFileUploader from '@TutorShared/components/fields/FormFileUploader';
6+
import FormImageInput from '@TutorShared/components/fields/FormImageInput';
7+
import FormInput from '@TutorShared/components/fields/FormInput';
8+
import FormRadioGroup from '@TutorShared/components/fields/FormRadioGroup';
9+
import FormSelectInput from '@TutorShared/components/fields/FormSelectInput';
10+
import FormSwitch from '@TutorShared/components/fields/FormSwitch';
11+
import FormTextareaInput from '@TutorShared/components/fields/FormTextareaInput';
12+
import FormTimeInput from '@TutorShared/components/fields/FormTimeInput';
13+
import FormVideoInput from '@TutorShared/components/fields/FormVideoInput';
14+
import FormWPEditor from '@TutorShared/components/fields/FormWPEditor';
15+
import { type FormControllerProps } from '@TutorShared/utils/form';
16+
import { FieldType, type Option } from '@TutorShared/utils/types';
17+
import { Controller, type RegisterOptions, type UseFormReturn } from 'react-hook-form';
18+
19+
interface FieldRendererProps {
20+
name: string;
21+
label?: string;
22+
buttonText?: string;
23+
helpText?: string;
24+
infoText?: string;
25+
placeholder?: string;
26+
type: FieldType;
27+
options?: Option<string | number>[];
28+
defaultValue?: unknown;
29+
rules?: Exclude<RegisterOptions, 'valueAsNumber' | 'valueAsDate' | 'setValueAs'>;
30+
form: UseFormReturn;
31+
}
32+
33+
const FieldRenderer = ({
34+
name,
35+
label,
36+
buttonText,
37+
helpText,
38+
infoText,
39+
placeholder,
40+
type,
41+
options,
42+
defaultValue,
43+
rules,
44+
form,
45+
}: FieldRendererProps) => {
46+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
47+
const renderField = (controllerProps: FormControllerProps<any>) => {
48+
const field = (() => {
49+
switch (type) {
50+
case 'text':
51+
return <FormInput {...controllerProps} label={label} placeholder={placeholder} helpText={helpText} />;
52+
case 'number':
53+
return (
54+
<FormInput {...controllerProps} type="number" label={label} placeholder={placeholder} helpText={helpText} />
55+
);
56+
case 'password':
57+
return (
58+
<FormInput
59+
{...controllerProps}
60+
type="password"
61+
label={label}
62+
placeholder={placeholder}
63+
helpText={helpText}
64+
/>
65+
);
66+
case 'textarea':
67+
return <FormTextareaInput {...controllerProps} label={label} placeholder={placeholder} helpText={helpText} />;
68+
case 'select':
69+
return (
70+
<FormSelectInput
71+
{...controllerProps}
72+
label={label}
73+
options={options || []}
74+
placeholder={placeholder}
75+
helpText={helpText}
76+
/>
77+
);
78+
case 'radio':
79+
return <FormRadioGroup {...controllerProps} label={label} options={options || []} />;
80+
case 'checkbox':
81+
return <FormCheckbox {...controllerProps} label={label} />;
82+
case 'switch':
83+
return <FormSwitch {...controllerProps} label={label} helpText={helpText} />;
84+
case 'date':
85+
return <FormDateInput {...controllerProps} label={label} placeholder={placeholder} helpText={helpText} />;
86+
case 'time':
87+
return <FormTimeInput {...controllerProps} label={label} placeholder={placeholder} helpText={helpText} />;
88+
case 'image':
89+
return (
90+
<FormImageInput
91+
{...controllerProps}
92+
label={label}
93+
buttonText={buttonText}
94+
helpText={helpText}
95+
infoText={infoText}
96+
/>
97+
);
98+
case 'video':
99+
return (
100+
<FormVideoInput
101+
{...controllerProps}
102+
label={label}
103+
buttonText={buttonText}
104+
helpText={helpText}
105+
infoText={infoText}
106+
/>
107+
);
108+
case 'uploader':
109+
return <FormFileUploader {...controllerProps} label={label} buttonText={buttonText} helpText={helpText} />;
110+
case 'WPEditor':
111+
return <FormWPEditor {...controllerProps} label={label} placeholder={placeholder} helpText={helpText} />
112+
default:
113+
return <Alert type="danger">Unsupported field type: {type}</Alert>;
114+
}
115+
})();
116+
117+
return (
118+
<ComponentErrorBoundary
119+
componentName={`field ${name}`}
120+
onError={(error, errorInfo) => {
121+
console.warn(`Field ${name} failed to render:`, { error, errorInfo });
122+
}}
123+
>
124+
{field}
125+
</ComponentErrorBoundary>
126+
);
127+
};
128+
129+
return (
130+
<Controller
131+
name={name}
132+
control={form.control}
133+
defaultValue={defaultValue ?? ''}
134+
rules={rules}
135+
render={(controllerProps) => renderField(controllerProps)}
136+
/>
137+
);
138+
};
139+
140+
export default FieldRenderer;

assets/react/v3/entries/course-builder/components/curriculum/QuestionConditions.tsx

+11-4
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,18 @@ import { Controller, useFormContext } from 'react-hook-form';
55
import FormInput from '@TutorShared/components/fields/FormInput';
66
import FormSwitch from '@TutorShared/components/fields/FormSwitch';
77

8-
import SVGIcon from '@TutorShared/atoms/SVGIcon';
9-
import { colorTokens, spacing } from '@TutorShared/config/styles';
10-
import { typography } from '@TutorShared/config/typography';
11-
import Show from '@TutorShared/controls/Show';
8+
import CourseBuilderInjectionSlot from '@CourseBuilderComponents/CourseBuilderSlot';
129
import { useQuizModalContext } from '@CourseBuilderContexts/QuizModalContext';
1310
import {
1411
QuizDataStatus,
1512
type QuizForm,
1613
type QuizQuestionType,
1714
calculateQuizDataStatus,
1815
} from '@CourseBuilderServices/quiz';
16+
import SVGIcon from '@TutorShared/atoms/SVGIcon';
17+
import { colorTokens, spacing } from '@TutorShared/config/styles';
18+
import { typography } from '@TutorShared/config/typography';
19+
import Show from '@TutorShared/controls/Show';
1920
import { styleUtils } from '@TutorShared/utils/style-utils';
2021
import type { IconCollection } from '@TutorShared/utils/types';
2122

@@ -250,6 +251,12 @@ const QuestionConditions = () => {
250251
/>
251252
)}
252253
/>
254+
255+
<CourseBuilderInjectionSlot
256+
section="Curriculum.Quiz.bottom_of_question_sidebar"
257+
namePrefix={`questions.${activeQuestionIndex}.`}
258+
form={form}
259+
/>
253260
</div>
254261
</div>
255262
</div>

assets/react/v3/entries/course-builder/components/curriculum/QuestionForm.tsx

+7
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import Show from '@TutorShared/controls/Show';
3030
import { usePrevious } from '@TutorShared/hooks/usePrevious';
3131
import { styleUtils } from '@TutorShared/utils/style-utils';
3232

33+
import CourseBuilderInjectionSlot from '@CourseBuilderComponents/CourseBuilderSlot';
3334
import emptyStateImage2x from '@SharedImages/quiz-empty-state-2x.webp';
3435
import emptyStateImage from '@SharedImages/quiz-empty-state.webp';
3536

@@ -146,6 +147,12 @@ const QuestionForm = () => {
146147
)}
147148
/>
148149
</Show>
150+
151+
<CourseBuilderInjectionSlot
152+
section="Curriculum.Quiz.after_question_description"
153+
namePrefix={`questions.${activeQuestionIndex}.`}
154+
form={form}
155+
/>
149156
</div>
150157
</div>
151158

assets/react/v3/entries/course-builder/components/curriculum/QuizSettings.tsx

+3
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import FormSelectInput from '@TutorShared/components/fields/FormSelectInput';
1313
import FormSwitch from '@TutorShared/components/fields/FormSwitch';
1414
import FormTopicPrerequisites from '@TutorShared/components/fields/FormTopicPrerequisites';
1515

16+
import CourseBuilderInjectionSlot from '@CourseBuilderComponents/CourseBuilderSlot';
1617
import { useQuizModalContext } from '@CourseBuilderContexts/QuizModalContext';
1718
import type { ContentDripType } from '@CourseBuilderServices/course';
1819
import type { CourseTopic } from '@CourseBuilderServices/curriculum';
@@ -360,6 +361,8 @@ const QuizSettings = ({ contentDripType }: QuizSettingsProps) => {
360361
</Show>
361362
</div>
362363
</Card>
364+
365+
<CourseBuilderInjectionSlot section="Curriculum.Quiz.bottom_of_settings" form={form} />
363366
</div>
364367
);
365368
};

assets/react/v3/entries/course-builder/components/curriculum/TopicContent.tsx

+5-5
Original file line numberDiff line numberDiff line change
@@ -159,19 +159,19 @@ const TopicContent = ({ type, topic, content, onCopy, onDelete, isOverlay = fals
159159
const exportQuizMutation = useExportQuizMutation();
160160

161161
const handleShowModalOrPopover = () => {
162-
const isContentType = type as keyof typeof modalComponent;
163-
if (modalComponent[isContentType]) {
162+
const contentType = type as keyof typeof modalComponent;
163+
if (modalComponent[contentType]) {
164164
showModal({
165-
component: modalComponent[isContentType],
165+
component: modalComponent[contentType],
166166
props: {
167167
contentDripType: form.watch('contentDripType'),
168168
topicId: topicId,
169169
lessonId: contentId,
170170
assignmentId: contentId,
171171
quizId: contentId,
172-
title: modalTitle[isContentType],
172+
title: modalTitle[contentType],
173173
subtitle: sprintf(__('Topic: %s', 'tutor'), topic.title),
174-
icon: <SVGIcon name={modalIcon[isContentType]} height={24} width={24} />,
174+
icon: <SVGIcon name={modalIcon[contentType]} height={24} width={24} />,
175175
...(type === 'tutor_h5p_quiz' && {
176176
contentType: 'tutor_h5p_quiz',
177177
}),

0 commit comments

Comments
 (0)