Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Course builder extensions #1579

Merged
merged 15 commits into from
Feb 11, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 44 additions & 1 deletion assets/react/v3/@types/index.d.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -130,5 +172,6 @@ declare global {
root: string;
versionString: string;
};
Tutor: Tutor;
}
}
13 changes: 8 additions & 5 deletions assets/react/v3/entries/course-builder/components/App.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -32,10 +33,12 @@ const App = () => {
<RTLProvider>
<QueryClientProvider client={queryClient}>
<ToastProvider position="bottom-center">
<ModalProvider>
<Global styles={createGlobalCss()} />
{routers}
</ModalProvider>
<CourseBuilderSlotProvider>
<ModalProvider>
<Global styles={createGlobalCss()} />
{routers}
</ModalProvider>
</CourseBuilderSlotProvider>
</ToastProvider>
</QueryClientProvider>
</RTLProvider>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import ComponentErrorBoundary from '@TutorShared/components/ComponentErrorBoundary';
import React from 'react';

interface ContentRendererProps {
component: React.ReactNode;
}

const ContentRenderer = ({ component }: ContentRendererProps) => {
return <ComponentErrorBoundary componentName={'content'}>{component}</ComponentErrorBoundary>;
};

export default ContentRenderer;
Original file line number Diff line number Diff line change
@@ -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<any>;
}

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) => (
<FieldRenderer
key={props.name}
form={form}
{...props}
name={namePrefix ? `${namePrefix}${props.name}` : props.name}
/>
))}
{getNestedContent().map(({ component }, index) => (
<ContentRenderer key={index} component={component} />
))}
</>
);
};

export default CourseBuilderInjectionSlot;
140 changes: 140 additions & 0 deletions assets/react/v3/entries/course-builder/components/FieldRenderer.tsx
Original file line number Diff line number Diff line change
@@ -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<string | number>[];
defaultValue?: unknown;
rules?: Exclude<RegisterOptions, 'valueAsNumber' | 'valueAsDate' | 'setValueAs'>;
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<any>) => {
const field = (() => {
switch (type) {
case 'text':
return <FormInput {...controllerProps} label={label} placeholder={placeholder} helpText={helpText} />;
case 'number':
return (
<FormInput {...controllerProps} type="number" label={label} placeholder={placeholder} helpText={helpText} />
);
case 'password':
return (
<FormInput
{...controllerProps}
type="password"
label={label}
placeholder={placeholder}
helpText={helpText}
/>
);
case 'textarea':
return <FormTextareaInput {...controllerProps} label={label} placeholder={placeholder} helpText={helpText} />;
case 'select':
return (
<FormSelectInput
{...controllerProps}
label={label}
options={options || []}
placeholder={placeholder}
helpText={helpText}
/>
);
case 'radio':
return <FormRadioGroup {...controllerProps} label={label} options={options || []} />;
case 'checkbox':
return <FormCheckbox {...controllerProps} label={label} />;
case 'switch':
return <FormSwitch {...controllerProps} label={label} helpText={helpText} />;
case 'date':
return <FormDateInput {...controllerProps} label={label} placeholder={placeholder} helpText={helpText} />;
case 'time':
return <FormTimeInput {...controllerProps} label={label} placeholder={placeholder} helpText={helpText} />;
case 'image':
return (
<FormImageInput
{...controllerProps}
label={label}
buttonText={buttonText}
helpText={helpText}
infoText={infoText}
/>
);
case 'video':
return (
<FormVideoInput
{...controllerProps}
label={label}
buttonText={buttonText}
helpText={helpText}
infoText={infoText}
/>
);
case 'uploader':
return <FormFileUploader {...controllerProps} label={label} buttonText={buttonText} helpText={helpText} />;
case 'WPEditor':
return <FormWPEditor {...controllerProps} label={label} placeholder={placeholder} helpText={helpText} />
default:
return <Alert type="danger">Unsupported field type: {type}</Alert>;
}
})();

return (
<ComponentErrorBoundary
componentName={`field ${name}`}
onError={(error, errorInfo) => {
console.warn(`Field ${name} failed to render:`, { error, errorInfo });
}}
>
{field}
</ComponentErrorBoundary>
);
};

return (
<Controller
name={name}
control={form.control}
defaultValue={defaultValue ?? ''}
rules={rules}
render={(controllerProps) => renderField(controllerProps)}
/>
);
};

export default FieldRenderer;
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,18 @@ 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,
type QuizForm,
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';

Expand Down Expand Up @@ -250,6 +251,12 @@ const QuestionConditions = () => {
/>
)}
/>

<CourseBuilderInjectionSlot
section="Curriculum.Quiz.bottom_of_question_sidebar"
namePrefix={`questions.${activeQuestionIndex}.`}
form={form}
/>
</div>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -146,6 +147,12 @@ const QuestionForm = () => {
)}
/>
</Show>

<CourseBuilderInjectionSlot
section="Curriculum.Quiz.after_question_description"
namePrefix={`questions.${activeQuestionIndex}.`}
form={form}
/>
</div>
</div>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -360,6 +361,8 @@ const QuizSettings = ({ contentDripType }: QuizSettingsProps) => {
</Show>
</div>
</Card>

<CourseBuilderInjectionSlot section="Curriculum.Quiz.bottom_of_settings" form={form} />
</div>
);
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: <SVGIcon name={modalIcon[isContentType]} height={24} width={24} />,
icon: <SVGIcon name={modalIcon[contentType]} height={24} width={24} />,
...(type === 'tutor_h5p_quiz' && {
contentType: 'tutor_h5p_quiz',
}),
Expand Down
Loading
Loading