chore: ai, public course and other ui improvements

This commit is contained in:
Rotimi Best
2026-05-09 06:05:03 +01:00
parent 5717dd8004
commit c31ad63955
318 changed files with 20668 additions and 4788 deletions
@@ -6,7 +6,7 @@
import * as UnderlineTabs from '@cio/ui/custom/underline-tabs';
import EyeIcon from '@lucide/svelte/icons/eye';
import PencilIcon from '@lucide/svelte/icons/pencil';
import { onDestroy, onMount, untrack } from 'svelte';
import { onDestroy, onMount, tick, untrack } from 'svelte';
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
import CirclePlusIcon from '@lucide/svelte/icons/circle-plus';
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
@@ -14,7 +14,11 @@
import { ContentType } from '@cio/utils/constants/content';
import {
type ExerciseEditorErrors,
handleAddQuestion,
handleAddSection,
getQuestionsForSection,
hasSections,
questionnaire,
questionnaireOrder,
reset,
@@ -36,21 +40,28 @@
import { IconButton } from '@cio/ui/custom/icon-button';
import UpdateDescription from '$features/course/components/exercise/update-description.svelte';
import { ContentNavigationActions } from '$features/course/components/lesson';
import { RefreshPageData, RoleBasedSecurity } from '$features/ui';
import {
// RefreshPageData,
RoleBasedSecurity,
UnsavedChanges
} from '$features/ui';
import { isSelfPacedLikeCourse } from '$features/course/utils/compliance-utils';
import { getOrderedNavigableContent } from '$features/course/utils/content';
import { refreshExercisePageData } from '$features/course/utils/exercise-page-utils';
import {
hydrateExercisePageData
// , refreshExercisePageData
} from '$features/course/utils/exercise-page-utils';
import { Empty } from '@cio/ui/custom/empty';
import VideoIcon from '@lucide/svelte/icons/video';
import {
getQuestionTypeId,
getQuestionTypeKeyFromId,
getQuestionTypeOptionById
getQuestionTypeKeyFromId
} from '$features/course/components/exercise/question-type-utils';
import type { SubmissionListItem } from '$features/course/utils/types';
import { Badge } from '@cio/ui/base/badge';
import { isAutoGradableQuestionType } from '@cio/question-types';
import * as Dialog from '@cio/ui/base/dialog';
import type { ExerciseSectionState } from '$features/course/components/exercise/store';
interface Props {
exerciseId?: string;
@@ -85,7 +96,32 @@
let shouldDeleteExercise = $state(false);
let isSaving = $state(false);
let isDeleting = $state(false);
let reorderQuestions = $state(false);
let selectedTab = $state<ExerciseTab>('questions');
// eslint-disable-next-line svelte/prefer-writable-derived -- must be writable: cleared before intentional navigations and bound to UnsavedChanges
let hasUnsavedChanges = $state(false);
function isTemporaryId(id: string | number | undefined) {
return typeof id === 'string' && id.includes('-form');
}
function hasDirtyQuestionnaire() {
const hasDirtyQuestion = ($questionnaire.questions ?? []).some((question) => {
const hasDirtyOption = (question.options ?? []).some((option) => option.isDirty || isTemporaryId(option.id));
return question.isDirty || Boolean(question.deletedAt) || isTemporaryId(question.id) || hasDirtyOption;
});
const hasDirtySection = ($questionnaire.sections ?? []).some(
(section) => section.isDirty || Boolean(section.deletedAt)
);
return Boolean(
$questionnaire.isTitleDirty ||
$questionnaire.isDescriptionDirty ||
$questionnaire.isDueByDirty ||
hasDirtyQuestion ||
hasDirtySection
);
}
async function handleDeleteExercise() {
if (!courseApi.course?.id) return;
@@ -94,6 +130,7 @@
await exerciseApi.delete(courseApi.course.id, exerciseId);
if (exerciseApi.success) {
hasUnsavedChanges = false;
courseApi.removeContentItem(exerciseId, ContentType.Exercise);
goBack();
}
@@ -111,6 +148,25 @@
});
}
function getSectionValidationErrors(sections: ExerciseSectionState[]): ExerciseEditorErrors {
const sectionErrors: ExerciseEditorErrors = {};
const requiredFieldMessage = t.get('validations.generic.required_field');
for (const section of sections) {
const nextSectionErrors: ExerciseEditorErrors[string] = {};
if (!section.title?.trim()) {
nextSectionErrors.title = requiredFieldMessage;
}
if (Object.keys(nextSectionErrors).length === 0) continue;
sectionErrors[section.id] = nextSectionErrors;
}
return sectionErrors;
}
async function handleSave() {
if ($isOrgStudent || !courseApi.course?.id) return;
@@ -119,16 +175,33 @@
const transformedQuestions = transformQuestionsToApiFormat($questionnaire.questions, {
shouldFilterEmptyLabels: false
});
const activeSections = $questionnaire.sections.filter((section) => !section.deletedAt);
const sectionsPayload = activeSections.map((section) => ({
id: section.id,
title: section.title,
description: section.description,
order: section.order,
colorTheme: section.colorTheme,
afterBehavior: section.afterBehavior,
questionIds: getQuestionsForSection($questionnaire.questions, section.id).map((question) => question.id)
}));
const shouldSyncSections = $questionnaire.sections.length > 0 || hasSections($questionnaire.sections);
const sectionValidationErrors = getSectionValidationErrors(activeSections);
// Validate using Zod schema
const zodResult = ZExerciseUpdate.safeParse({
questions: transformedQuestions
questions: transformedQuestions,
sections: shouldSyncSections ? sectionsPayload : undefined,
sectionDisplayMode: $questionnaire.sectionDisplayMode
});
if (!zodResult.success) {
const zodErrors = mapZodErrorsToTranslations(zodResult.error);
if (!zodResult.success || Object.keys(sectionValidationErrors).length > 0) {
const zodErrors = !zodResult.success ? mapZodErrorsToTranslations(zodResult.error) : {};
const questionErrors = mapZodErrorsToQuestionErrors(zodErrors, $questionnaire.questions);
questionnaireValidation.set(questionErrors);
questionnaireValidation.set({
...questionErrors,
...sectionValidationErrors
});
snackbar.error('Please fix all validation errors before saving');
return;
}
@@ -167,7 +240,9 @@
title: $questionnaire.title ?? '',
description: $questionnaire.description ?? '',
dueBy: $questionnaire.dueBy ?? '',
questions: questions
questions: questions,
sections: shouldSyncSections ? sectionsPayload : undefined,
sectionDisplayMode: $questionnaire.sectionDisplayMode
});
// Check if there are validation errors from the API
@@ -179,20 +254,10 @@
}
if (exerciseApi.success) {
questionnaire.update((q) => ({
...q,
isTitleDirty: false,
isDescriptionDirty: false,
questions:
exerciseApi.exercise?.questions?.map((question) => {
const questionType = getQuestionTypeOptionById(getQuestionTypeId(question));
return {
...question,
questionTypeId: questionType.id,
questionType
};
}) || q.questions
}));
if (exerciseApi.exercise) {
hydrateExercisePageData(exerciseApi.exercise, exerciseId);
}
hasUnsavedChanges = false;
patchExerciseListItemLocally();
snackbar.success('snackbar.exercise.success');
}
@@ -216,6 +281,17 @@
selectedTab = nextTab;
});
$effect(() => {
hasUnsavedChanges = !$isOrgStudent && hasDirtyQuestionnaire();
});
$effect(() => {
const hasActiveSections = hasSections($questionnaire.sections);
if (!hasActiveSections && reorderQuestions) {
reorderQuestions = false;
}
});
$effect(() => {
const currentTab = page.url.searchParams.get('tab') ?? '';
// Prevent self-navigation loops: only update URL when it actually changes.
@@ -250,6 +326,7 @@
const isExerciseUnlocked = $derived(exerciseContentItem?.isUnlocked ?? false);
const isSelfPacedCourse = $derived(isSelfPacedLikeCourse(courseApi.course?.type));
const isPublicCourse = $derived(courseApi.course?.type === 'PUBLIC');
const activeQuestionTypeIds = $derived(
($questionnaire.questions ?? []).filter((q) => !q.deletedAt).map((q) => getQuestionTypeId(q))
);
@@ -265,11 +342,42 @@
if ($isOrgStudent || !isSelfPacedCourse || activeQuestionTypeIds.length === 0) return null;
return isExerciseFullyAutoGradable(activeQuestionTypeIds) ? ('auto' as const) : ('manual' as const);
});
async function scrollToExerciseElement(elementId: string) {
await tick();
requestAnimationFrame(() => {
document.getElementById(elementId)?.scrollIntoView({
block: 'start',
behavior: 'smooth',
inline: 'nearest'
});
});
}
function getFirstActiveSectionId() {
const [firstActiveSection] = [...($questionnaire.sections ?? [])]
.filter((section) => !section.deletedAt)
.sort((leftSection, rightSection) => leftSection.order - rightSection.order);
return firstActiveSection?.id ?? null;
}
async function addQuestionFromHeader() {
preview = false;
const questionId = handleAddQuestion(getFirstActiveSectionId());
await scrollToExerciseElement(`exercise-question-${questionId}`);
}
async function addSectionFromHeader() {
preview = false;
const sectionId = handleAddSection();
await scrollToExerciseElement(`exercise-section-${sectionId}`);
}
</script>
<Page.Header isSticky={true} class="z-50! min-h-[36px]">
<Page.Header isSticky={true} class="z-100! min-h-[36px]">
<Page.HeaderContent>
<Page.Title class="flex flex-wrap items-center gap-2">
<Page.Title class="flex flex-col gap-2">
<span>{$questionnaire.title || 'Exercise'}</span>
<RoleBasedSecurity allowedRoles={[1, 2]}>
{#if teacherAutoGradeBadge === 'auto'}
@@ -298,7 +406,10 @@
{$t('course.navItem.lessons.exercises.all_exercises.save')}
</Button>
<IconButton
onclick={() => (preview = !preview)}
onclick={() => {
preview = !preview;
reorderQuestions = false;
}}
tooltip={preview
? $t('course.navItem.lessons.exercises.all_exercises.view_mode.edit')
: $t('course.navItem.lessons.exercises.all_exercises.preview')}
@@ -310,13 +421,22 @@
<EyeIcon size={20} />
{/if}
</IconButton>
<IconButton
onclick={handleAddQuestion}
tooltip={$t('course.navItem.lessons.exercises.all_exercises.add_question')}
tooltipSide="bottom"
>
<CirclePlusIcon size={20} />
</IconButton>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Button type="button" variant="ghost" size="icon" class="h-8 w-8">
<CirclePlusIcon size={20} />
<span class="sr-only">{$t('course.navItem.lessons.exercises.all_exercises.add')}</span>
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="center" class="z-201!">
<DropdownMenu.Item onclick={addQuestionFromHeader}>
{$t('course.navItem.lessons.exercises.all_exercises.add_question')}
</DropdownMenu.Item>
<DropdownMenu.Item onclick={addSectionFromHeader}>
{$t('course.navItem.lessons.exercises.all_exercises.add_section')}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Button variant="ghost" size="icon" class="h-8 w-8">
@@ -325,10 +445,19 @@
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item onclick={() => ($questionnaireOrder.open = true)}>
{$t('course.navItem.lessons.exercises.all_exercises.reorder')}
</DropdownMenu.Item>
<DropdownMenu.Separator />
{#if hasSections($questionnaire.sections)}
<DropdownMenu.Item onclick={() => (reorderQuestions = !reorderQuestions)}>
{reorderQuestions
? $t('course.navItem.lessons.exercises.all_exercises.reorder_done')
: $t('course.navItem.lessons.exercises.all_exercises.reorder')}
</DropdownMenu.Item>
<DropdownMenu.Separator />
{:else}
<DropdownMenu.Item onclick={() => ($questionnaireOrder.open = true)}>
{$t('course.navItem.lessons.exercises.all_exercises.reorder')}
</DropdownMenu.Item>
<DropdownMenu.Separator />
{/if}
<DropdownMenu.Item
class="text-red-600 focus:text-red-600 dark:text-red-400"
onclick={() => (shouldDeleteExercise = true)}
@@ -341,7 +470,7 @@
</div>
{/if}
<RefreshPageData onRefresh={() => refreshExercisePageData(courseApi.course?.id ?? '', exerciseId)} />
<!-- <RefreshPageData onRefresh={() => refreshExercisePageData(courseApi.course?.id ?? '', exerciseId)} /> -->
</RoleBasedSecurity>
</div>
</Page.Action>
@@ -377,7 +506,7 @@
<Badge variant="outline">{submissions.length}</Badge>
</UnderlineTabs.Trigger>
</UnderlineTabs.List>
<UnderlineTabs.Content value="questions">
<UnderlineTabs.Content value="questions" class="mx-auto w-full md:max-w-3xl">
<UpdateDescription {preview} />
{#if !preview}
<EditMode
@@ -385,6 +514,8 @@
{goBack}
{requiresPositivePointsForAutoGrade}
selfPacedCourse={isSelfPacedCourse}
{isPublicCourse}
{reorderQuestions}
/>
{:else}
<ViewMode {preview} {exerciseId} isFetchingExercise={isFetching} {mySubmissions} />
@@ -430,3 +561,5 @@
</div>
{/snippet}
</Page.Body>
<UnsavedChanges bind:hasUnsavedChanges />