chore: ai, public course and other ui improvements
This commit is contained in:
@@ -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>
|
||||
+12
-7
@@ -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 > 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>
|
||||
|
||||
|
||||
+26
-1
@@ -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'
|
||||
),
|
||||
|
||||
+66
@@ -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) {
|
||||
|
||||
+72
-3
@@ -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}
|
||||
|
||||
+59
-18
@@ -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}
|
||||
|
||||
+27
-4
@@ -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"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user