Files
classroomio/apps/dashboard/src/lib/features/course/pages/exercise.svelte
T

317 lines
11 KiB
Svelte
Raw Normal View History

2026-03-09 11:18:22 +01:00
<script lang="ts">
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import { Button } from '@cio/ui/base/button';
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 * as DropdownMenu from '@cio/ui/base/dropdown-menu';
import CirclePlusIcon from '@lucide/svelte/icons/circle-plus';
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import * as Page from '@cio/ui/base/page';
import { ContentType } from '@cio/utils/constants/content';
import {
handleAddQuestion,
questionnaire,
questionnaireOrder,
reset,
mapZodErrorsToQuestionErrors,
questionnaireValidation
} from '$features/course/components/exercise/store';
import { ZExerciseUpdate } from '@cio/utils/validation/exercise';
import { mapZodErrorsToTranslations } from '$lib/utils/validation';
import { transformQuestionsToApiFormat } from '$features/course/components/exercise/functions';
import { globalStore } from '$lib/utils/store/app';
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$features/ui/snackbar/store';
import { exerciseApi } from '$features/course/api';
import { courseApi } from '$features/course/api';
import EditMode from '$features/course/components/exercise/edit-mode.svelte';
import ViewMode from '$features/course/components/exercise/view-mode.svelte';
import Submissions from '$features/course/components/exercise/submissions/submissions.svelte';
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 { RoleBasedSecurity } from '$features/ui';
import { getOrderedNavigableContent } from '$features/course/utils/content';
import { Empty } from '@cio/ui/custom/empty';
import VideoIcon from '@lucide/svelte/icons/video';
import {
getQuestionTypeId,
getQuestionTypeOptionById
} from '$features/course/components/exercise/question-type-utils';
import type { SubmissionListItem } from '$features/course/utils/types';
import { Badge } from '@cio/ui/base/badge';
interface Props {
exerciseId?: string;
goBack?: () => void;
isFetching?: boolean;
submissions: SubmissionListItem[];
mySubmission: SubmissionListItem | null;
}
let {
exerciseId = $bindable(''),
goBack = () => {},
isFetching = false,
submissions,
mySubmission
}: Props = $props();
type ExerciseTab = 'questions' | 'submissions';
function normalizeExerciseTab(tabParam: string | null): ExerciseTab {
if (tabParam === 'submissions' || tabParam === 'analytics') {
return 'submissions';
}
return 'questions';
}
let preview: boolean = $state(false);
let shouldDeleteExercise = $state(false);
let isSaving = $state(false);
let selectedTab = $state<ExerciseTab>('questions');
function patchExerciseListItemLocally() {
const nonDeletedQuestionsCount = ($questionnaire.questions || []).filter((question) => !question.deletedAt).length;
courseApi.updateContentItem(exerciseId, ContentType.Exercise, {
title: $questionnaire.title ?? '',
dueBy: $questionnaire.dueBy || null,
questionCount: nonDeletedQuestionsCount
});
}
async function handleSave() {
if ($globalStore.isStudent || !courseApi.course?.id) return;
// Transform questionnaire to API format for validation
// Include all non-deleted options (even empty ones) so Zod can catch validation errors
const transformedQuestions = transformQuestionsToApiFormat($questionnaire.questions, {
shouldFilterEmptyLabels: false
});
// Validate using Zod schema
const zodResult = ZExerciseUpdate.safeParse({
questions: transformedQuestions
});
if (!zodResult.success) {
const zodErrors = mapZodErrorsToTranslations(zodResult.error);
const questionErrors = mapZodErrorsToQuestionErrors(zodErrors, $questionnaire.questions);
questionnaireValidation.set(questionErrors);
snackbar.error('Please fix all validation errors before saving');
return;
}
isSaving = true;
reset();
try {
// Transform questionnaire to API format (filter empty options for API, include deleted items)
const questions = transformQuestionsToApiFormat($questionnaire.questions, {
shouldFilterEmptyLabels: true,
shouldIncludeDeleted: true
});
await exerciseApi.update(courseApi.course?.id, exerciseId, {
title: $questionnaire.title ?? '',
description: $questionnaire.description ?? '',
dueBy: $questionnaire.dueBy ?? '',
questions: questions
});
// Check if there are validation errors from the API
if (Object.keys(exerciseApi.errors || {}).length > 0) {
console.log('Validation errors from API:', exerciseApi.errors);
snackbar.error('Please fix all validation errors before saving');
isSaving = false;
return;
}
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
}));
patchExerciseListItemLocally();
snackbar.success('snackbar.exercise.success');
}
} catch {
snackbar.error();
}
isSaving = false;
}
onDestroy(() => {
reset();
});
onMount(() => {
selectedTab = normalizeExerciseTab(page.url.searchParams.get('tab'));
});
$effect(() => {
const nextTab = normalizeExerciseTab(page.url.searchParams.get('tab'));
if (nextTab === untrack(() => selectedTab)) return;
selectedTab = nextTab;
});
$effect(() => {
const currentTab = page.url.searchParams.get('tab') ?? '';
// Prevent self-navigation loops: only update URL when it actually changes.
if (currentTab === selectedTab) return;
untrack(() => {
const url = new URL(page.url);
url.searchParams.set('tab', selectedTab);
goto(resolve(`${url.pathname}${url.search}`, {}), {
replaceState: true,
keepFocus: true,
noScroll: true
});
});
});
$effect(() => {
const addNewQ = $questionnaire?.questions?.length < 1;
if (addNewQ) {
untrack(() => {
handleAddQuestion();
});
}
});
const exerciseContentItem = $derived(
getOrderedNavigableContent(courseApi.course).find(
(item) => item.type === ContentType.Exercise && item.id === exerciseId
)
);
const isExerciseUnlocked = $derived(exerciseContentItem?.isUnlocked ?? false);
</script>
<Page.Header isSticky={true} class="z-250! min-h-[36px]">
2026-03-09 11:18:22 +01:00
<Page.HeaderContent>
<Page.Title>{$questionnaire.title || 'Exercise'}</Page.Title>
</Page.HeaderContent>
<Page.Action>
<div class="flex items-center gap-2">
{#if $globalStore.isStudent && courseApi.course?.id && exerciseId}
<ContentNavigationActions courseId={courseApi.course.id} {exerciseId} />
{/if}
<RoleBasedSecurity allowedRoles={[1, 2]}>
{#if selectedTab === 'questions'}
<div class="right-0 flex w-full items-center justify-end">
<div class="flex items-center gap-2">
<Button class="mr-2" onclick={handleSave} loading={isSaving}>
{$t('course.navItem.lessons.exercises.all_exercises.save')}
</Button>
<IconButton
onclick={() => (preview = !preview)}
tooltip={preview
? $t('course.navItem.lessons.exercises.all_exercises.view_mode.edit')
: $t('course.navItem.lessons.exercises.all_exercises.preview')}
tooltipSide="bottom"
>
{#if preview}
<PencilIcon size={20} />
{:else}
<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 variant="ghost" size="icon" class="h-8 w-8">
<EllipsisVerticalIcon class="h-5 w-5" />
<span class="sr-only">Open menu</span>
</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 />
<DropdownMenu.Item
class="text-red-600 focus:text-red-600 dark:text-red-400"
onclick={() => (shouldDeleteExercise = true)}
>
{$t('course.navItem.lessons.exercises.all_exercises.delete_exercise')}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
</div>
{/if}
</RoleBasedSecurity>
</div>
</Page.Action>
</Page.Header>
<Page.Body>
{#snippet child()}
<div class="overflow-x-hidden">
{#if $globalStore.isStudent}
{#if isExerciseUnlocked}
<ViewMode {preview} {exerciseId} isFetchingExercise={isFetching} {mySubmission} />
{:else}
<Empty
title={$t('course.navItem.lessons.content_locked_title')}
description={$t('course.navItem.lessons.content_locked_description')}
icon={VideoIcon}
variant="page"
class="text-center"
/>
{/if}
{:else}
<UnderlineTabs.Root bind:value={selectedTab} class="mb-4">
<UnderlineTabs.List class="grid w-full max-w-xs grid-cols-2">
<UnderlineTabs.Trigger value="questions">
{$t('course.navItem.lessons.exercises.all_exercises.questions')}
</UnderlineTabs.Trigger>
<UnderlineTabs.Trigger value="submissions">
{$t('course.navItem.lessons.exercises.all_exercises.analytics.submissions')}
<Badge>{submissions.length}</Badge>
</UnderlineTabs.Trigger>
</UnderlineTabs.List>
<UnderlineTabs.Content value="questions">
<UpdateDescription {preview} />
{#if !preview}
<EditMode bind:shouldDeleteExercise {exerciseId} {goBack} />
{:else}
<ViewMode {preview} {exerciseId} isFetchingExercise={isFetching} {mySubmission} />
{/if}
</UnderlineTabs.Content>
<UnderlineTabs.Content value="submissions">
<Submissions bind:exerciseId {submissions} />
</UnderlineTabs.Content>
</UnderlineTabs.Root>
{/if}
</div>
{/snippet}
</Page.Body>