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
@@ -349,7 +349,10 @@ export class CourseApi extends BaseApiWithErrors {
* @param fields Course creation fields (title, description, type)
* @returns The created course data or null on error
*/
async create(fields: { title: string; description: string; type: TCourseType }) {
async create(
fields: { title: string; description: string; type: TCourseType },
onCreated?: (courseId: string) => void
) {
const org = get(currentOrg);
const userProfile = get(profile);
@@ -390,11 +393,12 @@ export class CourseApi extends BaseApiWithErrors {
user_email: userProfile.email
});
// Navigate to the new course
goto(resolve(`/courses/${newCourse.id}`, {}));
// Show success message
snackbar.success('Course created successfully');
if (onCreated) {
onCreated(newCourse.id);
} else {
goto(resolve(`/courses/${newCourse.id}`, {}));
snackbar.success('Course created successfully');
}
// Mark as successful
this.success = true;
@@ -6,10 +6,25 @@ import type {
Exercise,
GetExerciseRequest,
SubmitExerciseRequest,
UpdateExerciseRequest
UpdateExerciseRequest,
VideoRecordingPlaybackRequest,
VideoRecordingUploadCompleteRequest,
VideoRecordingUploadInitRequest
} from '../utils/types';
import type { TExerciseCreate, TExerciseSubmissionCreate, TExerciseUpdate } from '@cio/utils/validation/exercise';
import { ZExerciseCreate, ZExerciseSubmissionCreate, ZExerciseUpdate } from '@cio/utils/validation/exercise';
import type {
TExerciseCreate,
TExerciseSubmissionCreate,
TExerciseUpdate,
TExerciseVideoRecordingUploadComplete,
TExerciseVideoRecordingUploadInit
} from '@cio/utils/validation/exercise';
import {
ZExerciseCreate,
ZExerciseSubmissionCreate,
ZExerciseUpdate,
ZExerciseVideoRecordingUploadComplete,
ZExerciseVideoRecordingUploadInit
} from '@cio/utils/validation/exercise';
import { mapZodErrorsToTranslations } from '$lib/utils/validation';
import { snackbar } from '$features/ui/snackbar/store';
@@ -32,7 +47,7 @@ export class ExerciseApi extends BaseApiWithErrors {
logContext: 'fetching exercise',
onSuccess: (response) => {
if (response.data) {
this.exercise = response.data as Exercise;
this.exercise = response.data as unknown as Exercise;
this.success = true;
this.errors = {};
}
@@ -64,7 +79,7 @@ export class ExerciseApi extends BaseApiWithErrors {
logContext: 'creating exercise',
onSuccess: (response) => {
if (response.data) {
this.exercise = response.data as Exercise;
this.exercise = response.data as unknown as Exercise;
snackbar.success('Exercise created successfully');
this.success = true;
this.errors = {};
@@ -106,7 +121,7 @@ export class ExerciseApi extends BaseApiWithErrors {
logContext: 'updating exercise',
onSuccess: (response) => {
if (response.data) {
this.exercise = response.data as Exercise;
this.exercise = response.data as unknown as Exercise;
this.success = true;
this.errors = {};
}
@@ -193,6 +208,71 @@ export class ExerciseApi extends BaseApiWithErrors {
});
}
async initVideoRecordingUpload(
courseId: string,
exerciseId: string,
questionId: number | string,
fields: TExerciseVideoRecordingUploadInit
) {
const result = ZExerciseVideoRecordingUploadInit.safeParse(fields);
if (!result.success) {
this.errors = mapZodErrorsToTranslations(result.error, 'exercise');
return null;
}
return await this.execute<VideoRecordingUploadInitRequest>({
requestFn: () =>
classroomio.course[':courseId'].exercise[':exerciseId'].question[':questionId'][
'video-recording'
].upload.init.$post({
param: { courseId, exerciseId, questionId: String(questionId) },
json: result.data
}),
logContext: 'initializing video recording upload'
});
}
async completeVideoRecordingUpload(
courseId: string,
exerciseId: string,
questionId: number | string,
fields: TExerciseVideoRecordingUploadComplete
) {
const result = ZExerciseVideoRecordingUploadComplete.safeParse(fields);
if (!result.success) {
this.errors = mapZodErrorsToTranslations(result.error, 'exercise');
return null;
}
return await this.execute<VideoRecordingUploadCompleteRequest>({
requestFn: () =>
classroomio.course[':courseId'].exercise[':exerciseId'].question[':questionId'][
'video-recording'
].upload.complete.$post({
param: { courseId, exerciseId, questionId: String(questionId) },
json: result.data
}),
logContext: 'completing video recording upload'
});
}
async getVideoRecordingPlaybackUrl(
courseId: string,
exerciseId: string,
submissionId: string,
questionId: number | string
) {
return await this.execute<VideoRecordingPlaybackRequest>({
requestFn: () =>
classroomio.course[':courseId'].exercise[':exerciseId'].submission[':submissionId'].question[':questionId'][
'video-recording'
].playback.$get({
param: { courseId, exerciseId, submissionId, questionId: String(questionId) }
}),
logContext: 'getting video recording playback URL'
});
}
/**
* Creates an exercise from a template
* @param courseId Course ID
@@ -220,7 +300,7 @@ export class ExerciseApi extends BaseApiWithErrors {
logContext: 'creating exercise from template',
onSuccess: (response) => {
if (response.data) {
this.exercise = response.data as Exercise;
this.exercise = response.data as unknown as Exercise;
snackbar.success('Exercise created successfully');
this.success = true;
this.errors = {};
@@ -163,9 +163,20 @@ export class LessonApi extends BaseApiWithErrors {
json: result.data
}),
logContext: 'updating lesson',
onSuccess: () => {
this.errors = {};
},
onError: (result) => {
console.log('onError', result);
snackbar.error('snackbar.lessons.lesson_update_failed');
if (typeof result === 'string') {
snackbar.error('snackbar.lessons.lesson_update_failed');
return;
}
if ('error' in result && 'field' in result && result.field) {
this.errors[result.field] = result.error;
snackbar.error(result.error);
} else {
snackbar.error('snackbar.lessons.lesson_update_failed');
}
}
});
@@ -2,7 +2,10 @@
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import ExternalLinkIcon from '@lucide/svelte/icons/external-link';
import CopyIcon from '@lucide/svelte/icons/copy';
import { copyCourseModal, deleteCourseModal } from '$features/course/utils/store';
import { copyPublicCoursePageUrl, openCoursePreview } from '$features/course/utils/course-preview';
import { currentOrgDomain } from '$lib/utils/store/org';
import { IconButton } from '@cio/ui/custom/icon-button';
import { t } from '$lib/utils/functions/translations';
@@ -14,9 +17,23 @@
title: string;
description: string;
isPublished?: boolean;
courseType?: string | null;
slug?: string;
/** Compact menu for LMS course cards */
lmsPublicQuickOnly?: boolean;
}
let { id, title, description, isPublished = false }: Props = $props();
let {
id,
title,
description,
isPublished = false,
courseType = null,
slug = '',
lmsPublicQuickOnly = false
}: Props = $props();
const showPublicCourseLinks = $derived(isPublished && courseType === 'PUBLIC' && slug.trim().length > 0);
function redirect(url: string) {
goto(resolve(url, {}));
@@ -43,40 +60,65 @@
$copyCourseModal.description = description;
}
function handleOpenCourseSite() {
window.open(`/courses/${id}`, '_blank');
function handleOpenPublicCourse() {
openCoursePreview({
courseId: id,
courseSlug: slug,
currentOrgDomain: $currentOrgDomain
});
}
async function handleCopyCourseUrl() {
await copyPublicCoursePageUrl(slug, $currentOrgDomain);
}
</script>
<DropdownMenu.Root>
<DropdownMenu.Trigger
class="ui:data-[state=open]:opacity-100 absolute top-6 right-6 z-40 flex items-center justify-center opacity-0 transition-all delay-150 duration-200 ease-in-out group-hover:opacity-100"
onclick={(e) => e.stopPropagation()}
>
<IconButton variant="outline">
<EllipsisVerticalIcon size={16} />
</IconButton>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
{#if isPublished}
<DropdownMenu.Item onclick={handleOpenCourseSite}>
<ExternalLinkIcon size={16} class="mr-2" />
{$t('courses.course_card.context_menu.open_course_site')}
</DropdownMenu.Item>
<DropdownMenu.Separator />
{/if}
<DropdownMenu.Item onclick={handleCloneCourse}>
{$t('courses.course_card.context_menu.clone')}
</DropdownMenu.Item>
<DropdownMenu.Item onclick={handleShareCourse}>
{$t('courses.course_card.context_menu.share')}
</DropdownMenu.Item>
<DropdownMenu.Item onclick={handleInvite}>
{$t('courses.course_card.context_menu.invite')}
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item class="text-red-600" onclick={handleDeleteCourse}>
{$t('courses.course_card.context_menu.delete')}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
{#if lmsPublicQuickOnly ? showPublicCourseLinks : true}
<DropdownMenu.Root>
<DropdownMenu.Trigger
class="ui:data-[state=open]:opacity-100 absolute top-6 right-6 z-40 flex items-center justify-center opacity-0 transition-all delay-150 duration-200 ease-in-out group-hover:opacity-100"
onclick={(e) => e.stopPropagation()}
>
<IconButton variant="outline">
<EllipsisVerticalIcon size={16} />
</IconButton>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
{#if lmsPublicQuickOnly}
<DropdownMenu.Item onclick={handleOpenPublicCourse}>
<ExternalLinkIcon size={16} class="mr-2" />
{$t('courses.course_card.context_menu.open_public_course')}
</DropdownMenu.Item>
<DropdownMenu.Item onclick={() => void handleCopyCourseUrl()}>
<CopyIcon size={16} class="mr-2" />
{$t('courses.course_card.context_menu.copy_course_url')}
</DropdownMenu.Item>
{:else}
{#if showPublicCourseLinks}
<DropdownMenu.Item onclick={handleOpenPublicCourse}>
<ExternalLinkIcon size={16} class="mr-2" />
{$t('courses.course_card.context_menu.open_public_course')}
</DropdownMenu.Item>
<DropdownMenu.Item onclick={() => void handleCopyCourseUrl()}>
<CopyIcon size={16} class="mr-2" />
{$t('courses.course_card.context_menu.copy_course_url')}
</DropdownMenu.Item>
<DropdownMenu.Separator />
{/if}
<DropdownMenu.Item onclick={handleCloneCourse}>
{$t('courses.course_card.context_menu.clone')}
</DropdownMenu.Item>
<DropdownMenu.Item onclick={handleShareCourse}>
{$t('courses.course_card.context_menu.share')}
</DropdownMenu.Item>
<DropdownMenu.Item onclick={handleInvite}>
{$t('courses.course_card.context_menu.invite')}
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item class="text-red-600" onclick={handleDeleteCourse}>
{$t('courses.course_card.context_menu.delete')}
</DropdownMenu.Item>
{/if}
</DropdownMenu.Content>
</DropdownMenu.Root>
{/if}
@@ -7,6 +7,7 @@
import CircleDotIcon from '@lucide/svelte/icons/circle-dot';
import ShieldCheckIcon from '@lucide/svelte/icons/shield-check';
import TrendingUpIcon from '@lucide/svelte/icons/trending-up';
import GlobeIcon from '@lucide/svelte/icons/globe';
import ArrowRightIcon from '@lucide/svelte/icons/arrow-right';
import { Button } from '@cio/ui/base/button';
@@ -165,6 +166,18 @@
: undefined
);
const visibilityBadge = $derived(
type === 'PUBLIC'
? {
label: $t('courses.course_card.public_badge'),
icon: GlobeIcon,
iconClass: 'custom ui:text-primary size-3.5 shrink-0'
}
: undefined
);
const showLmsPublicCourseMenu = $derived(isLMS && type === 'PUBLIC' && !!slug?.trim() && isPublished);
const complianceStatusKey = $derived(
isLMS && type === 'COMPLIANCE' && !isExplore
? getStudentCourseComplianceStatusKey(course as UserEnrolledCourses[number])
@@ -202,6 +215,7 @@
{title}
{description}
{typeBadge}
{visibilityBadge}
class="group relative"
>
{#snippet media()}
@@ -211,8 +225,12 @@
{#snippet overlay()}
{#if actions}
{@render actions()}
{:else if !isLMS && !isOnLandingPage}
<CardDropdown {id} {title} {description} {isPublished} />
{:else if !isOnLandingPage}
{#if !isLMS}
<CardDropdown {id} {title} {description} {isPublished} courseType={type} {slug} />
{:else if showLmsPublicCourseMenu}
<CardDropdown {id} {title} {description} {isPublished} courseType={type} {slug} lmsPublicQuickOnly={true} />
{/if}
{/if}
{/snippet}
@@ -16,11 +16,12 @@
import { openCoursePreview } from '$features/course/utils/course-preview';
import { toggleAiAssistant } from '$features/ai-assistant/utils/store';
import { t } from '$lib/utils/functions/translations';
import { isFreePlan } from '$lib/utils/store/org';
import CoursePublishBadge from './course-publish-badge.svelte';
import CoursePublicBadge from './course-public-badge.svelte';
const siteName = $derived($currentOrg.siteName);
const showCoursePublishBadge = $derived(!$isStudentExperience);
const isPublicCourse = $derived(courseApi.course?.type === 'PUBLIC');
$effect(() => {
if (!siteName) return;
@@ -38,7 +39,9 @@
</script>
<header
class="border-border ui:bg-background sticky top-0 z-100 flex h-12 w-full shrink-0 items-center gap-2 border-b backdrop-blur transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-8"
class="border-border ui:bg-background sticky top-0 z-100 flex w-full shrink-0 items-center gap-2 border-b backdrop-blur transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-8 {isPublicCourse
? 'h-auto min-h-14 py-1.5'
: 'h-12'}"
>
<div class="flex w-full items-center gap-2 px-4">
<Sidebar.Trigger />
@@ -47,24 +50,34 @@
<Separator orientation="vertical" />
</div>
<div class="flex min-w-0 items-center gap-2">
<p class="max-w-2xs truncate text-sm font-medium">
{courseApi.course?.title || ''}
</p>
<div class="flex w-[60%] min-w-0 flex-1 flex-col justify-center gap-0.5">
<div class="flex min-w-0 items-center gap-2">
<p class="max-w-xs truncate text-sm font-medium">
{courseApi.course?.title || ''}
</p>
{#if showCoursePublishBadge}
<CoursePublishBadge isPublished={courseApi.course?.isPublished} />
{#if isPublicCourse}
<CoursePublicBadge class="shrink-0" />
{/if}
{#if showCoursePublishBadge}
<CoursePublishBadge isPublished={courseApi.course?.isPublished ?? false} />
{/if}
</div>
{#if isPublicCourse}
<p class="ui:text-muted-foreground max-w-xl truncate text-xs">
{$t('course.header.public_course_helper')}
</p>
{/if}
</div>
<span class="grow"></span>
{#if !$isFreePlan}
<Button variant="outline" size="sm" onclick={toggleAiAssistant}>
<SparklesIcon size={14} />
{$t('course.navItems.nav_ai_assistant')}
</Button>
{/if}
<Button variant="outline" size="sm" onclick={toggleAiAssistant}>
<SparklesIcon size={14} />
{$t('course.navItems.nav_ai_assistant')}
</Button>
<Popover.Root>
<Popover.Trigger>
@@ -11,8 +11,13 @@
import { Image } from '$features/ui';
import { t } from '$lib/utils/functions/translations';
import { copyCourseModal, deleteCourseModal } from '$features/course/utils/store';
import { copyPublicCoursePageUrl, openCoursePreview } from '$features/course/utils/course-preview';
import { currentOrgDomain } from '$lib/utils/store/org';
import { buildCoursePlaceholderAvatarUrl } from '$features/course/utils/course-list-row-utils';
import CourseContentIcon from './course-content-icon.svelte';
import CoursePublicBadge from './course-public-badge.svelte';
import ExternalLinkIcon from '@lucide/svelte/icons/external-link';
import CopyIcon from '@lucide/svelte/icons/copy';
interface Tag {
id: string;
@@ -59,10 +64,14 @@
const bannerImage = $derived(logo?.trim() ? logo : '/images/classroomio-course-img-template.jpg');
const showPublicCourseLinks = $derived(isPublished && type === 'PUBLIC' && slug.trim().length > 0);
const showActionsColumn = $derived(!isLMS || (isLMS && showPublicCourseLinks));
const gridTemplateColumns = $derived(
isLMS
? '7rem minmax(0, 2fr) 5.5rem minmax(0, 2fr) 4.5rem 6rem'
: '7rem minmax(0, 2fr) 5.5rem minmax(0, 2fr) 4.5rem 6rem 2.5rem'
showActionsColumn
? '7rem minmax(0, 2fr) 5.5rem minmax(0, 2fr) 4.5rem 6rem 2.5rem'
: '7rem minmax(0, 2fr) 5.5rem minmax(0, 2fr) 4.5rem 6rem'
);
const studentPlaceholderAvatarUrls = $derived.by(() => [
@@ -112,6 +121,10 @@
$deleteCourseModal.id = id;
$deleteCourseModal.title = title;
}
async function handleCopyCourseUrl() {
await copyPublicCoursePageUrl(slug, $currentOrgDomain);
}
</script>
<ResourceListRow.Root
@@ -133,7 +146,12 @@
<!-- Title / type / updated -->
<div class="flex min-w-0 flex-col gap-0.5">
<p class="line-clamp-2 text-base">{title}</p>
<div class="flex min-w-0 flex-wrap items-center gap-2">
<p class="line-clamp-2 min-w-0 flex-1 text-base">{title}</p>
{#if type === 'PUBLIC'}
<CoursePublicBadge class="shrink-0" />
{/if}
</div>
{#if typeLabel}
<p class="ui:text-muted-foreground mt-0.5 text-sm">{typeLabel}</p>
{/if}
@@ -225,7 +243,7 @@
</div>
<!-- Actions -->
{#if !isLMS}
{#if showActionsColumn}
<div class="flex justify-end">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
@@ -235,7 +253,7 @@
variant="ghost"
size="icon"
class="size-8"
aria-label="Course actions"
aria-label={$t('courses.course_card.actions_menu_aria')}
onclick={(e) => e.stopPropagation()}
>
<EllipsisVerticalIcon class="size-4" />
@@ -243,16 +261,38 @@
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item onclick={handleOpen}>
{$t('courses.course_card.context_menu.open')}
</DropdownMenu.Item>
<DropdownMenu.Item onclick={handleClone}>
{$t('courses.course_card.context_menu.clone')}
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item variant="destructive" onclick={handleDelete}>
{$t('courses.course_card.context_menu.delete')}
</DropdownMenu.Item>
{#if showPublicCourseLinks}
<DropdownMenu.Item
onclick={() =>
openCoursePreview({
courseId: id,
courseSlug: slug,
currentOrgDomain: $currentOrgDomain
})}
>
<ExternalLinkIcon class="mr-2 size-4" />
{$t('courses.course_card.context_menu.open_public_course')}
</DropdownMenu.Item>
<DropdownMenu.Item onclick={() => void handleCopyCourseUrl()}>
<CopyIcon class="mr-2 size-4" />
{$t('courses.course_card.context_menu.copy_course_url')}
</DropdownMenu.Item>
{/if}
{#if !isLMS}
{#if showPublicCourseLinks}
<DropdownMenu.Separator />
{/if}
<DropdownMenu.Item onclick={handleOpen}>
{$t('courses.course_card.context_menu.open')}
</DropdownMenu.Item>
<DropdownMenu.Item onclick={handleClone}>
{$t('courses.course_card.context_menu.clone')}
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item variant="destructive" onclick={handleDelete}>
{$t('courses.course_card.context_menu.delete')}
</DropdownMenu.Item>
{/if}
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
@@ -0,0 +1,19 @@
<script lang="ts">
import { Badge } from '@cio/ui/base/badge';
import GlobeIcon from '@lucide/svelte/icons/globe';
import { t } from '$lib/utils/functions/translations';
interface Props {
class?: string;
}
let { class: className = '' }: Props = $props();
</script>
<Badge
variant="secondary"
class="ui:inline-flex ui:shrink-0 ui:items-center ui:gap-1 ui:rounded-md! ui:text-xs ui:capitalize {className}"
>
<GlobeIcon class="size-3.5 shrink-0" />
{$t('courses.course_card.public_badge')}
</Badge>
@@ -4,18 +4,23 @@
import { t } from '$lib/utils/functions/translations';
import { deleteConfirmation } from './store';
let { onDelete = () => {}, onCancel = () => {} } = $props();
let {
title = $t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.title'),
description = $t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.sure'),
cancelLabel = $t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.no'),
confirmLabel = $t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.yes'),
onDelete = () => {},
onCancel = () => {}
} = $props();
</script>
<Dialog.Root bind:open={$deleteConfirmation.open}>
<Dialog.Content class="w-96">
<Dialog.Header>
<Dialog.Title>{$t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.title')}</Dialog.Title>
<Dialog.Title>{title}</Dialog.Title>
</Dialog.Header>
<div>
<p class="mt-0 text-base dark:text-white">
{$t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.sure')}
</p>
<p class="mt-0 text-base dark:text-white">{description}</p>
<div class="mt-5 flex items-center justify-between">
<Button
@@ -25,7 +30,7 @@
onCancel();
}}
>
{$t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.no')}
{cancelLabel}
</Button>
<Button
onclick={() => {
@@ -33,7 +38,7 @@
$deleteConfirmation.open = false;
}}
>
{$t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.yes')}
{confirmLabel}
</Button>
</div>
</div>
@@ -1,24 +1,32 @@
<script lang="ts">
import * as Select from '@cio/ui/base/select';
import TrashIcon from '@lucide/svelte/icons/trash';
import GripVerticalIcon from '@lucide/svelte/icons/grip-vertical';
import { dndzone } from 'svelte-dnd-action';
import { flip } from 'svelte/animate';
import { tick } from 'svelte';
import {
questionnaire,
deleteConfirmation,
questionnaireValidation,
handleRemoveQuestion,
handleCode
handleCode,
handleAddQuestion,
handleRemoveSection,
handleReorderSections,
handleReorderQuestionsInSection,
handleUpdateSectionAfterBehavior,
handleUpdateSectionColorTheme,
handleUpdateSectionDescription,
handleUpdateSectionTitle,
hasSections,
getQuestionsForSection
} from './store';
import { preventDefault } from '$lib/utils/functions/svelte';
import { exerciseApi } from '$features/course/api';
import { courseApi } from '$features/course/api';
import { ContentType } from '@cio/utils/constants/content';
import { PREMIUM_QUESTION_TYPE_KEYS, QUESTION_TYPES } from '$features/ui/question/constants';
import { Button } from '@cio/ui/base/button';
import { Badge } from '@cio/ui/base/badge';
import { QUESTION_TYPES } from '$features/ui/question/constants';
import { ExerciseQuestion } from '@cio/ui';
import OrderModal from './order-modal.svelte';
import QuestionTypeSelect from './question-type-select.svelte';
import { t } from '$lib/utils/functions/translations';
import { IconButton } from '@cio/ui/custom/icon-button';
import { TextareaField } from '@cio/ui/custom/textarea-field';
@@ -26,17 +34,20 @@
import { QuestionContainer } from '$features/course/components';
import { uploadImage } from '$lib/utils/services/upload';
import {
getExerciseEditorQuestionTypeLabel,
getQuestionTypeOptionById,
questionTypeSupportsOptions,
toExerciseQuestionModel
} from './question-type-utils';
import { QUESTION_TYPE_KEY } from '@cio/question-types';
import { getExerciseQuestionLabels } from './question-labels';
import { isFreePlan } from '$lib/utils/store/org';
import { openUpgradeModal } from '$lib/utils/functions/org';
import { PremiumIcon } from '@cio/ui/custom/moving-icons';
import SectionEditor from './section-editor.svelte';
import type { Question } from '$features/course/types';
import type { ExerciseEditorErrors } from './store';
const flipDurationMs = 200;
const initialQuestionsLength = $questionnaire.questions.length;
type QuestionDndEvent = CustomEvent<{ items: Question[] }>;
interface Props {
shouldDeleteExercise?: boolean;
@@ -45,19 +56,52 @@
/** Self-paced + all auto-gradable question types: enforce points &gt; 0 */
requiresPositivePointsForAutoGrade?: boolean;
selfPacedCourse?: boolean;
/** Public course restricts picker to auto-gradable question types only. */
isPublicCourse?: boolean;
reorderQuestions?: boolean;
}
let {
shouldDeleteExercise = $bindable(false),
exerciseId,
goBack = () => {},
exerciseId: _exerciseId,
goBack: _goBack = () => {},
requiresPositivePointsForAutoGrade = false,
selfPacedCourse = false
selfPacedCourse = false,
isPublicCourse = false,
reorderQuestions = false
}: Props = $props();
const availableQuestionTypes = $derived(
isPublicCourse ? QUESTION_TYPES.filter((type) => type.autoGradable) : QUESTION_TYPES
);
let questionIdToDelete = $state(null);
let sectionIdToDelete = $state<string | null>(null);
let deleteConfirmationType = $state<'question' | 'section'>('question');
let reorderQuestionItemsBySectionId = $state<Record<string, Question[]>>({});
let previousReorderQuestions = $state(false);
let errors = $derived($questionnaireValidation);
const questionLabels = $derived(getExerciseQuestionLabels());
const activeSections = $derived(
[...($questionnaire.sections ?? [])].filter((section) => !section.deletedAt).sort((a, b) => a.order - b.order)
);
const sectionLabels = $derived({
questions: t.get('course.navItem.lessons.exercises.all_exercises.section.questions'),
shortQuestions: t.get('course.navItem.lessons.exercises.all_exercises.section.short_questions'),
required: t.get('course.navItem.lessons.exercises.all_exercises.section.required'),
addQuestion: t.get('course.navItem.lessons.exercises.all_exercises.section.add_question'),
delete: t.get('course.navItem.lessons.exercises.all_exercises.section.delete'),
moveUp: t.get('course.navItem.lessons.exercises.all_exercises.section.move_up'),
moveDown: t.get('course.navItem.lessons.exercises.all_exercises.section.move_down'),
color: t.get('course.navItem.lessons.exercises.all_exercises.section.color'),
descriptionPlaceholder: t.get('course.navItem.lessons.exercises.all_exercises.section.description_placeholder')
});
const sectionAfterBehaviorLabels = $derived({
afterSectionPrefix: t.get('course.navItem.lessons.exercises.all_exercises.section.after_section'),
continue: t.get('course.navItem.lessons.exercises.all_exercises.section.continue'),
goToSection: t.get('course.navItem.lessons.exercises.all_exercises.section.go_to_section'),
submit: t.get('course.navItem.lessons.exercises.all_exercises.section.submit')
});
function shouldScrollToLast(questionId, questions) {
const [lastQuestion] = questions.slice(-1);
@@ -67,25 +111,62 @@
return isLast && initialQuestionsLength !== currentQuestionsLength;
}
async function scrollToExerciseElement(elementId: string) {
await tick();
requestAnimationFrame(() => {
document.getElementById(elementId)?.scrollIntoView({
block: 'start',
behavior: 'smooth',
inline: 'nearest'
});
});
}
function getQuestionElementId(questionId: string | number) {
return `exercise-question-${questionId}`;
}
async function addQuestionToSection(sectionId: string) {
const questionId = handleAddQuestion(sectionId);
await scrollToExerciseElement(getQuestionElementId(questionId));
}
function onInitDeleteClicked(questionId) {
return () => {
deleteConfirmationType = 'question';
questionIdToDelete = questionId;
$deleteConfirmation.open = true;
};
}
function onFinalDeleteClicked() {
if (deleteConfirmationType === 'section' && sectionIdToDelete) {
deleteSection(sectionIdToDelete);
sectionIdToDelete = null;
deleteConfirmationType = 'question';
return;
}
handleRemoveQuestion(questionIdToDelete)();
questionIdToDelete = null;
}
function getQuestionErrorMsg(errors, question, errorKey) {
function onCancelDeleteClicked() {
questionIdToDelete = null;
sectionIdToDelete = null;
deleteConfirmationType = 'question';
}
function getQuestionErrorMsg(
errors: ExerciseEditorErrors,
question: Question,
errorKey: 'option' | 'title' | 'points'
) {
return errors[question.id] ? errors[question.id][errorKey] : null;
}
function getQuestionTypeLabel(questionType) {
if (!questionType) return t.get('course.navItem.lessons.exercises.all_exercises.edit_mode.select_type');
if (questionType.labelKey) return t.get(questionType.labelKey);
return questionType.label;
function getSectionErrorMsg(errors: ExerciseEditorErrors, sectionId: string, errorKey: 'title' | 'description') {
return errors[sectionId]?.[errorKey];
}
function makeDefaultOption() {
@@ -117,6 +198,7 @@
const current = q.questions[idx];
let nextOptions = current.options ?? [];
let nextSettings = current.settings ?? {};
if (nextQuestionTypeKey === QUESTION_TYPE_KEY.TRUE_FALSE) {
const correctValue = (current.settings as { correctValue?: boolean })?.correctValue ?? true;
@@ -130,11 +212,20 @@
nextOptions = [];
}
if (nextQuestionTypeKey === QUESTION_TYPE_KEY.VIDEO_RECORDING) {
nextSettings = {
...nextSettings,
maxDurationSeconds: 60,
allowRetakes: true
};
}
const next = [...q.questions];
next[idx] = {
...current,
questionType: nextQuestionType,
questionTypeId: nextQuestionType.id,
settings: nextSettings,
options: nextOptions,
isDirty: true
};
@@ -179,12 +270,158 @@
return { ...q, questions: next };
});
}
function moveSection(sectionId: string, direction: -1 | 1) {
const currentIndex = activeSections.findIndex((section) => section.id === sectionId);
const targetIndex = currentIndex + direction;
if (currentIndex < 0 || targetIndex < 0 || targetIndex >= activeSections.length) return;
const orderedIds = activeSections.map((section) => section.id);
const [sectionToMove] = orderedIds.splice(currentIndex, 1);
orderedIds.splice(targetIndex, 0, sectionToMove);
handleReorderSections(orderedIds);
}
function deleteSection(sectionId: string) {
const sectionIndex = activeSections.findIndex((section) => section.id === sectionId);
const nearestSection = activeSections[sectionIndex + 1] ?? activeSections[sectionIndex - 1];
handleRemoveSection(sectionId, nearestSection?.id);
}
function onInitSectionDeleteClicked(sectionId: string) {
deleteConfirmationType = 'section';
sectionIdToDelete = sectionId;
$deleteConfirmation.open = true;
}
function syncReorderQuestionItems() {
reorderQuestionItemsBySectionId = Object.fromEntries(
activeSections.map((section) => [section.id, getQuestionsForSection($questionnaire.questions, section.id)])
);
}
function getReorderQuestionsForSection(sectionId: string, sectionQuestions: Question[]) {
return reorderQuestionItemsBySectionId[sectionId] ?? sectionQuestions;
}
function handleSectionQuestionDndConsider(sectionId: string, event: QuestionDndEvent) {
reorderQuestionItemsBySectionId = {
...reorderQuestionItemsBySectionId,
[sectionId]: event.detail.items
};
}
function handleSectionQuestionDndFinalize(sectionId: string, event: QuestionDndEvent) {
reorderQuestionItemsBySectionId = {
...reorderQuestionItemsBySectionId,
[sectionId]: event.detail.items
};
handleReorderQuestionsInSection(
sectionId,
event.detail.items.map((question) => question.id)
);
}
function getRequiredQuestionCount(questions: Question[]) {
return questions.filter((question) => toExerciseQuestionModel(question).required !== false).length;
}
$effect(() => {
if (reorderQuestions && !previousReorderQuestions) {
syncReorderQuestionItems();
}
if (!reorderQuestions && previousReorderQuestions) {
reorderQuestionItemsBySectionId = {};
}
previousReorderQuestions = reorderQuestions;
});
</script>
<DeleteConfirmationModal onCancel={() => (questionIdToDelete = null)} onDelete={onFinalDeleteClicked} />
<DeleteConfirmationModal
title={deleteConfirmationType === 'section'
? $t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.section_title')
: $t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.title')}
description={deleteConfirmationType === 'section'
? $t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.section_sure')
: $t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.sure')}
onCancel={onCancelDeleteClicked}
onDelete={onFinalDeleteClicked}
/>
<OrderModal />
{#snippet questionEditor(question, index)}
<QuestionContainer
elementId={getQuestionElementId(question.id)}
key={String(question.id ?? `new-${index}`)}
onClose={onInitDeleteClicked(question.id)}
scrollToQuestion={shouldScrollToLast(question.id, $questionnaire.questions)}
bind:points={question.points}
hasError={!!errors[question.id]}
errorMsg={getQuestionErrorMsg(errors, question, 'points')}
pointsHint={requiresPositivePointsForAutoGrade && Number(question.points) === 0
? $t('course.navItem.lessons.exercises.all_exercises.points_required_auto_grade')
: null}
onPointsChange={() => {
question.isDirty = true;
}}
>
{#if typeof question.code === 'string'}
<div class="my-3 flex w-3/5 items-center justify-between">
<TextareaField
bind:value={question.code}
rows={2}
placeholder={$t('course.navItem.lessons.exercises.all_exercises.edit_mode.write')}
/>
<IconButton onclick={() => handleCode(question.id, false)}>
<TrashIcon size={16} />
</IconButton>
</div>
{/if}
<div class="mt-2 flex flex-col">
<ExerciseQuestion.QuestionRenderer
showContainer={false}
contract={{
mode: 'edit',
question: toExerciseQuestionModel(question),
labels: questionLabels,
onImageUpload: uploadImage
}}
onQuestionChange={(nextQuestion) => onSharedQuestionChange(question.id, nextQuestion)}
>
{#snippet questionTypeSelect()}
<QuestionTypeSelect
value={question.questionTypeId?.toString()}
onValueChange={(nextValue) => onQuestionTypeChange(question.id, nextValue)}
triggerQuestionType={question?.questionType}
types={availableQuestionTypes}
{selfPacedCourse}
/>
{/snippet}
</ExerciseQuestion.QuestionRenderer>
{#if getQuestionErrorMsg(errors, question, 'option')}
<p class="text-sm text-red-500">{getQuestionErrorMsg(errors, question, 'option')}</p>
{/if}
</div>
</QuestionContainer>
{/snippet}
{#snippet compactQuestionRow(question, index)}
<div class="border-border flex items-center gap-3 rounded-md border bg-white px-4 py-3 dark:bg-black">
<span class="ui:text-muted-foreground w-6 text-sm font-medium tabular-nums">{index + 1}</span>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-semibold">
{question.title || getExerciseEditorQuestionTypeLabel(question?.questionType)}
</p>
<p class="ui:text-muted-foreground text-xs">{getExerciseEditorQuestionTypeLabel(question?.questionType)}</p>
</div>
</div>
{/snippet}
<div class="mb-20 w-full">
{#if Object.values(errors).length}
<div class="mb-4 flex w-full justify-center">
@@ -192,107 +429,92 @@
</div>
{/if}
<div class="questions px-6 pt-6">
{#each $questionnaire.questions as question, index (question.id)}
{#if !question.deletedAt}
<QuestionContainer
key={String(question.id ?? `new-${index}`)}
onClose={onInitDeleteClicked(question.id)}
scrollToQuestion={shouldScrollToLast(question.id, $questionnaire.questions)}
bind:points={question.points}
hasError={!!errors[question.id]}
errorMsg={getQuestionErrorMsg(errors, question, 'points')}
pointsHint={requiresPositivePointsForAutoGrade && Number(question.points) === 0
? $t('course.navItem.lessons.exercises.all_exercises.points_required_auto_grade')
: null}
onPointsChange={() => {
question.isDirty = true;
}}
>
{#if typeof question.code === 'string'}
<div class="my-3 flex w-3/5 items-center justify-between">
<TextareaField
bind:value={question.code}
rows={2}
placeholder={$t('course.navItem.lessons.exercises.all_exercises.edit_mode.write')}
/>
<IconButton onclick={() => handleCode(question.id, false)}>
<TrashIcon size={16} />
</IconButton>
</div>
{/if}
<div class="mt-2 flex flex-col">
<ExerciseQuestion.QuestionRenderer
showContainer={false}
contract={{
mode: 'edit',
question: toExerciseQuestionModel(question),
labels: questionLabels,
onImageUpload: uploadImage
{#if hasSections($questionnaire.sections)}
<div class="space-y-5">
{#each activeSections as section, sectionIndex (section.id)}
{@const sectionQuestions = getQuestionsForSection($questionnaire.questions, section.id)}
{@const reorderSectionQuestions = getReorderQuestionsForSection(section.id, sectionQuestions)}
<SectionEditor
exerciseSectionId={section.id}
title={section.title}
description={section.description}
sectionNumber={sectionIndex + 1}
totalSections={activeSections.length}
colorTheme={section.colorTheme}
questionCount={sectionQuestions.length}
requiredQuestionCount={getRequiredQuestionCount(sectionQuestions)}
labels={sectionLabels}
titleError={getSectionErrorMsg(errors, section.id, 'title')}
descriptionError={getSectionErrorMsg(errors, section.id, 'description')}
showReorderHandles={reorderQuestions}
onTitleChange={(title) => handleUpdateSectionTitle(section.id, title)}
onDescriptionChange={(description) => handleUpdateSectionDescription(section.id, description)}
onColorThemeChange={(colorTheme) => handleUpdateSectionColorTheme(section.id, colorTheme)}
onAddQuestion={() => addQuestionToSection(section.id)}
onDelete={() => onInitSectionDeleteClicked(section.id)}
onMoveUp={() => moveSection(section.id, -1)}
onMoveDown={() => moveSection(section.id, 1)}
canMoveUp={sectionIndex > 0}
canMoveDown={sectionIndex < activeSections.length - 1}
>
<div
class="space-y-2"
use:dndzone={{
items: reorderQuestions ? reorderSectionQuestions : sectionQuestions,
flipDurationMs,
dropTargetStyle: {
border: '2px #1d4ed8 solid',
'border-style': 'dashed',
'border-radius': '0.5rem'
},
dragDisabled: !reorderQuestions,
type: `exercise-section-question-${section.id}`
}}
onQuestionChange={(nextQuestion) => onSharedQuestionChange(question.id, nextQuestion)}
onconsider={(event) => handleSectionQuestionDndConsider(section.id, event)}
onfinalize={(event) => handleSectionQuestionDndFinalize(section.id, event)}
>
{#snippet questionTypeSelect()}
<Select.Root
type="single"
value={question.questionTypeId?.toString()}
onValueChange={(value) => onQuestionTypeChange(question.id, value)}
{#each reorderQuestions ? reorderSectionQuestions : sectionQuestions as question, index (question.id)}
<div
animate:flip={{ duration: flipDurationMs }}
class="flex items-start gap-3 transition {reorderQuestions
? 'cursor-grab active:cursor-grabbing'
: ''}"
>
<Select.Trigger class="w-[180px]">
{getQuestionTypeLabel(question?.questionType)}
</Select.Trigger>
<Select.Content>
{#each QUESTION_TYPES as type (type.key)}
{#if $isFreePlan && PREMIUM_QUESTION_TYPE_KEYS.has(type.key)}
<button
type="button"
class="ui:flex ui:w-full ui:cursor-pointer ui:select-none ui:items-center ui:gap-2 ui:rounded-sm ui:py-1.5 ui:pl-2 ui:pr-8 ui:text-sm ui:outline-hidden ui:hover:bg-accent ui:hover:text-accent-foreground ui:relative"
onclick={(e) => {
e.preventDefault();
e.stopPropagation();
openUpgradeModal();
}}
title={$t('course.navItem.lessons.exercises.all_exercises.edit_mode.premium_question_type')}
>
<PremiumIcon size={16} class="ui:text-blue-700 ui:dark:text-white ui:shrink-0" />
<span>{getQuestionTypeLabel(type)}</span>
</button>
{:else}
<Select.Item value={type.id.toString()} label={getQuestionTypeLabel(type)}>
{#snippet children({ selected: _sel })}
<span class="flex w-full min-w-0 items-center justify-between gap-2">
<span class="truncate">{getQuestionTypeLabel(type)}</span>
{#if selfPacedCourse}
<Badge
variant={type.autoGradable ? 'success' : 'warning'}
class="ui:shrink-0 ui:text-[10px] ui:font-normal"
>
{type.autoGradable
? $t(
'course.navItem.lessons.exercises.all_exercises.edit_mode.question_type_auto_gradable'
)
: $t(
'course.navItem.lessons.exercises.all_exercises.edit_mode.question_type_manual_grading'
)}
</Badge>
{/if}
</span>
{/snippet}
</Select.Item>
{/if}
{/each}
</Select.Content>
</Select.Root>
{/snippet}
</ExerciseQuestion.QuestionRenderer>
<div class="ui:text-muted-foreground flex w-5 shrink-0 flex-col items-center gap-2 pt-3">
{#if reorderQuestions}
<GripVerticalIcon class="h-4 w-4 cursor-grab active:cursor-grabbing" aria-hidden="true" />
{/if}
<span class="text-xs font-medium tabular-nums">{index + 1}</span>
</div>
<div class="min-w-0 flex-1">
{#if reorderQuestions}
{@render compactQuestionRow(question, index)}
{:else}
{@render questionEditor(question, index)}
{/if}
</div>
</div>
{/each}
</div>
</SectionEditor>
{#if getQuestionErrorMsg(errors, question, 'option')}
<p class="text-sm text-red-500">{getQuestionErrorMsg(errors, question, 'option')}</p>
{/if}
</div>
</QuestionContainer>
{/if}
{/each}
<ExerciseQuestion.SectionAfterBehavior
{sectionIndex}
currentSectionId={section.id}
allSections={activeSections}
afterBehavior={section.afterBehavior}
labels={sectionAfterBehaviorLabels}
onChange={(behavior) => handleUpdateSectionAfterBehavior(section.id, behavior)}
/>
{/each}
</div>
{:else}
{#each $questionnaire.questions as question, index (question.id)}
{#if !question.deletedAt}
{@render questionEditor(question, index)}
{/if}
{/each}
{/if}
</div>
</div>
@@ -2,11 +2,13 @@
import { Switch } from '@cio/ui/base/switch';
import { Label } from '@cio/ui/base/label';
import { Button } from '@cio/ui/base/button';
import { InputField } from '@cio/ui/custom/input-field';
import { questionnaire } from './store';
import { exerciseApi } from '$features/course/api';
import { courseApi } from '$features/course/api';
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$features/ui/snackbar/store';
import { slugifyTitle } from '@cio/utils/validation';
type Props = {
exerciseId: string;
@@ -15,13 +17,21 @@
let { exerciseId }: Props = $props();
let saving = $state(false);
let slug = $state($questionnaire.slug ?? '');
$effect(() => {
slug = $questionnaire.slug ?? '';
});
const isPublicCourse = $derived(courseApi.course?.type === 'PUBLIC');
async function saveSettings() {
if (!courseApi.course?.id) return;
saving = true;
await exerciseApi.update(courseApi.course.id, exerciseId, {
allowMultipleAttempts: !!$questionnaire.allowMultipleAttempts
allowMultipleAttempts: !!$questionnaire.allowMultipleAttempts,
slug: isPublicCourse && slug ? slug : undefined
});
saving = false;
@@ -50,6 +60,21 @@
/>
</div>
{#if isPublicCourse}
<div class="rounded-lg border border-neutral-200 p-4 dark:border-neutral-700">
<InputField
label={$t('course.navItem.settings.slug.label')}
helperMessage={$t('course.navItem.settings.slug.description')}
value={slug}
placeholder={slugifyTitle($questionnaire.title ?? '')}
onInputChange={(e) => {
slug = e.currentTarget.value;
}}
errorMessage={exerciseApi.errors.slug}
/>
</div>
{/if}
<Button onclick={saveSettings} loading={saving} disabled={!courseApi.course?.id}>
{$t('course.navItem.lessons.exercises.all_exercises.settings_save')}
</Button>
@@ -158,6 +158,7 @@ export function transformQuestionsToApiFormat(
question: q.title,
points: q.points || 0,
questionTypeId: q.questionTypeId,
exerciseSectionId: q.exerciseSectionId ?? null,
order: typeof (q as { order?: number }).order === 'number' ? (q as { order: number }).order : index + 1,
settings: getQuestionSettings(q),
options: questionTypeSupportsOptions(getQuestionTypeKey(q)) ? formatOptions(q.options) : [],
@@ -6,10 +6,14 @@
import { toExerciseQuestionModel } from './question-type-utils';
import { getExerciseQuestionLabels } from './question-labels';
import { ExerciseQuestion } from '@cio/ui';
import type { QuestionnaireMetaData } from './store';
import { NumberBadge } from '@cio/ui/base/number-badge';
import type { ExerciseSectionState, QuestionnaireMetaData } from './store';
import { t } from '$lib/utils/functions/translations';
import { getExerciseSectionDisplayTitle } from '$features/course/utils/exercise-section-utils';
interface Props {
questions?: Question[];
sections?: ExerciseSectionState[];
questionnaireMetaData?: Partial<QuestionnaireMetaData>;
/** Per-question scores (e.g. from `questionnaireMetaData.grades` or grading modal `questionAnswerByPoint`). */
grades?: Record<string, number>;
@@ -22,6 +26,7 @@
let {
questions = [],
sections = [],
questionnaireMetaData: _questionnaireMetaData = {},
grades = $bindable({}),
disableGrading = true,
@@ -46,6 +51,9 @@
const questionModels = $derived(questions.map((question) => toExerciseQuestionModel(question)));
const questionLabels = $derived(getExerciseQuestionLabels());
const sectionFallbackTitle = $derived($t('course.navItem.lessons.exercises.all_exercises.section.fallback_title'));
const activeSections = $derived(sections.filter((section) => !section.deletedAt).sort((a, b) => a.order - b.order));
const hasSectionGroups = $derived(activeSections.length > 0);
const answersByKey = $derived(
questionModels.reduce(
(acc, questionModel, index) => {
@@ -56,9 +64,55 @@
{} as Record<string, AnswerData>
)
);
function getQuestionsForSection(exerciseSectionId: string) {
return questions.filter((question) => !question.deletedAt && question.exerciseSectionId === exerciseSectionId);
}
function getSectionPoints(sectionQuestions: Question[]) {
return sectionQuestions.reduce((total, question) => total + Number(question.points ?? 0), 0);
}
</script>
{#if disableGrading && !Object.keys(grades || {}).length}
{#if hasSectionGroups}
<div class="space-y-6">
{#each activeSections as section, sectionIndex (section.id)}
{@const sectionQuestions = getQuestionsForSection(section.id)}
{@const sectionQuestionModels = sectionQuestions.map((question) => toExerciseQuestionModel(question))}
<section class="space-y-4 pt-4">
<ExerciseQuestion.SectionHeader
title={getExerciseSectionDisplayTitle({
title: section.title,
sectionNumber: sectionIndex + 1,
sectionLabel: sectionFallbackTitle
})}
description={section.description}
sectionNumber={sectionIndex + 1}
totalSections={activeSections.length}
colorTheme={section.colorTheme}
questionCount={sectionQuestions.length}
totalPoints={getSectionPoints(sectionQuestions)}
labels={{
section: $t('course.navItem.lessons.exercises.all_exercises.section.fallback_title'),
questions: $t('course.navItem.lessons.exercises.all_exercises.view_mode.questions'),
points: $t('course.navItem.lessons.exercises.all_exercises.view_mode.points')
}}
/>
<ExerciseQuestion.QuestionList
contract={{
mode: disableGrading ? 'review' : 'review',
questions: sectionQuestionModels,
answersByKey,
labels: questionLabels,
disabled: true
}}
itemClass="mb-4"
/>
</section>
{/each}
</div>
{:else if disableGrading && !Object.keys(grades || {}).length}
<ExerciseQuestion.QuestionList
contract={{ mode: 'review', questions: questionModels, answersByKey, labels: questionLabels, disabled: true }}
itemClass="mb-4"
@@ -67,8 +121,8 @@
{#each questions as currentQuestion, index (currentQuestion.id)}
{@const questionModel = questionModels[index]}
<div class="mb-4 space-y-3">
<div class="flex items-center justify-between">
<p class="text-base font-semibold text-gray-700 dark:text-gray-200">Q{index + 1}</p>
<div class="flex items-center justify-between gap-2">
<NumberBadge number={index + 1} active />
<Grade grade={grades[currentQuestion.id]} gradeMax={currentQuestion.points} disableGrading={true} />
</div>
@@ -87,8 +141,8 @@
{#each questions as currentQuestion, index (currentQuestion.id)}
{@const questionModel = questionModels[index]}
<div class="mb-4 space-y-3">
<div class="flex items-center justify-between">
<p class="text-base font-semibold text-gray-700 dark:text-gray-200">Q{index + 1}</p>
<div class="flex items-center justify-between gap-2">
<NumberBadge number={index + 1} active />
<Grade bind:grade={grades[currentQuestion.id]} gradeMax={currentQuestion.points} {disableGrading} />
</div>
@@ -289,6 +289,111 @@ export function getExerciseQuestionLabels(): ExerciseQuestionLabels {
'file_upload.preview.max_size_label': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.file_upload.preview.max_size_label'
),
'video_recording.edit.max_duration_label': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.edit.max_duration_label'
),
'video_recording.edit.max_duration_helper': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.edit.max_duration_helper'
),
'video_recording.edit.max_duration_placeholder': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.edit.max_duration_placeholder'
),
'video_recording.edit.manual_grading': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.edit.manual_grading'
),
'video_recording.take.unsupported': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.take.unsupported'
),
'video_recording.take.permission_prompt': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.take.permission_prompt'
),
'video_recording.take.permission_denied': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.take.permission_denied'
),
'video_recording.take.start_camera': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.take.start_camera'
),
'video_recording.take.camera_label': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.take.camera_label'
),
'video_recording.take.camera_placeholder': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.take.camera_placeholder'
),
'video_recording.take.play_preview': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.take.play_preview'
),
'video_recording.take.pause_preview': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.take.pause_preview'
),
'video_recording.take.start_recording': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.take.start_recording'
),
'video_recording.take.stop_recording': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.take.stop_recording'
),
'video_recording.take.elapsed': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.take.elapsed'
),
'video_recording.take.remaining': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.take.remaining'
),
'video_recording.take.max_duration': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.take.max_duration'
),
'video_recording.take.too_short': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.take.too_short'
),
'video_recording.take.use_recording': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.take.use_recording'
),
'video_recording.take.retake': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.take.retake'
),
'video_recording.take.delete': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.take.delete'
),
'video_recording.take.retry_upload': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.take.retry_upload'
),
'video_recording.take.uploading': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.take.uploading'
),
'video_recording.take.uploaded': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.take.uploaded'
),
'video_recording.take.upload_failed': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.take.upload_failed'
),
'video_recording.take.replaces_previous': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.take.replaces_previous'
),
'video_recording.preview.max_duration_label': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.preview.max_duration_label'
),
'video_recording.preview.manual_grading': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.preview.manual_grading'
),
'video_recording.submission.no_video': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.submission.no_video'
),
'video_recording.submission.unavailable': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.submission.unavailable'
),
'video_recording.submission.duration': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.submission.duration'
),
'video_recording.submission.file_size': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.submission.file_size'
),
'video_recording.submission.recorded_at': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.submission.recorded_at'
),
'video_recording.submission.uploaded_at': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.submission.uploaded_at'
),
'video_recording.submission.retake_count': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.video_recording.submission.retake_count'
),
'link.edit.instructions_placeholder': t.get(
'course.navItem.lessons.exercises.all_exercises.shared_question.link.edit.instructions_placeholder'
),
@@ -0,0 +1,66 @@
<script lang="ts">
import * as Select from '@cio/ui/base/select';
import { Badge } from '@cio/ui/base/badge';
import { PREMIUM_QUESTION_TYPE_KEYS, QUESTION_TYPES } from '$features/ui/question/constants';
import { PremiumIcon } from '@cio/ui/custom/moving-icons';
import { getExerciseEditorQuestionTypeLabel } from './question-type-utils';
import { isFreePlan } from '$lib/utils/store/org';
import { openUpgradeModal } from '$lib/utils/functions/org';
import { t } from '$lib/utils/functions/translations';
import type { Question } from '$features/course/types';
type QuestionTypeEntry = (typeof QUESTION_TYPES)[number];
interface Props {
value: string | undefined;
onValueChange: (value: string) => void;
triggerQuestionType: Question['questionType'] | undefined;
types: QuestionTypeEntry[];
selfPacedCourse?: boolean;
}
let { value, onValueChange, triggerQuestionType, types, selfPacedCourse = false }: Props = $props();
</script>
<Select.Root type="single" {value} {onValueChange}>
<Select.Trigger class="w-full min-w-0 sm:w-[180px]">
{getExerciseEditorQuestionTypeLabel(triggerQuestionType)}
</Select.Trigger>
<Select.Content>
{#each types as typeEntry (typeEntry.key)}
{#if $isFreePlan && PREMIUM_QUESTION_TYPE_KEYS.has(typeEntry.key)}
<button
type="button"
class="ui:flex ui:w-full ui:cursor-pointer ui:select-none ui:items-center ui:gap-2 ui:rounded-sm ui:py-1.5 ui:pl-2 ui:pr-8 ui:text-sm ui:outline-hidden ui:hover:bg-accent ui:hover:text-accent-foreground ui:relative"
onclick={(clickEvent) => {
clickEvent.preventDefault();
clickEvent.stopPropagation();
openUpgradeModal();
}}
title={$t('course.navItem.lessons.exercises.all_exercises.edit_mode.premium_question_type')}
>
<PremiumIcon size={16} class="ui:text-blue-700 ui:dark:text-white ui:shrink-0" />
<span>{getExerciseEditorQuestionTypeLabel(typeEntry)}</span>
</button>
{:else}
<Select.Item value={typeEntry.id.toString()} label={getExerciseEditorQuestionTypeLabel(typeEntry)}>
{#snippet children({ selected: _sel })}
<span class="flex w-full min-w-0 items-center justify-between gap-2">
<span class="truncate">{getExerciseEditorQuestionTypeLabel(typeEntry)}</span>
{#if selfPacedCourse}
<Badge
variant={typeEntry.autoGradable ? 'success' : 'warning'}
class="ui:shrink-0 ui:text-[10px] ui:font-normal"
>
{typeEntry.autoGradable
? $t('course.navItem.lessons.exercises.all_exercises.edit_mode.question_type_auto_gradable')
: $t('course.navItem.lessons.exercises.all_exercises.edit_mode.question_type_manual_grading')}
</Badge>
{/if}
</span>
{/snippet}
</Select.Item>
{/if}
{/each}
</Select.Content>
</Select.Root>
@@ -8,9 +8,19 @@ import {
} from '@cio/question-types';
import type { Question } from '$features/course/types';
import { DEFAULT_QUESTION_TYPE, QUESTION_TYPES } from '$features/ui/question/constants';
import { t } from '$lib/utils/functions/translations';
type QuestionTypeOption = (typeof QUESTION_TYPES)[number];
/** Label for exercise editor picker and lists (translations via labelKey when present). */
export function getExerciseEditorQuestionTypeLabel(
questionType: Question['questionType'] | QuestionTypeOption | null | undefined
): string {
if (!questionType) return t.get('course.navItem.lessons.exercises.all_exercises.edit_mode.select_type');
if ('labelKey' in questionType && questionType.labelKey) return t.get(questionType.labelKey);
return questionType.label ?? t.get('course.navItem.lessons.exercises.all_exercises.edit_mode.select_type');
}
const OPTION_QUESTION_TYPE_KEYS = new Set<ExerciseQuestionTypeKey>([
QUESTION_TYPE_KEY.RADIO,
QUESTION_TYPE_KEY.CHECKBOX,
@@ -54,6 +64,12 @@ export function toExerciseQuestionModel(question: Question): ExerciseQuestionMod
const rawSettings = (question as Question & { settings?: Record<string, unknown> }).settings;
const settings = rawSettings && typeof rawSettings === 'object' ? rawSettings : {};
const required =
typeof (question as Question & { required?: boolean }).required === 'boolean'
? (question as Question & { required?: boolean }).required
: typeof settings.required === 'boolean'
? settings.required
: true;
return {
id: question.id,
@@ -61,6 +77,7 @@ export function toExerciseQuestionModel(question: Question): ExerciseQuestionMod
title: question.title || '',
questionType: getQuestionTypeKey(question),
points: Number(question.points || 0),
required,
options,
settings
};
@@ -0,0 +1,184 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import type { ExerciseSectionColorTheme } from '@cio/question-types';
import * as Field from '@cio/ui/base/field';
import { Button } from '@cio/ui/base/button';
import { Input } from '@cio/ui/base/input';
import { IconButton } from '@cio/ui/custom/icon-button';
import { TextareaField } from '@cio/ui/custom/textarea-field';
import GripVerticalIcon from '@lucide/svelte/icons/grip-vertical';
import XIcon from '@lucide/svelte/icons/x';
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up';
import ArrowDownIcon from '@lucide/svelte/icons/arrow-down';
const COLOR_THEMES: ExerciseSectionColorTheme[] = ['blue', 'green', 'amber', 'rose', 'violet', 'slate'];
const COLOR_CLASSES: Record<ExerciseSectionColorTheme, string> = {
blue: 'bg-blue-500',
green: 'bg-emerald-500',
amber: 'bg-amber-500',
rose: 'bg-rose-500',
violet: 'bg-violet-500',
slate: 'bg-slate-500'
};
const BORDER_CLASSES: Record<ExerciseSectionColorTheme, string> = {
blue: 'border-blue-500',
green: 'border-emerald-500',
amber: 'border-amber-500',
rose: 'border-rose-500',
violet: 'border-violet-500',
slate: 'border-slate-500'
};
interface Props {
exerciseSectionId: string;
title: string;
description?: string | null;
sectionNumber: number;
totalSections: number;
colorTheme: ExerciseSectionColorTheme;
questionCount: number;
requiredQuestionCount: number;
titleError?: string;
descriptionError?: string;
showReorderHandles?: boolean;
onTitleChange?: (title: string) => void;
onDescriptionChange?: (description: string | null) => void;
onColorThemeChange?: (colorTheme: ExerciseSectionColorTheme) => void;
onAddQuestion?: () => void;
onDelete?: () => void;
onMoveUp?: () => void;
onMoveDown?: () => void;
canMoveUp?: boolean;
canMoveDown?: boolean;
labels?: Record<string, string>;
children?: Snippet;
}
let {
exerciseSectionId,
title,
description = null,
colorTheme,
questionCount,
requiredQuestionCount,
titleError = '',
descriptionError = '',
showReorderHandles = false,
onTitleChange,
onDescriptionChange,
onColorThemeChange,
onAddQuestion,
onDelete,
onMoveUp,
onMoveDown,
canMoveUp = false,
canMoveDown = false,
labels = {},
children
}: Props = $props();
let localTitle = $derived(title);
let localDescription = $derived(description ?? '');
function handleTitleInput() {
onTitleChange?.(localTitle);
}
function handleDescriptionInput() {
onDescriptionChange?.(localDescription || null);
}
</script>
<section
id={`exercise-section-${exerciseSectionId}`}
class={`overflow-hidden rounded-lg border bg-white shadow-xs dark:bg-zinc-950 ${BORDER_CLASSES[colorTheme]}`}
>
<div class="flex items-center gap-3 border-b bg-zinc-50 px-4 py-3 dark:bg-zinc-900">
{#if showReorderHandles}
<GripVerticalIcon class="ui:text-muted-foreground h-4 w-4 shrink-0" aria-hidden="true" />
{/if}
<span class={`h-2.5 w-2.5 shrink-0 rounded-full ${COLOR_CLASSES[colorTheme]}`}></span>
<Input
bind:value={localTitle}
oninput={handleTitleInput}
aria-invalid={titleError ? 'true' : undefined}
class={`min-w-0 flex-1 border-transparent bg-transparent px-1 font-semibold shadow-none focus-visible:ring-1 ${titleError ? 'ui:border-destructive' : ''}`}
/>
<div class="flex shrink-0 items-center gap-2">
{#each COLOR_THEMES as theme (theme)}
<button
type="button"
class={`ui:border h-3 w-3 cursor-pointer rounded-full ${COLOR_CLASSES[theme]} ${theme === colorTheme ? 'ring-2 ring-offset-2 dark:ring-offset-zinc-900' : ''}`}
title={`${labels.color ?? 'Section color'}: ${theme}`}
onclick={() => onColorThemeChange?.(theme)}
></button>
{/each}
</div>
<span class="ui:text-muted-foreground shrink-0 text-sm font-semibold">
{questionCount}
{labels.shortQuestions ?? labels.questions ?? 'q'}
</span>
<IconButton
onclick={onDelete}
tooltip={labels.delete ?? 'Delete section'}
tooltipSide="bottom"
variant="outline"
size="icon-sm"
>
<XIcon size={16} />
</IconButton>
</div>
{#if titleError}
<div class="bg-white px-4 pt-2 dark:bg-zinc-950">
<Field.Error>{titleError}</Field.Error>
</div>
{/if}
<div class="border-b bg-white px-4 py-3 dark:bg-zinc-950">
<TextareaField
bind:value={localDescription}
rows={2}
oninput={handleDescriptionInput}
placeholder={labels.descriptionPlaceholder ?? 'Describe this section'}
errorMessage={descriptionError}
className="m-0"
bgColor="resize-none border-dashed bg-transparent text-sm"
/>
</div>
<div class="space-y-4 bg-white px-4 py-4 dark:bg-zinc-950">
{@render children?.()}
<div class="flex justify-center">
<Button variant="outline" onclick={onAddQuestion}>+ {labels.addQuestion ?? 'Add question'}</Button>
</div>
</div>
<div class="flex items-center justify-between border-t bg-zinc-50 px-4 py-3 dark:bg-zinc-900">
<p class="ui:text-muted-foreground text-sm">
{requiredQuestionCount}
{labels.required ?? 'required'}
</p>
<div class="flex items-center gap-1">
<IconButton
onclick={onMoveUp}
disabled={!canMoveUp}
tooltip={labels.moveUp ?? 'Move section up'}
tooltipSide="bottom"
>
<ArrowUpIcon size={16} />
</IconButton>
<IconButton
onclick={onMoveDown}
disabled={!canMoveDown}
tooltip={labels.moveDown ?? 'Move section down'}
tooltipSide="bottom"
>
<ArrowDownIcon size={16} />
</IconButton>
</div>
</div>
</section>
@@ -1,18 +1,33 @@
import { DEFAULT_QUESTION_TYPE, QUESTION_TEMPLATE } from '$features/ui/question/constants';
import type { AnswerData } from '@cio/question-types';
import type {
AnswerData,
ExerciseSectionAfterBehavior,
ExerciseSectionColorTheme,
ExerciseSectionDisplayMode
} from '@cio/question-types';
import type { Question } from '$features/course/types';
import { STATUS } from './constants';
import type { Writable } from 'svelte/store';
import { isUUID } from '$lib/utils/functions/isUUID';
import { writable } from 'svelte/store';
import { UNTITLED_EXERCISE_SECTION_TITLE } from '$features/course/utils/exercise-section-utils';
export const isQuestionnaireFetching = writable(false);
export const deleteConfirmation = writable({ open: false });
export const questionnaireOrder = writable({ open: false });
// {'question-id': { option: '', title: '', ... }}
export const questionnaireValidation = writable({});
export interface ExerciseEditorFieldErrors {
option?: string;
title?: string;
points?: string;
description?: string;
}
export type ExerciseEditorErrors = Record<string, ExerciseEditorFieldErrors>;
// {'question-or-section-id': { option: '', title: '', description: '', ... }}
export const questionnaireValidation = writable<ExerciseEditorErrors>({});
export interface QuestionnaireMetaData {
answers: Record<string, AnswerData | undefined>;
@@ -27,6 +42,34 @@ export interface QuestionnaireMetaData {
comment: string;
/** Set when exercise is finished so route knows not to overwrite questionnaire */
exerciseId: string | null;
currentSectionIndex: number;
sectionPhase: 'overview' | 'questions';
}
export interface ExerciseSectionState {
id: string;
title: string;
description: string | null;
order: number;
colorTheme: ExerciseSectionColorTheme;
afterBehavior: ExerciseSectionAfterBehavior;
isDirty?: boolean;
deletedAt?: string;
}
export interface QuestionnaireState {
title?: string | null;
dueBy?: string | null;
isDueByDirty?: boolean;
isTitleDirty?: boolean;
description?: string | null;
isDescriptionDirty?: boolean;
questions: Question[];
sections: ExerciseSectionState[];
sectionDisplayMode: ExerciseSectionDisplayMode;
totalSubmissions: number;
allowMultipleAttempts?: boolean;
slug?: string | null;
}
const initAnswerState: QuestionnaireMetaData = {
@@ -40,22 +83,14 @@ const initAnswerState: QuestionnaireMetaData = {
progressValue: 0,
status: STATUS.PENDING,
comment: '',
exerciseId: null
exerciseId: null,
currentSectionIndex: 0,
sectionPhase: 'overview'
};
export const questionnaireMetaData: Writable<QuestionnaireMetaData> = writable(initAnswerState);
export const questionnaire: Writable<{
title?: string | null;
dueBy?: string | null;
isDueByDirty?: boolean;
isTitleDirty?: boolean;
description?: string | null;
isDescriptionDirty?: boolean;
questions: Question[];
totalSubmissions: number;
allowMultipleAttempts?: boolean;
}> = writable({
export const questionnaire: Writable<QuestionnaireState> = writable({
title: '',
dueBy: '',
isDueByDirty: false,
@@ -63,8 +98,11 @@ export const questionnaire: Writable<{
description: '',
isDescriptionDirty: false,
questions: [],
sections: [],
sectionDisplayMode: 'one_question',
totalSubmissions: 0,
allowMultipleAttempts: false
allowMultipleAttempts: false,
slug: ''
});
export function clearQuestionnaireValidation() {
@@ -84,7 +122,9 @@ export function reset() {
finalTotalGrade: 0,
status: STATUS.PENDING,
comment: '',
progressValue: 100
progressValue: 100,
currentSectionIndex: 0,
sectionPhase: 'overview'
}));
clearQuestionnaireValidation();
@@ -106,8 +146,8 @@ export function resetStudentExerciseTake() {
export function mapZodErrorsToQuestionErrors(
zodErrors: Record<string, string>,
questions: Question[]
): Record<string, { option?: string; title?: string; points?: string }> {
const errors: Record<string, { option?: string; title?: string; points?: string }> = {};
): ExerciseEditorErrors {
const errors: ExerciseEditorErrors = {};
for (const [path, message] of Object.entries(zodErrors)) {
const pathParts = path.split('.');
const questionIndex = pathParts[0] === 'questions' ? parseInt(pathParts[1]) : -1;
@@ -134,20 +174,45 @@ export function mapZodErrorsToQuestionErrors(
return errors;
}
export function handleAddQuestion() {
export function hasSections(sections: ExerciseSectionState[]) {
return sections.some((section) => !section.deletedAt);
}
function compareQuestionsByOrder(leftQuestion: Question, rightQuestion: Question) {
const leftOrder = leftQuestion.order ?? 0;
const rightOrder = rightQuestion.order ?? 0;
if (leftOrder !== rightOrder) return leftOrder - rightOrder;
return String(leftQuestion.id).localeCompare(String(rightQuestion.id), undefined, { numeric: true });
}
export function getQuestionsForSection(questions: Question[], exerciseSectionId: string) {
return questions
.filter((question) => !question.deletedAt && question.exerciseSectionId === exerciseSectionId)
.sort(compareQuestionsByOrder);
}
export function handleAddQuestion(exerciseSectionId?: string | null) {
let nextQuestionId: string | number = '';
questionnaire.update((q) => {
const { questions } = q;
const activeSections = q.sections.filter((section) => !section.deletedAt);
const nextExerciseSectionId = exerciseSectionId ?? (activeSections.length > 0 ? activeSections[0].id : null);
nextQuestionId = `${questions.length + 1}-form`;
return {
...q,
questions: [
...questions,
{
...QUESTION_TEMPLATE,
id: `${questions.length + 1}-form`,
name: `${questions.length + 1}-form`,
id: nextQuestionId,
name: `${nextQuestionId}`,
value: '',
points: 0,
order: questions.length,
exerciseSectionId: nextExerciseSectionId,
questionType: DEFAULT_QUESTION_TYPE,
questionTypeId: DEFAULT_QUESTION_TYPE.id,
options: [
@@ -162,6 +227,217 @@ export function handleAddQuestion() {
]
};
});
return nextQuestionId;
}
export function handleAddSection() {
let nextSectionId = '';
questionnaire.update((q) => {
const activeSections = q.sections.filter((section) => !section.deletedAt);
const firstAvailableSectionOrder =
activeSections.reduce((highestOrder, section) => Math.max(highestOrder, section.order), -1) + 1;
const activeUnsectionedQuestions = q.questions.filter(
(question) => !question.deletedAt && !question.exerciseSectionId
);
const nextSectionDisplayNumber = activeSections.length + (activeUnsectionedQuestions.length > 0 ? 2 : 1);
const nextSections = [...q.sections];
let questions = q.questions;
let nextSectionOrder = firstAvailableSectionOrder;
if (activeUnsectionedQuestions.length > 0) {
const untitledSectionId = crypto.randomUUID();
const untitledSection: ExerciseSectionState = {
id: untitledSectionId,
title: UNTITLED_EXERCISE_SECTION_TITLE,
description: null,
order: nextSectionOrder,
colorTheme: 'blue',
afterBehavior: { action: 'continue' },
isDirty: true
};
nextSectionOrder += 1;
nextSections.push(untitledSection);
questions = q.questions.map((question) => {
if (question.deletedAt || question.exerciseSectionId) return question;
return {
...question,
exerciseSectionId: untitledSectionId,
isDirty: true
};
});
}
const sectionId = crypto.randomUUID();
nextSectionId = sectionId;
const nextSection: ExerciseSectionState = {
id: sectionId,
title: `Section ${nextSectionDisplayNumber}`,
description: null,
order: nextSectionOrder,
colorTheme: 'blue',
afterBehavior: { action: 'continue' },
isDirty: true
};
return {
...q,
questions,
sections: [...nextSections, nextSection]
};
});
return nextSectionId;
}
export function handleRemoveSection(exerciseSectionId: string, moveQuestionsToExerciseSectionId?: string | null) {
questionnaire.update((q) => {
const activeSections = q.sections.filter((section) => !section.deletedAt);
const remainingSections = activeSections.filter((section) => section.id !== exerciseSectionId);
const shouldClearSections = remainingSections.length === 0;
const deletedAt = new Date().toString();
return {
...q,
sections: q.sections.map((section) =>
section.id === exerciseSectionId ? { ...section, deletedAt, isDirty: true } : section
),
questions: q.questions.map((question) => {
if (question.exerciseSectionId !== exerciseSectionId) return question;
if (moveQuestionsToExerciseSectionId || shouldClearSections) {
return {
...question,
exerciseSectionId: shouldClearSections ? null : moveQuestionsToExerciseSectionId,
isDirty: true
};
}
return {
...question,
deletedAt,
isDirty: true
};
})
};
});
}
export function handleMoveQuestionToSection(questionId: string | number, exerciseSectionId: string | null) {
questionnaire.update((q) => ({
...q,
questions: q.questions.map((question) =>
question.id === questionId ? { ...question, exerciseSectionId, isDirty: true } : question
)
}));
}
export function handleUpdateSectionAfterBehavior(
exerciseSectionId: string,
afterBehavior: ExerciseSectionAfterBehavior
) {
questionnaire.update((q) => ({
...q,
sections: q.sections.map((section) =>
section.id === exerciseSectionId ? { ...section, afterBehavior, isDirty: true } : section
)
}));
}
export function handleUpdateSectionColorTheme(exerciseSectionId: string, colorTheme: ExerciseSectionColorTheme) {
questionnaire.update((q) => ({
...q,
sections: q.sections.map((section) =>
section.id === exerciseSectionId ? { ...section, colorTheme, isDirty: true } : section
)
}));
}
export function handleUpdateSectionTitle(exerciseSectionId: string, title: string) {
questionnaire.update((q) => ({
...q,
sections: q.sections.map((section) =>
section.id === exerciseSectionId ? { ...section, title, isDirty: true } : section
)
}));
}
export function handleUpdateSectionDescription(exerciseSectionId: string, description: string | null) {
questionnaire.update((q) => ({
...q,
sections: q.sections.map((section) =>
section.id === exerciseSectionId ? { ...section, description, isDirty: true } : section
)
}));
}
export function handleReorderSections(orderedSectionIds: string[]) {
const orderBySectionId = new Map(orderedSectionIds.map((sectionId, index) => [sectionId, index]));
questionnaire.update((q) => ({
...q,
sections: q.sections.map((section) => {
const nextOrder = orderBySectionId.get(section.id);
if (nextOrder === undefined || nextOrder === section.order) return section;
return { ...section, order: nextOrder, isDirty: true };
})
}));
}
export function handleReorderQuestionsInSection(exerciseSectionId: string, orderedQuestionIds: Array<string | number>) {
questionnaire.update((q) => {
const activeSections = [...q.sections].filter((section) => !section.deletedAt).sort((a, b) => a.order - b.order);
const sectionExists = activeSections.some((section) => section.id === exerciseSectionId);
if (!sectionExists) return q;
const questionById = new Map(q.questions.map((question) => [String(question.id), question]));
const orderedQuestionIdSet = new Set(orderedQuestionIds.map((questionId) => String(questionId)));
const reorderedActiveQuestions: Question[] = [];
for (const section of activeSections) {
const sectionQuestions = getQuestionsForSection(q.questions, section.id);
if (section.id !== exerciseSectionId) {
reorderedActiveQuestions.push(...sectionQuestions);
continue;
}
const orderedSectionQuestions = orderedQuestionIds
.map((questionId) => questionById.get(String(questionId)))
.filter(
(question): question is Question =>
Boolean(question) && !question.deletedAt && question.exerciseSectionId === exerciseSectionId
);
const missingSectionQuestions = sectionQuestions.filter(
(question) => !orderedQuestionIdSet.has(String(question.id))
);
reorderedActiveQuestions.push(...orderedSectionQuestions, ...missingSectionQuestions);
}
const activeUnsectionedQuestions = q.questions
.filter((question) => !question.deletedAt && !question.exerciseSectionId)
.sort(compareQuestionsByOrder);
const nextOrderByQuestionId = new Map(
[...reorderedActiveQuestions, ...activeUnsectionedQuestions].map((question, index) => [
String(question.id),
index + 1
])
);
return {
...q,
questions: q.questions.map((question) => {
const nextOrder = nextOrderByQuestionId.get(String(question.id));
if (nextOrder === undefined || question.order === nextOrder) return question;
return { ...question, order: nextOrder, isDirty: true };
})
};
});
}
export function handleAddOption(questionId) {
@@ -4,13 +4,14 @@
import { UserAvatar } from '@cio/ui/custom/user-avatar';
import { submissions } from './store';
import { questionnaire } from '../store';
import { getQuestionsForSection, questionnaire } from '../store';
import { t } from '$lib/utils/functions/translations';
import type { ExerciseSubmissions } from './types';
import type { Question } from '$features/course/types';
import type { AnswerData } from '@cio/question-types';
import { toExerciseQuestionModel } from '../question-type-utils';
import { getExerciseQuestionLabels } from '../question-labels';
import { getExerciseSectionDisplayTitle } from '$features/course/utils/exercise-section-utils';
interface Props {
isLoading?: boolean;
@@ -20,6 +21,11 @@
let studentSelected = $state(0);
const questionLabels = $derived(getExerciseQuestionLabels());
const sectionFallbackTitle = $derived($t('course.navItem.lessons.exercises.all_exercises.section.fallback_title'));
const activeSections = $derived(
[...($questionnaire.sections ?? [])].filter((section) => !section.deletedAt).sort((a, b) => a.order - b.order)
);
const hasSectionGroups = $derived(activeSections.length > 0);
function toNumericQuestionId(question: Question): number | null {
const numericId = Number(question.id);
@@ -42,6 +48,20 @@
return submittedAnswer.answerData;
}
function getSectionScore(student: ExerciseSubmissions, questions: Question[]) {
return questions.reduce((total, question) => {
const questionId = toNumericQuestionId(question);
if (questionId === null) return total;
const answer = student.answers.find((item) => item.questionId === questionId);
return total + (answer?.point ?? 0);
}, 0);
}
function getSectionMaxPoints(questions: Question[]) {
return questions.reduce((total, question) => total + Number(question.points ?? 0), 0);
}
</script>
{#if isLoading}
@@ -74,17 +94,66 @@
)}
</p>
{#if $submissions[studentSelected]}
{#if $questionnaire.questions}
{#if hasSectionGroups}
<div class="space-y-8">
{#each activeSections as section, sectionIndex (section.id)}
{@const sectionQuestions = getQuestionsForSection($questionnaire.questions, section.id)}
<section class="space-y-4">
<ExerciseQuestion.SectionHeader
title={getExerciseSectionDisplayTitle({
title: section.title,
sectionNumber: sectionIndex + 1,
sectionLabel: sectionFallbackTitle
})}
description={section.description}
sectionNumber={sectionIndex + 1}
totalSections={activeSections.length}
colorTheme={section.colorTheme}
questionCount={sectionQuestions.length}
totalPoints={getSectionMaxPoints(sectionQuestions)}
labels={{
section: $t('course.navItem.lessons.exercises.all_exercises.section.fallback_title'),
questions: $t('course.navItem.lessons.exercises.all_exercises.view_mode.questions'),
points: $t('course.navItem.lessons.exercises.all_exercises.view_mode.points')
}}
/>
{#each sectionQuestions as q, i (`${q.id}-${i}`)}
<div class="pb-4">
<ExerciseQuestion.QuestionRenderer
contract={{
mode: 'review',
question: toExerciseQuestionModel(q),
answer: getStudentAnswerForQuestion($submissions[studentSelected], q),
labels: questionLabels,
disabled: true
}}
questionNumber={i + 1}
questionNumberActive={false}
/>
</div>
{/each}
<p class="border-t pt-2 text-sm font-medium">
{getSectionScore($submissions[studentSelected], sectionQuestions)}/{getSectionMaxPoints(sectionQuestions)}
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.points')}
</p>
</section>
{/each}
</div>
{:else if $questionnaire.questions}
{#each $questionnaire.questions as q, i (`${q.id}-${i}`)}
<div class="pb-4">
<ExerciseQuestion.QuestionRenderer
contract={{
mode: 'review',
question: { ...toExerciseQuestionModel(q), title: `${i + 1}. ${q.title}` },
question: toExerciseQuestionModel(q),
answer: getStudentAnswerForQuestion($submissions[studentSelected], q),
labels: questionLabels,
disabled: true
}}
questionNumber={i + 1}
questionNumberActive={false}
/>
</div>
{/each}
@@ -4,18 +4,20 @@
import { ExerciseQuestion } from '@cio/ui';
import { submissions } from './store';
import { questionnaire } from '../store';
import { getQuestionsForSection, questionnaire } from '../store';
import { t } from '$lib/utils/functions/translations';
import type { Question } from '$features/course/types';
import type { ExerciseSubmissions } from './types';
import type { AnswerData, ExerciseQuestionLabels, ExerciseSubmissionModel } from '@cio/question-types';
import { toExerciseQuestionModel } from '../question-type-utils';
import { getExerciseSectionDisplayTitle } from '$features/course/utils/exercise-section-utils';
interface Props {
isLoading?: boolean;
}
let { isLoading = $bindable(true) }: Props = $props();
const sectionFallbackTitle = $derived($t('course.navItem.lessons.exercises.all_exercises.section.fallback_title'));
function toAnswerData(answerData: unknown): AnswerData | null {
if (!answerData || typeof answerData !== 'object' || !('type' in answerData)) return null;
@@ -34,13 +36,6 @@
};
}
function toSubmissionQuestionModel(question: Question, index: number) {
return {
...toExerciseQuestionModel(question),
title: `${index + 1}. ${question.title || ''}`
};
}
const submissionLabels = $derived.by(
(): ExerciseQuestionLabels => ({
'submission.common.no_answer': $t('course.navItem.lessons.exercises.all_exercises.analytics.individual.no'),
@@ -59,8 +54,16 @@
const submissionQuestions = $derived(
$questionnaire.questions
.filter((question) => !question.deletedAt)
.map((question, index) => toSubmissionQuestionModel(question, index))
.map((question) => toExerciseQuestionModel(question))
);
const activeSections = $derived(
[...($questionnaire.sections ?? [])].filter((section) => !section.deletedAt).sort((a, b) => a.order - b.order)
);
const hasSectionGroups = $derived(activeSections.length > 0);
function getSectionPoints(questions: Question[]) {
return questions.reduce((total, question) => total + Number(question.points ?? 0), 0);
}
</script>
{#if isLoading}
@@ -71,14 +74,52 @@
{$t('course.navItem.lessons.exercises.all_exercises.analytics.summary.question_chart')}
</p>
<ExerciseQuestion.QuestionList
contract={{
mode: 'submission',
questions: submissionQuestions,
submissions: submissionModels,
labels: submissionLabels,
disabled: true
}}
/>
{#if hasSectionGroups}
<div class="space-y-8">
{#each activeSections as section, sectionIndex (section.id)}
{@const sectionQuestions = getQuestionsForSection($questionnaire.questions, section.id)}
<section class="space-y-4">
<ExerciseQuestion.SectionHeader
title={getExerciseSectionDisplayTitle({
title: section.title,
sectionNumber: sectionIndex + 1,
sectionLabel: sectionFallbackTitle
})}
description={section.description}
sectionNumber={sectionIndex + 1}
totalSections={activeSections.length}
colorTheme={section.colorTheme}
questionCount={sectionQuestions.length}
totalPoints={getSectionPoints(sectionQuestions)}
labels={{
section: $t('course.navItem.lessons.exercises.all_exercises.section.fallback_title'),
questions: $t('course.navItem.lessons.exercises.all_exercises.view_mode.questions'),
points: $t('course.navItem.lessons.exercises.all_exercises.view_mode.points')
}}
/>
<ExerciseQuestion.QuestionList
contract={{
mode: 'submission',
questions: sectionQuestions.map((question) => toExerciseQuestionModel(question)),
submissions: submissionModels,
labels: submissionLabels,
disabled: true
}}
/>
</section>
{/each}
</div>
{:else}
<ExerciseQuestion.QuestionList
contract={{
mode: 'submission',
questions: submissionQuestions,
submissions: submissionModels,
labels: submissionLabels,
disabled: true
}}
/>
{/if}
</div>
{/if}
@@ -4,7 +4,13 @@
import { fly } from 'svelte/transition';
import { cubicInOut } from 'svelte/easing';
import { courseApi } from '$features/course/api';
import { questionnaire, questionnaireMetaData, resetStudentExerciseTake } from './store';
import {
getQuestionsForSection,
hasSections,
questionnaire,
questionnaireMetaData,
resetStudentExerciseTake
} from './store';
import Preview from './preview.svelte';
import { ExerciseQuestion } from '@cio/ui';
import * as Alert from '@cio/ui/base/alert';
@@ -22,6 +28,7 @@
getTextareaAnswerCharacterCount,
toApiPayload,
type AnswerData,
type ExerciseQuestionVideoRecordingUploader,
validateTextareaAnswer
} from '@cio/question-types';
import { exerciseApi, presignApi } from '$features/course/api';
@@ -37,10 +44,12 @@
import { isSelfPacedLikeCourse } from '$features/course/utils/compliance-utils';
import { toExerciseQuestionModel } from './question-type-utils';
import { getExerciseQuestionLabels } from './question-labels';
import { getExerciseSectionDisplayTitle } from '$features/course/utils/exercise-section-utils';
import axios from 'axios';
import { IconButton } from '@cio/ui/custom/icon-button';
import ChevronLeftIcon from '@lucide/svelte/icons/chevron-left';
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
import type { Question } from '$features/course/types';
interface Props {
preview?: boolean;
@@ -61,11 +70,17 @@
let slideDirection = $state<'next' | 'prev'>('next');
let selectedTryIndex = $state(-1);
const questionLabels = $derived(getExerciseQuestionLabels());
const sectionFallbackTitle = $derived($t('course.navItem.lessons.exercises.all_exercises.section.fallback_title'));
const submissionList = $derived(Array.isArray(mySubmissions) ? mySubmissions : []);
function handleStart() {
questionnaireMetaData.update((m) => ({ ...m, currentQuestionIndex: m.currentQuestionIndex + 1 }));
questionnaireMetaData.update((m) => ({
...m,
currentQuestionIndex: 1,
currentSectionIndex: 0,
sectionPhase: hasSectionedExercise ? 'overview' : 'questions'
}));
}
function normalizeAnswerValue(
@@ -84,11 +99,107 @@
return toApiPayload(answerData, questionId);
}
async function submitExercise() {
if (!courseApi.course?.id || isSubmitting) return;
isSubmitting = true;
const updated = get(questionnaireMetaData);
localStorage.removeItem(`autosave-exercise-${exerciseId}`);
const totalPossibleGrade = getTotalPossibleGrade($questionnaire.questions);
const answersForApi = Object.entries(updated.answers)
.map(([questionKey, val]) => {
const question = $questionnaire.questions.find(
(item) => getExerciseQuestionContractKey(toExerciseQuestionModel(item)) === questionKey
);
if (!question) return null;
return mapAnswerToApiPayload(question, val);
})
.filter((answer) => answer !== null) as Array<{ questionId: number; optionId?: number; answer?: string }>;
if (answersForApi.length === 0) {
isSubmitting = false;
snackbar.error(t.get('course.navItem.lessons.exercises.all_exercises.view_mode.answers_required'));
return;
}
const submitResult = await exerciseApi.submit(courseApi.course.id, exerciseId, answersForApi);
if (exerciseApi.success && submitResult?.data) {
skipHydrateFromSubmissions = false;
const sub = submitResult.data as unknown as SubmissionListItem & { gradingState?: string };
if (sub.gradingState === 'completed' && sub.statusId === STATUS.GRADED && Array.isArray(sub.answers)) {
applyMySubmission(sub);
} else {
questionnaireMetaData.update((m) => ({
...m,
status: 1,
totalPossibleGrade,
grades: {},
comment: '',
isFinished: true,
exerciseId
}));
}
courseApi.updateContentItem(exerciseId, ContentType.Exercise, { isComplete: true });
toggleConfetti();
setTimeout(toggleConfetti, 3000);
const allContentItems = getOrderedNavigableContent(courseApi.course);
const allComplete =
$isOrgStudent && allContentItems.length > 0 && allContentItems.every((item) => item.isComplete);
if (allComplete && courseApi.course?.id) {
openCourseCompletionModal(courseApi.course.id);
}
}
isSubmitting = false;
}
function evaluateSectionAfterBehavior() {
if (!currentSection) return;
const behavior = currentSection.afterBehavior;
if (behavior.action === 'submit') {
submitExercise();
return;
}
if (behavior.action === 'go_to_section' && behavior.exerciseSectionId) {
const targetIndex = activeSections.findIndex((section) => section.id === behavior.exerciseSectionId);
if (targetIndex >= 0) {
questionnaireMetaData.update((m) => ({
...m,
currentSectionIndex: targetIndex,
currentQuestionIndex: 1,
sectionPhase: 'overview'
}));
return;
}
}
const nextSectionIndex = $questionnaireMetaData.currentSectionIndex + 1;
if (nextSectionIndex < activeSections.length) {
questionnaireMetaData.update((m) => ({
...m,
currentSectionIndex: nextSectionIndex,
currentQuestionIndex: 1,
sectionPhase: 'overview'
}));
return;
}
submitExercise();
}
async function onSubmit(id, value) {
if (!courseApi.course?.id) return;
const { answers } = $questionnaireMetaData;
const { questions } = $questionnaire;
const questions = hasSectionedExercise ? currentSectionQuestions : $questionnaire.questions;
const prevAnswer = answers[id];
const formattedAnswer = normalizeAnswerValue(prevAnswer, value);
@@ -110,63 +221,11 @@
const isLastQuestion = currentIndex === questions.length;
if (isLastQuestion) {
// Stay on last question, show spinner, submit to server
if (isSubmitting) return;
isSubmitting = true;
const updated = get(questionnaireMetaData);
localStorage.removeItem(`autosave-exercise-${exerciseId}`);
const totalPossibleGrade = getTotalPossibleGrade($questionnaire.questions);
const answersForApi = Object.entries(updated.answers)
.map(([questionKey, val]) => {
const question = questions.find(
(item) => getExerciseQuestionContractKey(toExerciseQuestionModel(item)) === questionKey
);
if (!question) return null;
return mapAnswerToApiPayload(question, val);
})
.filter((answer) => answer !== null) as Array<{ questionId: number; optionId?: number; answer?: string }>;
if (answersForApi.length === 0) {
isSubmitting = false;
snackbar.error(t.get('course.navItem.lessons.exercises.all_exercises.view_mode.answers_required'));
return;
if (hasSectionedExercise) {
evaluateSectionAfterBehavior();
} else {
await submitExercise();
}
const submitResult = await exerciseApi.submit(courseApi.course.id, exerciseId, answersForApi);
if (exerciseApi.success && submitResult?.data) {
skipHydrateFromSubmissions = false;
const sub = submitResult.data as unknown as SubmissionListItem & { gradingState?: string };
if (sub.gradingState === 'completed' && sub.statusId === STATUS.GRADED && Array.isArray(sub.answers)) {
applyMySubmission(sub);
} else {
questionnaireMetaData.update((m) => ({
...m,
status: 1,
totalPossibleGrade,
grades: {},
comment: '',
isFinished: true,
exerciseId
}));
}
courseApi.updateContentItem(exerciseId, ContentType.Exercise, { isComplete: true });
toggleConfetti();
setTimeout(toggleConfetti, 3000);
const allContentItems = getOrderedNavigableContent(courseApi.course);
const allComplete =
$isOrgStudent && allContentItems.length > 0 && allContentItems.every((item) => item.isComplete);
if (allComplete && courseApi.course?.id) {
openCourseCompletionModal(courseApi.course.id);
}
}
isSubmitting = false;
} else {
// Advance to next question
slideDirection = 'next';
@@ -177,6 +236,11 @@
}
function onPrevious() {
if (hasSectionedExercise && $questionnaireMetaData.currentQuestionIndex <= 1) {
questionnaireMetaData.update((m) => ({ ...m, sectionPhase: 'overview' }));
return;
}
slideDirection = 'prev';
questionnaireMetaData.update((m) => ({ ...m, currentQuestionIndex: m.currentQuestionIndex - 1 }));
}
@@ -186,6 +250,14 @@
return 100;
}
if (hasSectionedExercise) {
const activeQuestionCount = $questionnaire.questions.filter((question) => !question.deletedAt).length;
const answeredCount = Object.values($questionnaireMetaData.answers).filter((answer) =>
hasAnswerValue(answer)
).length;
return Math.round((answeredCount / activeQuestionCount) * 100) || 0;
}
return Math.round(((currentQuestionIndex - 1) / $questionnaire.questions.length) * 100) || 0;
}
@@ -233,6 +305,48 @@
return { fileKey: uploadResult.fileKey, fileName: file.name, fileUrl };
}
const handleVideoRecordingUpload: ExerciseQuestionVideoRecordingUploader = async (input) => {
if (!courseApi.course?.id || !exerciseId) {
throw new Error('Exercise is not ready for upload');
}
const initResult = await exerciseApi.initVideoRecordingUpload(courseApi.course.id, exerciseId, input.questionId, {
fileName: input.fileName,
mimeType: input.mimeType as 'video/webm' | 'video/mp4' | 'video/quicktime',
size: input.size
});
if (!initResult?.data) {
throw new Error('Failed to get upload URL');
}
await axios.put(initResult.data.uploadUrl, input.blob, {
headers: { 'Content-Type': input.mimeType },
maxContentLength: Infinity,
maxBodyLength: Infinity
});
const completeResult = await exerciseApi.completeVideoRecordingUpload(
courseApi.course.id,
exerciseId,
input.questionId,
{
assetId: initResult.data.assetId,
storageKey: initResult.data.storageKey,
fileName: input.fileName,
mimeType: input.mimeType as 'video/webm' | 'video/mp4' | 'video/quicktime',
size: input.size,
durationSeconds: input.durationSeconds,
recordedAt: input.recordedAt,
retakeCount: input.retakeCount
}
);
if (!completeResult?.data) {
throw new Error('Failed to complete upload');
}
return completeResult.data;
};
function hasAnswerValue(answerValue: AnswerData | null | undefined): boolean {
if (answerValue === null || answerValue === undefined) return false;
if (typeof answerValue === 'object' && 'type' in answerValue) {
@@ -256,6 +370,8 @@
}
case 'FILE_UPLOAD':
return !!answerValue.fileKey;
case 'VIDEO_RECORDING':
return !!answerValue.assetId && !!answerValue.storageKey;
case 'MATCHING':
return answerValue.pairs.length > 0;
case 'ORDERING':
@@ -271,16 +387,22 @@
return false;
}
function onSharedAnswerChange(answerValue) {
function onSharedAnswerChange(answerValue: AnswerData | null) {
if (!sharedCurrentQuestionKey) return;
questionnaireMetaData.update((metaData) => ({
...metaData,
answers: {
...metaData.answers,
[sharedCurrentQuestionKey]: answerValue
questionnaireMetaData.update((metaData) => {
const answers = { ...metaData.answers };
if (answerValue === null) {
delete answers[sharedCurrentQuestionKey];
} else {
answers[sharedCurrentQuestionKey] = answerValue;
}
}));
return {
...metaData,
answers
};
});
}
function onSharedNext(valueOverride?: AnswerData) {
@@ -300,7 +422,59 @@
onSubmit(sharedCurrentQuestionKey, valueToUse);
}
function beginCurrentSection() {
questionnaireMetaData.update((m) => ({
...m,
currentQuestionIndex: 1,
sectionPhase: 'questions'
}));
}
function goBackFromSectionOverview() {
if ($questionnaireMetaData.currentSectionIndex <= 0) {
questionnaireMetaData.update((m) => ({
...m,
currentQuestionIndex: 0,
sectionPhase: 'overview'
}));
return;
}
questionnaireMetaData.update((m) => ({
...m,
currentSectionIndex: m.currentSectionIndex - 1,
currentQuestionIndex: 1,
sectionPhase: 'overview'
}));
}
function onSectionQuestionAnswerChange(question: Question, answerValue: AnswerData) {
const questionKey = getExerciseQuestionContractKey(toExerciseQuestionModel(question));
questionnaireMetaData.update((metaData) => ({
...metaData,
answers: {
...metaData.answers,
[questionKey]: answerValue
}
}));
}
function completeAllQuestionsSection() {
const missingAnswer = currentSectionQuestions.find((question) => {
const questionKey = getExerciseQuestionContractKey(toExerciseQuestionModel(question));
return !hasAnswerValue($questionnaireMetaData.answers[questionKey]);
});
if (missingAnswer) {
snackbar.error(t.get('course.navItem.lessons.exercises.all_exercises.view_mode.answer_required'));
return;
}
evaluateSectionAfterBehavior();
}
function handleEnterKey(e: KeyboardEvent) {
if (hasSectionedExercise && $questionnaire.sectionDisplayMode === 'all_questions') return;
if (e.key !== 'Enter' || isSubmitting || !currentQuestion) return;
const target = e.target as HTMLElement;
@@ -374,9 +548,34 @@
});
const progressValue = $derived(getProgressValue($questionnaireMetaData.currentQuestionIndex));
const currentQuestion = $derived($questionnaire.questions[$questionnaireMetaData.currentQuestionIndex - 1]);
const activeSections = $derived(
[...($questionnaire.sections ?? [])].filter((section) => !section.deletedAt).sort((a, b) => a.order - b.order)
);
const hasSectionedExercise = $derived(hasSections($questionnaire.sections ?? []));
const currentSection = $derived(
hasSectionedExercise ? activeSections[$questionnaireMetaData.currentSectionIndex] : null
);
const currentSectionQuestions = $derived(
currentSection ? getQuestionsForSection($questionnaire.questions, currentSection.id) : []
);
const currentSectionDisplayTitle = $derived(
currentSection
? getExerciseSectionDisplayTitle({
title: currentSection.title,
sectionNumber: $questionnaireMetaData.currentSectionIndex + 1,
sectionLabel: sectionFallbackTitle
})
: ''
);
const currentQuestionList = $derived(hasSectionedExercise ? currentSectionQuestions : $questionnaire.questions);
const currentQuestion = $derived(currentQuestionList[$questionnaireMetaData.currentQuestionIndex - 1]);
const isExerciseLoaded = $derived(alreadyCheckedAutoSavedData && $questionnaire.questions.length > 0);
const isFinished = $derived(isExerciseLoaded && $questionnaireMetaData.currentQuestionIndex > 0 && !currentQuestion);
const isFinished = $derived(
isExerciseLoaded &&
$questionnaireMetaData.currentQuestionIndex > 0 &&
!currentQuestion &&
(!hasSectionedExercise || $questionnaireMetaData.isFinished)
);
const sharedQuestionModel = $derived(currentQuestion ? toExerciseQuestionModel(currentQuestion) : null);
const sharedCurrentQuestionKey = $derived(
sharedQuestionModel ? getExerciseQuestionContractKey(sharedQuestionModel) : null
@@ -385,6 +584,33 @@
sharedCurrentQuestionKey ? $questionnaireMetaData.answers[sharedCurrentQuestionKey] : undefined
);
const canGoNextForSharedQuestion = $derived(hasAnswerValue(sharedCurrentAnswer));
const sectionQuestionTotalPoints = $derived(getTotalPossibleGrade(currentSectionQuestions));
const sectionNavigationGroups = $derived(
activeSections.map((section, sectionIndex) => {
const sectionQuestions = getQuestionsForSection($questionnaire.questions, section.id);
return {
id: section.id,
title: getExerciseSectionDisplayTitle({
title: section.title,
sectionNumber: sectionIndex + 1,
sectionLabel: sectionFallbackTitle
}),
isCurrent: currentSection?.id === section.id,
questions: sectionQuestions.map((question, questionIndex) => {
const questionKey = getExerciseQuestionContractKey(toExerciseQuestionModel(question));
return {
key: questionKey,
label: `Q${questionIndex + 1}`,
isAnswered: hasAnswerValue($questionnaireMetaData.answers[questionKey]),
isCurrent:
sectionIndex === $questionnaireMetaData.currentSectionIndex &&
questionIndex === $questionnaireMetaData.currentQuestionIndex - 1 &&
$questionnaireMetaData.sectionPhase === 'questions'
};
})
};
})
);
const flyX = $derived(slideDirection === 'next' ? 300 : -300);
const flyOutX = $derived(-flyX);
@@ -459,6 +685,8 @@
answers,
totalPossibleGrade,
currentQuestionIndex: $questionnaire.questions.length,
currentSectionIndex: activeSections.length > 0 ? activeSections.length - 1 : 0,
sectionPhase: 'questions',
isFinished: true,
status: submission.statusId ?? STATUS.PENDING,
finalTotalGrade,
@@ -470,7 +698,7 @@
$effect(() => {
if (prevExerciseId === exerciseId) return;
if (!!prevExerciseId) {
if (prevExerciseId) {
isSubmitting = false;
alreadyCheckedAutoSavedData = false;
skipHydrateFromSubmissions = false;
@@ -483,7 +711,12 @@
{#if !preview && $questionnaire.questions.length && !$questionnaireMetaData.isFinished && $questionnaireMetaData.currentQuestionIndex > 0}
<div class="mb-6 flex min-w-0 items-center gap-3">
<span class="ui:text-muted-foreground shrink-0 text-sm tabular-nums">
{$questionnaireMetaData.currentQuestionIndex}/{$questionnaire.questions.length}
{#if hasSectionedExercise}
{Object.values($questionnaireMetaData.answers).filter((answer) => hasAnswerValue(answer))
.length}/{$questionnaire.questions.length}
{:else}
{$questionnaireMetaData.currentQuestionIndex}/{$questionnaire.questions.length}
{/if}
</span>
<Progress class="min-w-0 flex-1" value={$questionnaireMetaData.progressValue} />
</div>
@@ -493,7 +726,11 @@
{#if preview}
<RoleBasedSecurity allowedRoles={[1, 2]}>
<Preview questions={filterOutDeleted($questionnaire.questions)} questionnaireMetaData={$questionnaireMetaData} />
<Preview
questions={filterOutDeleted($questionnaire.questions)}
sections={$questionnaire.sections}
questionnaireMetaData={$questionnaireMetaData}
/>
</RoleBasedSecurity>
{:else if !$questionnaire.questions.length}
<Empty
@@ -621,6 +858,7 @@
questions={[...$questionnaire.questions].sort(
(leftQuestion, rightQuestion) => leftQuestion.order - rightQuestion.order
)}
sections={$questionnaire.sections}
questionnaireMetaData={$questionnaireMetaData}
grades={$questionnaireMetaData.grades}
disableGrading={true}
@@ -636,44 +874,133 @@
{/if}
</RoleBasedSecurity>
{/if}
{:else if currentQuestion && currentQuestion?.id}
<div class="flex flex-col gap-4">
<div class="grid overflow-hidden" style="grid-template-areas: 'slot';">
{#key currentQuestion.id}
<div
id="question"
class="min-w-0 [grid-area:slot]"
in:fly={{ x: flyX, duration: 350, easing: cubicInOut }}
out:fly={{ x: flyOutX, duration: 350, easing: cubicInOut }}
>
{#if sharedQuestionModel}
<ExerciseQuestion.QuestionRenderer
contract={{
mode: 'take',
question: sharedQuestionModel,
answer: sharedCurrentAnswer,
labels: questionLabels,
disabled: isSubmitting,
onFileUpload: handleFileUpload
}}
onAnswerChange={onSharedAnswerChange}
/>
{/if}
</div>
{/key}
</div>
<div>
<ExerciseQuestion.QuestionNavigation
canGoBack={$questionnaireMetaData.currentQuestionIndex > 1}
canGoNext={canGoNextForSharedQuestion && !isSubmitting}
isLast={$questionnaireMetaData.currentQuestionIndex === $questionnaire.questions.length}
{isSubmitting}
previousLabel={t.get('course.navItem.lessons.exercises.all_exercises.previous')}
nextLabel={t.get('course.navItem.lessons.exercises.all_exercises.next')}
finishLabel={t.get('course.navItem.lessons.exercises.all_exercises.finish')}
{onPrevious}
onNext={onSharedNext}
{:else if hasSectionedExercise && currentSection && $questionnaireMetaData.sectionPhase === 'overview'}
<ExerciseQuestion.SectionOverview
sectionTitle={currentSectionDisplayTitle}
sectionDescription={currentSection.description}
sectionNumber={$questionnaireMetaData.currentSectionIndex + 1}
totalSections={activeSections.length}
questionCount={currentSectionQuestions.length}
totalPoints={sectionQuestionTotalPoints}
colorTheme={currentSection.colorTheme}
onBegin={beginCurrentSection}
onBack={goBackFromSectionOverview}
labels={{
beginSection: $t('course.navItem.lessons.exercises.all_exercises.view_mode.begin_section'),
back: $t('course.navItem.lessons.exercises.all_exercises.view_mode.back'),
questions: $t('course.navItem.lessons.exercises.all_exercises.view_mode.questions'),
points: $t('course.navItem.lessons.exercises.all_exercises.view_mode.points'),
section: $t('course.navItem.lessons.exercises.all_exercises.section.fallback_title')
}}
/>
{:else if hasSectionedExercise && currentSection && $questionnaire.sectionDisplayMode === 'all_questions' && $questionnaireMetaData.sectionPhase === 'questions'}
<div class="grid gap-4 lg:grid-cols-[180px_minmax(0,1fr)]">
<ExerciseQuestion.SectionNavigationSidebar sections={sectionNavigationGroups} />
<section class="space-y-4">
<ExerciseQuestion.SectionHeader
title={currentSectionDisplayTitle}
description={currentSection.description}
sectionNumber={$questionnaireMetaData.currentSectionIndex + 1}
totalSections={activeSections.length}
colorTheme={currentSection.colorTheme}
questionCount={currentSectionQuestions.length}
totalPoints={sectionQuestionTotalPoints}
labels={{
section: $t('course.navItem.lessons.exercises.all_exercises.section.fallback_title'),
questions: $t('course.navItem.lessons.exercises.all_exercises.view_mode.questions'),
points: $t('course.navItem.lessons.exercises.all_exercises.view_mode.points')
}}
/>
{#each currentSectionQuestions as sectionQuestion, sectionQuestionIndex (sectionQuestion.id)}
{@const sectionQuestionModel = toExerciseQuestionModel(sectionQuestion)}
{@const sectionQuestionKey = getExerciseQuestionContractKey(toExerciseQuestionModel(sectionQuestion))}
<ExerciseQuestion.QuestionRenderer
contract={{
mode: 'take',
question: sectionQuestionModel,
answer: $questionnaireMetaData.answers[sectionQuestionKey],
labels: questionLabels,
disabled: isSubmitting,
onFileUpload: handleFileUpload,
onVideoRecordingUpload: handleVideoRecordingUpload
}}
questionNumber={sectionQuestionIndex + 1}
questionNumberActive={false}
onAnswerChange={(answerValue) => onSectionQuestionAnswerChange(sectionQuestion, answerValue)}
/>
{/each}
<div class="flex justify-end">
<Button type="button" onclick={completeAllQuestionsSection} disabled={isSubmitting} loading={isSubmitting}>
{$questionnaireMetaData.currentSectionIndex === activeSections.length - 1
? $t('course.navItem.lessons.exercises.all_exercises.finish')
: $t('course.navItem.lessons.exercises.all_exercises.view_mode.complete_section')}
</Button>
</div>
</section>
</div>
{:else if currentQuestion && currentQuestion?.id}
<div class={hasSectionedExercise ? 'grid gap-4 lg:grid-cols-[180px_minmax(0,1fr)]' : ''}>
{#if hasSectionedExercise}
<ExerciseQuestion.SectionNavigationSidebar sections={sectionNavigationGroups} />
{/if}
<div class="flex min-w-0 flex-col gap-4">
{#if hasSectionedExercise && currentSection}
<ExerciseQuestion.SectionHeader
title={currentSectionDisplayTitle}
description={currentSection.description}
sectionNumber={$questionnaireMetaData.currentSectionIndex + 1}
totalSections={activeSections.length}
colorTheme={currentSection.colorTheme}
questionCount={currentSectionQuestions.length}
totalPoints={sectionQuestionTotalPoints}
labels={{
section: $t('course.navItem.lessons.exercises.all_exercises.section.fallback_title'),
questions: $t('course.navItem.lessons.exercises.all_exercises.view_mode.questions'),
points: $t('course.navItem.lessons.exercises.all_exercises.view_mode.points')
}}
/>
{/if}
<div class="grid overflow-hidden" style="grid-template-areas: 'slot';">
{#key currentQuestion.id}
<div
id="question"
class="min-w-0 [grid-area:slot]"
in:fly={{ x: flyX, duration: 350, easing: cubicInOut }}
out:fly={{ x: flyOutX, duration: 350, easing: cubicInOut }}
>
{#if sharedQuestionModel}
<ExerciseQuestion.QuestionRenderer
contract={{
mode: 'take',
question: sharedQuestionModel,
answer: sharedCurrentAnswer,
labels: questionLabels,
disabled: isSubmitting,
onFileUpload: handleFileUpload,
onVideoRecordingUpload: handleVideoRecordingUpload
}}
questionNumber={$questionnaireMetaData.currentQuestionIndex}
onAnswerChange={onSharedAnswerChange}
/>
{/if}
</div>
{/key}
</div>
<div>
<ExerciseQuestion.QuestionNavigation
canGoBack={hasSectionedExercise || $questionnaireMetaData.currentQuestionIndex > 1}
canGoNext={canGoNextForSharedQuestion && !isSubmitting}
isLast={$questionnaireMetaData.currentQuestionIndex === currentQuestionList.length}
{isSubmitting}
previousLabel={t.get('course.navItem.lessons.exercises.all_exercises.previous')}
nextLabel={t.get('course.navItem.lessons.exercises.all_exercises.next')}
finishLabel={t.get('course.navItem.lessons.exercises.all_exercises.finish')}
{onPrevious}
onNext={onSharedNext}
/>
</div>
</div>
</div>
{/if}
@@ -11,6 +11,7 @@
import { t } from '$lib/utils/functions/translations';
import { RoleBasedSecurity } from '$features/ui';
import DeleteLessonConfirmation from '$features/course/components/lesson/delete-lesson-confirmation.svelte';
import { slugifyTitle } from '@cio/utils/validation';
interface Props {
mode: (typeof MODES)[keyof typeof MODES];
@@ -20,6 +21,10 @@
onTitleChange: (value: string) => void;
onToggleLock: () => void;
onDeleteLesson: () => Promise<void> | void;
slug?: string;
showSlugEditor?: boolean;
slugError?: string;
onSlugChange?: (value: string) => void;
}
let {
@@ -29,9 +34,15 @@
isDeletingLesson = false,
onTitleChange,
onToggleLock,
onDeleteLesson
onDeleteLesson,
slug = '',
showSlugEditor = false,
slugError,
onSlugChange = () => {}
}: Props = $props();
// Slug field: commented out via `{#if false && ...}` below — drop `false &&` to show again.
let openDeleteModal = $state(false);
const lockLabel = $derived(
@@ -40,7 +51,7 @@
</script>
<div class="flex w-full items-center justify-between gap-2">
<div class="min-w-0 flex-1">
<div class="min-w-0 flex-1 space-y-2">
{#if mode === MODES.edit && !$isOrgStudent}
<RoleBasedSecurity allowedRoles={[1, 2]}>
<InputField
@@ -49,6 +60,17 @@
placeholder={$t('course.navItem.lessons.add_lesson.lesson_title')}
onInputChange={(e) => onTitleChange(e.currentTarget.value)}
/>
{#if false && showSlugEditor}
<InputField
className="max-w-xl"
label={$t('course.navItem.settings.slug.label')}
helperMessage={$t('course.navItem.settings.slug.description')}
value={slug}
placeholder={slugifyTitle(title || '')}
onInputChange={(e) => onSlugChange(e.currentTarget.value)}
errorMessage={slugError}
/>
{/if}
</RoleBasedSecurity>
{:else}
<Page.Title>{title}</Page.Title>
@@ -69,8 +91,9 @@
<IconButton
onclick={() => (openDeleteModal = true)}
disabled={isDeletingLesson}
aria-label={$t('course.navItem.lessons.add_lesson.delete')}
title={$t('course.navItem.lessons.add_lesson.delete')}
aria-label={$t('course.navItem.lessons.add_lesson.delete_tooltip')}
tooltip={$t('course.navItem.lessons.add_lesson.delete_tooltip')}
tooltipSide="bottom"
>
{#if isDeletingLesson}
<Spinner class="h-4 w-4" />
@@ -17,13 +17,6 @@
let step = $state(0);
const options = [
{
id: 'live-class',
title: $t('new_course_modal.live_class_label'),
subtitle: $t('new_course_modal.live_class_subtitle'),
type: 'LIVE_CLASS' as TCourseType,
isDisabled: false
},
{
id: 'self-paced',
title: $t('new_course_modal.self_paced_label'),
@@ -31,12 +24,26 @@
type: 'SELF_PACED' as TCourseType,
isDisabled: false
},
{
id: 'live-class',
title: $t('new_course_modal.live_class_label'),
subtitle: $t('new_course_modal.live_class_subtitle'),
type: 'LIVE_CLASS' as TCourseType,
isDisabled: false
},
{
id: 'compliance',
title: $t('new_course_modal.compliance_label'),
subtitle: $t('new_course_modal.compliance_subtitle'),
type: 'COMPLIANCE' as TCourseType,
isDisabled: false
},
{
id: 'public',
title: $t('new_course_modal.public_label'),
subtitle: $t('new_course_modal.public_subtitle'),
type: 'PUBLIC' as TCourseType,
isDisabled: false
}
];
const courseTypeOptionsForGroup = options.map((o) => ({
@@ -75,7 +82,7 @@
{#snippet course_type_selector()}
<Field.Description>{$t('courses.new_course_modal.type_selector_title')}</Field.Description>
<RadioOptionCardGroup options={courseTypeOptionsForGroup} bind:value={type} class="grid-cols-1 md:grid-cols-3" />
<RadioOptionCardGroup options={courseTypeOptionsForGroup} bind:value={type} class="grid-cols-1 md:grid-cols-2" />
{/snippet}
<Dialog.Root
@@ -14,6 +14,7 @@
/** Extra hint under points row (e.g. auto-grade requires non-zero points) */
pointsHint?: string | null;
onPointsChange?: any;
elementId?: string;
key?: string;
children?: import('svelte').Snippet;
}
@@ -27,6 +28,7 @@
errorMsg = null,
pointsHint = null,
onPointsChange = () => {},
elementId,
children
}: Props = $props();
@@ -44,6 +46,7 @@
</script>
<div
id={elementId}
bind:this={ref}
class="border-border border bg-white dark:bg-black {hasError ? 'border-red-700' : ''} root relative mb-6 rounded-md"
>
@@ -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 />
@@ -73,6 +73,8 @@
const lessonTitle = $derived(lessonApi.lesson?.title || 'Lesson');
const isLessonUnlocked = $derived(lessonApi.lesson?.isUnlocked ?? false);
const lessonSlug = $derived(lessonApi.lesson?.slug ?? '');
const isPublicCourse = $derived(courseApi.course?.type === 'PUBLIC');
function setModeQueryParam(value: (typeof MODES)[keyof typeof MODES]) {
const params = new SvelteURLSearchParams($page.url.searchParams);
@@ -202,7 +204,8 @@
isUnlocked: lessonApi.lesson.isUnlocked ?? undefined,
slideUrl: lessonApi.lesson.slideUrl || undefined,
videos: lessonApi.lesson.videos || [],
documents: lessonApi.lesson.documents || []
documents: lessonApi.lesson.documents || [],
slug: isPublicCourse && lessonApi.lesson.slug ? lessonApi.lesson.slug : undefined
}),
saveOrUpdateTranslation(lessonApi.currentLocale, lessonId)
]);
@@ -218,6 +221,10 @@
lessonApi.updateLessonState('title', value);
}
function handleLessonSlugChange(value: string) {
lessonApi.updateLessonState('slug', value);
}
function handleToggleLessonLock() {
const currentValue = lessonApi.lesson?.isUnlocked ?? false;
lessonApi.updateLessonState('isUnlocked', !currentValue);
@@ -361,6 +368,10 @@
onTitleChange={handleLessonTitleChange}
onToggleLock={handleToggleLessonLock}
onDeleteLesson={handleDeleteLesson}
slug={lessonSlug}
showSlugEditor={isPublicCourse}
slugError={lessonApi.errors.slug}
onSlugChange={handleLessonSlugChange}
/>
</Page.HeaderContent>
<Page.Action>
@@ -5,6 +5,7 @@
import BookOpenIcon from '@lucide/svelte/icons/book-open';
import ContentList from '$features/course/components/lesson/content-list.svelte';
import ContentSectionList from '$features/course/components/lesson/content-section-list.svelte';
import CourseContentIcon from '$features/course/components/course-content-icon.svelte';
import { courseApi } from '$features/course/api';
import { t } from '$lib/utils/functions/translations';
import { getCourseContent } from '$features/course/utils/content';
@@ -28,6 +29,12 @@
contentItems.filter((item) => item.type === ContentType.Lesson || item.type === ContentType.Exercise)
);
const sectionsTotal = $derived(
contentData.grouped ? contentData.sections.filter((section) => section.id !== 'ungrouped').length : 0
);
const lessonsTotal = $derived(contentItems.filter((item) => item.type === ContentType.Lesson).length);
const exercisesTotal = $derived(contentItems.filter((item) => item.type === ContentType.Exercise).length);
let isFetching: boolean = $state(false);
let hasHandledNext = $state(false);
@@ -65,6 +72,34 @@
variant="page"
/>
{:else if contentLength > 0}
<div
class="mb-5 grid grid-cols-1 gap-3 sm:grid-cols-3"
role="region"
aria-label={t.get('course.navItem.lessons.heading_v2')}
>
<div class="ui:border-border flex flex-col gap-1 rounded-lg border px-4 py-3">
<div class="ui:text-muted-foreground flex items-center gap-2 text-xs font-medium">
<CourseContentIcon type={ContentType.Section} size={14} />
<span>{$t('course.navItem.lessons.stats.sections')}</span>
</div>
<p class="text-2xl font-semibold tabular-nums">{sectionsTotal}</p>
</div>
<div class="ui:border-border flex flex-col gap-1 rounded-lg border px-4 py-3">
<div class="ui:text-muted-foreground flex items-center gap-2 text-xs font-medium">
<CourseContentIcon type={ContentType.Lesson} size={14} />
<span>{$t('course.navItem.lessons.stats.lessons')}</span>
</div>
<p class="text-2xl font-semibold tabular-nums">{lessonsTotal}</p>
</div>
<div class="ui:border-border flex flex-col gap-1 rounded-lg border px-4 py-3">
<div class="ui:text-muted-foreground flex items-center gap-2 text-xs font-medium">
<CourseContentIcon type={ContentType.Exercise} size={14} />
<span>{$t('course.navItem.lessons.stats.exercises')}</span>
</div>
<p class="text-2xl font-semibold tabular-nums">{exercisesTotal}</p>
</div>
</div>
{#if reorder}
<p class="text-center text-xs text-gray-400 italic dark:text-white">
{$t('course.navItem.lessons.drag')}
@@ -16,7 +16,7 @@
import Empty from '@cio/ui/custom/empty/empty.svelte';
import { BookIcon } from '@lucide/svelte';
import { ROLE } from '@cio/utils/constants';
import { buildNextNewsfeedReaction } from '@cio/ui/custom/newsfeed-reactions';
import { buildNextNewsfeedReaction, type NewsfeedReactionType } from '@cio/ui/custom/newsfeed-reactions';
interface Props {
courseId: string;
@@ -48,13 +48,13 @@
await newsfeedApi.deleteComment(courseId, feedId, commentId);
};
const addNewReaction = async (reactionType: string, feedId: string, authorId: string) => {
const addNewReaction = async (reactionType: NewsfeedReactionType, feedId: string, authorId: string) => {
if (!authorId || isReactingByFeedId[feedId]) return;
const reactedFeed = newsfeedApi.feeds.find((feed) => feed.id === feedId);
if (!reactedFeed) return;
const updatedReaction = buildNextNewsfeedReaction(reactedFeed.reaction, reactionType, authorId);
const updatedReaction = buildNextNewsfeedReaction(reactedFeed.reaction ?? undefined, reactionType, authorId);
isReactingByFeedId = { ...isReactingByFeedId, [feedId]: true };
@@ -170,7 +170,8 @@
metadata: metadataPayload,
slug: courseApi.course.slug ?? undefined,
compliance:
$settings.type === 'COMPLIANCE' ? (courseApi.course.compliance ?? DEFAULT_COMPLIANCE_SETTINGS) : undefined
$settings.type === 'COMPLIANCE' ? (courseApi.course.compliance ?? DEFAULT_COMPLIANCE_SETTINGS) : undefined,
callout: $settings.type === 'PUBLIC' ? sanitizeCalloutForSave($settings.callout) : null
};
const normalizedSelectedTagIds = normalizeTagIds(selectedTagIds);
@@ -220,11 +221,46 @@
lessonDownload: !!course.metadata?.lessonDownload,
isPublished: !!course.isPublished,
allowNewStudents: !!course.metadata?.allowNewStudent,
isContentGroupingEnabled: course.metadata?.isContentGroupingEnabled ?? true
isContentGroupingEnabled: course.metadata?.isContentGroupingEnabled ?? true,
callout: normalizeCallout(course.callout)
});
});
}
function sanitizeCalloutForSave(value: typeof $settings.callout) {
if (!value) return null;
const title = value.title.trim();
const description = value.description.trim();
const buttonLabel = value.buttonLabel.trim();
const buttonUrl = value.buttonUrl.trim();
if (!title && !description && !buttonLabel && !buttonUrl) return null;
return { title, description, buttonLabel, buttonUrl };
}
function normalizeCallout(value: unknown): typeof $settings.callout {
if (!value || typeof value !== 'object') return null;
const candidate = value as Record<string, unknown>;
if (
typeof candidate.title !== 'string' ||
typeof candidate.description !== 'string' ||
typeof candidate.buttonLabel !== 'string' ||
typeof candidate.buttonUrl !== 'string'
) {
return null;
}
return {
title: candidate.title,
description: candidate.description,
buttonLabel: candidate.buttonLabel,
buttonUrl: candidate.buttonUrl
};
}
// Initialize course from page data
$effect(() => {
const courseData = $page.data?.course;
@@ -240,6 +276,15 @@
}
});
$effect(() => {
if ($settings.type === 'PUBLIC' && $settings.callout === null) {
settings.update((prev) => ({
...prev,
callout: { title: '', description: '', buttonLabel: '', buttonUrl: '' }
}));
}
});
$effect(() => {
const courseId = courseApi.course?.id;
@@ -525,21 +570,97 @@
}}
>
<div class="mb-3 flex items-center space-x-2">
<RadioGroup.Item value={'LIVE_CLASS' as TCourseType} id="live-class" />
<Label for="live-class">{$t('course.navItem.settings.live_class')}</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroup.Item value={'SELF_PACED' as TCourseType} id="self-paced" />
<Label for="self-paced">{$t('course.navItem.settings.self_paced')}</Label>
</div>
<div class="mt-3 flex items-center space-x-2">
<div class="mb-3 flex items-center space-x-2">
<RadioGroup.Item value={'LIVE_CLASS' as TCourseType} id="live-class" />
<Label for="live-class">{$t('course.navItem.settings.live_class')}</Label>
</div>
<div class="mb-3 flex items-center space-x-2">
<RadioGroup.Item value={'COMPLIANCE' as TCourseType} id="compliance-course" />
<Label for="compliance-course">{$t('course.navItem.settings.compliance')}</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroup.Item value={'PUBLIC' as TCourseType} id="public-course" />
<Label for="public-course">{$t('course.navItem.settings.public')}</Label>
</div>
</RadioGroup.Root>
</Field.Field>
{#if courseApi.errors.type}
<div
class="ui:mt-2 ui:rounded-md ui:border ui:border-destructive/30 ui:bg-destructive/5 ui:p-3 ui:text-sm ui:text-destructive"
role="alert"
>
<div class="ui:font-medium">{$t('course.navItem.settings.convert_to_public_blocked')}</div>
<p class="ui:mt-1 ui:text-destructive/90">{courseApi.errors.type}</p>
</div>
{/if}
</Field.Set>
{#if $settings.type === 'PUBLIC' && $settings.callout}
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('course.navItem.settings.callout.legend')}</Field.Legend>
<Field.Description>{$t('course.navItem.settings.callout.description')}</Field.Description>
<Field.Group>
<Field.Field>
<Field.Label>{$t('course.navItem.settings.callout.title_label')}</Field.Label>
<InputField
bind:value={$settings.callout.title}
onInputChange={() => (hasUnsavedChanges = true)}
placeholder={$t('course.navItem.settings.callout.title_placeholder')}
/>
</Field.Field>
<Field.Field>
<Field.Label>{$t('course.navItem.settings.callout.description_label')}</Field.Label>
<TextareaField
bind:value={$settings.callout.description}
oninput={() => (hasUnsavedChanges = true)}
rows={3}
placeholder={$t('course.navItem.settings.callout.description_placeholder')}
/>
</Field.Field>
<Field.Field>
<Field.Label>{$t('course.navItem.settings.callout.button_label')}</Field.Label>
<InputField
bind:value={$settings.callout.buttonLabel}
onInputChange={() => (hasUnsavedChanges = true)}
placeholder={$t('course.navItem.settings.callout.button_label_placeholder')}
/>
</Field.Field>
<Field.Field>
<Field.Label>{$t('course.navItem.settings.callout.button_url_label')}</Field.Label>
<InputField
bind:value={$settings.callout.buttonUrl}
onInputChange={() => (hasUnsavedChanges = true)}
placeholder={$t('course.navItem.settings.callout.button_url_placeholder')}
type="url"
/>
</Field.Field>
</Field.Group>
<Field.Field>
<Button
variant="outline"
size="sm"
onclick={() => {
settings.update((prev) => ({ ...prev, callout: null }));
hasUnsavedChanges = true;
}}
>
{$t('course.navItem.settings.callout.clear')}
</Button>
</Field.Field>
</Field.Set>
{/if}
<Field.Separator />
<Field.Set>
@@ -33,6 +33,8 @@ export const defaultCourse: Course = {
bannerImage: null,
attendance: [],
group: null,
compliance: null,
callout: null,
content: {
grouped: false,
sections: [],
@@ -46,6 +46,7 @@ type QuestionOption = ApiOption & {
// Extended question type with client-side fields
export type Question = Omit<ApiQuestion, 'options'> & {
exerciseSectionId?: string | null;
isDirty?: boolean;
deletedAt?: string;
options: QuestionOption[];
@@ -8,6 +8,18 @@ interface OpenCoursePreviewOptions {
currentOrgDomain?: string;
}
/** Absolute URL for the org-site public course page (`/course/{slug}`). Client-only. */
export function getPublicCoursePageUrl(courseSlug: string, currentOrgDomain = ''): string {
if (typeof window === 'undefined') {
return '';
}
const trimmedDomain = currentOrgDomain?.trim();
const origin = trimmedDomain || window.location.origin;
return new URL(resolve(`/course/${courseSlug}`, {}), origin).toString();
}
export function openCoursePreview({ courseId, courseSlug, currentOrgDomain = '' }: OpenCoursePreviewOptions) {
if (!courseSlug) {
snackbar.info('course.header.preview_missing_slug');
@@ -19,9 +31,32 @@ export function openCoursePreview({ courseId, courseSlug, currentOrgDomain = ''
return false;
}
const origin = currentOrgDomain || window.location.origin;
const link = new URL(resolve(`/course/${courseSlug}`, {}), origin).toString();
const link = getPublicCoursePageUrl(courseSlug, currentOrgDomain);
if (!link) {
return false;
}
window.open(link, '_blank', 'noopener,noreferrer');
return true;
}
export async function copyPublicCoursePageUrl(courseSlug: string, currentOrgDomain = '') {
const url = getPublicCoursePageUrl(courseSlug, currentOrgDomain);
if (!url) {
return false;
}
try {
await navigator.clipboard.writeText(url);
snackbar.success('snackbar.public_course.url_copied');
return true;
} catch (error) {
console.error('copyPublicCoursePageUrl error:', error);
snackbar.error('snackbar.public_course.url_copy_failed');
return false;
}
}
@@ -1,28 +1,74 @@
import type { Exercise } from '$features/course/utils/types';
import type { Question } from '$features/course/types';
import { exerciseApi } from '$features/course/api';
import type { ExerciseSectionAfterBehavior, ExerciseSectionColorTheme } from '@cio/question-types';
import {
clearQuestionnaireValidation,
questionnaire,
questionnaireMetaData
} from '$features/course/components/exercise/store';
import { getQuestionTypeId, getQuestionTypeOptionById } from '$features/course/components/exercise/question-type-utils';
import type { Exercise } from '$features/course/utils/types';
import type { ExerciseSectionState } from '$features/course/components/exercise/store';
import type { Question } from '$features/course/types';
import { UNTITLED_EXERCISE_SECTION_TITLE } from './exercise-section-utils';
import { exerciseApi } from '$features/course/api';
import { normalizeQuestionOrder } from '$features/course/components/exercise/order-utils';
export function hydrateExercisePageData(exercise: Exercise, exerciseId: string) {
let questions: Question[] = [];
if (exercise.questions && Array.isArray(exercise.questions)) {
const mappedQuestions = exercise.questions.map((question) => {
const sections: ExerciseSectionState[] = Array.isArray(exercise.sections)
? exercise.sections
.map((section) => ({
id: section.id,
title: section.title,
description: section.description ?? null,
order: section.order,
colorTheme: section.colorTheme as ExerciseSectionColorTheme,
afterBehavior: section.afterBehavior as unknown as ExerciseSectionAfterBehavior
}))
.sort((left, right) => left.order - right.order)
: [];
const exerciseQuestions = [...(Array.isArray(exercise.questions) ? exercise.questions : [])];
if (exerciseQuestions.length > 0) {
const mappedQuestions: Question[] = exerciseQuestions.map((question) => {
const questionType = getQuestionTypeOptionById(getQuestionTypeId(question));
return {
...question,
exerciseSectionId: question.exerciseSectionId ?? null,
questionTypeId: questionType.id,
questionType
};
});
if (sections.length > 0) {
const unsectionedQuestions = mappedQuestions.filter(
(question) => !question.deletedAt && !question.exerciseSectionId
);
if (unsectionedQuestions.length > 0) {
const untitledSectionId = crypto.randomUUID();
const nextSectionOrder =
sections.reduce((highestOrder, section) => Math.max(highestOrder, section.order), -1) + 1;
sections.push({
id: untitledSectionId,
title: UNTITLED_EXERCISE_SECTION_TITLE,
description: null,
order: nextSectionOrder,
colorTheme: 'blue',
afterBehavior: { action: 'continue' },
isDirty: true
});
for (const question of unsectionedQuestions) {
question.exerciseSectionId = untitledSectionId;
question.isDirty = true;
}
}
}
questions = normalizeQuestionOrder(mappedQuestions);
}
@@ -36,8 +82,11 @@ export function hydrateExercisePageData(exercise: Exercise, exerciseId: string)
isDescriptionDirty: false,
isDueByDirty: false,
questions,
sections,
sectionDisplayMode: exercise.sectionDisplayMode ?? 'one_question',
totalSubmissions: 0,
allowMultipleAttempts: !!exercise.allowMultipleAttempts
allowMultipleAttempts: !!exercise.allowMultipleAttempts,
slug: exercise.slug ?? ''
});
questionnaireMetaData.update((metadata) => ({ ...metadata, exerciseId }));
@@ -0,0 +1,15 @@
export const UNTITLED_EXERCISE_SECTION_TITLE = 'Untitled section';
export function getExerciseSectionDisplayTitle({
title,
sectionNumber,
sectionLabel
}: {
title: string;
sectionNumber: number;
sectionLabel: string;
}) {
if (title.trim() === UNTITLED_EXERCISE_SECTION_TITLE) return `${sectionLabel} ${sectionNumber}`;
return title;
}
@@ -0,0 +1,121 @@
import type {
PublicCourseSidebarSection,
PublicExerciseViewData,
PublicLessonViewData
} from '@cio/ui/custom/public-course';
import type { ExerciseQuestionModel, ExerciseQuestionTypeKey } from '@cio/question-types';
import { classroomio, type InferResponseType } from '$lib/utils/services/api';
type GetPublicCourseSuccess = Extract<
InferResponseType<(typeof classroomio)['org-site']['course'][':courseSlug']['$get']>,
{ success: true }
>;
type PublicCourseTree = GetPublicCourseSuccess['data'];
type GetPublicCourseItemSuccess = Extract<
InferResponseType<(typeof classroomio)['org-site']['course'][':courseSlug']['item'][':itemSlug']['$get']>,
{ success: true }
>;
type PublicItem = GetPublicCourseItemSuccess['data'];
/**
* Group the flat tree into sidebar sections. Items that aren't assigned to any
* section are grouped under a synthetic "Lessons" section so the UI never has
* orphaned rows.
*/
export function toPublicSidebarSections(tree: PublicCourseTree): PublicCourseSidebarSection[] {
const sectionLookup = new Map<string, PublicCourseSidebarSection>();
for (const section of tree.sections) {
sectionLookup.set(section.id, {
id: section.id,
title: section.title,
items: []
});
}
const orphanSection: PublicCourseSidebarSection = {
id: '__unsectioned',
title: 'Lessons',
items: []
};
for (const item of tree.items) {
const targetSection =
item.sectionId && sectionLookup.has(item.sectionId) ? sectionLookup.get(item.sectionId)! : orphanSection;
targetSection.items.push({
kind: item.kind,
id: item.id,
slug: item.slug,
title: item.title,
isUnlocked: item.isUnlocked
});
}
const ordered = Array.from(sectionLookup.values()).filter((section) => section.items.length > 0);
if (orphanSection.items.length > 0) {
ordered.push(orphanSection);
}
return ordered;
}
export function toPublicLessonView(lesson: Extract<PublicItem, { kind: 'lesson' }>): PublicLessonViewData {
return {
kind: 'lesson',
title: lesson.title,
sectionTitle: lesson.sectionTitle,
body: lesson.body ?? '',
video: lesson.video,
isUnlocked: lesson.isUnlocked
};
}
type ApiQuestion = Extract<PublicItem, { kind: 'exercise' }>['questions']['unsectioned'][number];
/**
* Convert API question shapes into `ExerciseQuestionModel`s so the public
* exercise view can drive the same renderer pipeline as the authenticated
* student view (`ExerciseQuestion.QuestionList`). Question types that aren't
* auto-gradable are filtered out — the creator UI already blocks them on
* PUBLIC courses, but we double-check here so the page never ships answers
* for a question we can't grade in the browser.
*/
export function toPublicExerciseQuestions(questions: {
unsectioned: ApiQuestion[];
sections: Array<{ questions: ApiQuestion[] }>;
}): ExerciseQuestionModel[] {
const all = [...questions.unsectioned, ...questions.sections.flatMap((section) => section.questions)];
return all.map((question) => mapQuestion(question));
}
function mapQuestion(question: ApiQuestion): ExerciseQuestionModel {
return {
id: question.id,
title: question.title,
questionType: question.questionTypeKey as ExerciseQuestionTypeKey,
points: question.points,
options: question.options.map((option) => ({
id: option.id,
label: option.label,
isCorrect: option.isCorrect,
settings: (option.settings ?? {}) as Record<string, unknown>
})),
settings: (question.settings ?? {}) as Record<string, unknown>
};
}
export function toPublicExerciseView(exercise: Extract<PublicItem, { kind: 'exercise' }>): PublicExerciseViewData {
return {
kind: 'exercise',
title: exercise.title,
description: exercise.description,
sectionTitle: exercise.sectionTitle,
isUnlocked: exercise.isUnlocked,
questions: toPublicExerciseQuestions(exercise.questions)
};
}
@@ -1,4 +1,5 @@
import type { TCourseType } from '@cio/db/types';
import type { TCourseCallout } from '@cio/utils/validation/course';
import { writable } from 'svelte/store';
type CourseSettings = {
@@ -6,12 +7,13 @@ type CourseSettings = {
courseTitle: string;
courseDescription: string;
grading: boolean;
type: 'LIVE_CLASS' | 'SELF_PACED' | 'COMPLIANCE';
type: TCourseType;
allowNewStudents: boolean;
tabs: { id: number; name: string }[];
lessonDownload: boolean;
isPublished: boolean;
isContentGroupingEnabled: boolean;
callout: TCourseCallout | null;
};
export const settings = writable<CourseSettings>({
@@ -19,7 +21,7 @@ export const settings = writable<CourseSettings>({
courseTitle: '',
courseDescription: '',
grading: false,
type: 'LIVE_CLASS' as TCourseType,
type: 'SELF_PACED' as TCourseType,
allowNewStudents: false,
tabs: [
{ id: 1, name: 'course.navItem.lessons.materials.tabs.note.title' },
@@ -29,5 +31,6 @@ export const settings = writable<CourseSettings>({
],
lessonDownload: false,
isPublished: false,
isContentGroupingEnabled: true
isContentGroupingEnabled: true,
callout: null
});
@@ -261,6 +261,21 @@ export type UpdateSubmissionGradesRequest =
export type CreateCourseRequest = typeof classroomio.course.$post;
export type GetCourseRequest = (typeof classroomio.course)[':courseId']['$get'];
export type GetCourseBySlugRequest = (typeof classroomio.course)['slug'][':slug']['$get'];
export type GetCourseBySlugSuccess = Extract<InferResponseType<GetCourseBySlugRequest>, { success: true }>;
export type GetCourseBySlugData = GetCourseBySlugSuccess['data'];
/** Org snippet returned with course slug payload for org-site flows */
export type CourseSlugPayloadOrg = {
id: string;
name: string;
siteName: string | null;
theme?: unknown;
};
/** Slug course shape including optional org (RPC unions omit `org` on some branches). */
export type CourseBySlugWithOrg = GetCourseBySlugData & {
org?: CourseSlugPayloadOrg | null;
};
export type UpdateCourseRequest = (typeof classroomio.course)[':courseId']['$put'];
export type DeleteCourseRequest = (typeof classroomio.course)[':courseId']['$delete'];
export type UpdateCourseContentRequest = (typeof classroomio.course)[':courseId']['content']['$put'];
@@ -444,3 +459,10 @@ export type VideoUploadPresignSuccess = Extract<InferResponseType<VideoUploadPre
export type VideoDownloadPresignRequest = (typeof classroomio.course)['presign']['video']['download']['$post'];
export type VideoDownloadPresignSuccess = Extract<InferResponseType<VideoDownloadPresignRequest>, { success: true }>;
export type VideoRecordingUploadInitRequest =
(typeof classroomio.course)[':courseId']['exercise'][':exerciseId']['question'][':questionId']['video-recording']['upload']['init']['$post'];
export type VideoRecordingUploadCompleteRequest =
(typeof classroomio.course)[':courseId']['exercise'][':exerciseId']['question'][':questionId']['video-recording']['upload']['complete']['$post'];
export type VideoRecordingPlaybackRequest =
(typeof classroomio.course)[':courseId']['exercise'][':exerciseId']['submission'][':submissionId']['question'][':questionId']['video-recording']['playback']['$get'];