Files
classroomio/apps/dashboard/src/lib/features/course/components/exercise/edit-mode.svelte
T

531 lines
20 KiB
Svelte
Raw Normal View History

2026-03-09 11:18:22 +01:00
<script lang="ts">
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';
2026-03-09 11:18:22 +01:00
import {
questionnaire,
deleteConfirmation,
questionnaireValidation,
handleRemoveQuestion,
handleCode,
handleAddQuestion,
handleRemoveSection,
handleReorderSections,
handleReorderQuestionsInSection,
handleUpdateSectionAfterBehavior,
handleUpdateSectionColorTheme,
handleUpdateSectionDescription,
handleUpdateSectionTitle,
hasSections,
getQuestionsForSection
2026-03-09 11:18:22 +01:00
} from './store';
import { QUESTION_TYPES } from '$features/ui/question/constants';
2026-03-09 11:18:22 +01:00
import { ExerciseQuestion } from '@cio/ui';
import OrderModal from './order-modal.svelte';
import QuestionTypeSelect from './question-type-select.svelte';
2026-03-09 11:18:22 +01:00
import { t } from '$lib/utils/functions/translations';
import { IconButton } from '@cio/ui/custom/icon-button';
import { TextareaField } from '@cio/ui/custom/textarea-field';
import DeleteConfirmationModal from './delete-confirmation.svelte';
import { QuestionContainer } from '$features/course/components';
import { uploadImage } from '$lib/utils/services/upload';
import {
getExerciseEditorQuestionTypeLabel,
2026-03-09 11:18:22 +01:00
getQuestionTypeOptionById,
questionTypeSupportsOptions,
toExerciseQuestionModel
} from './question-type-utils';
import { QUESTION_TYPE_KEY } from '@cio/question-types';
import { getExerciseQuestionLabels } from './question-labels';
import SectionEditor from './section-editor.svelte';
import type { Question } from '$features/course/types';
import type { ExerciseEditorErrors } from './store';
import { getResolvedUploadLimits } from '$lib/utils/config/upload-limits-context';
2026-03-09 11:18:22 +01:00
const flipDurationMs = 200;
const uploadLimits = getResolvedUploadLimits();
const platformMaxFileSizeMb = uploadLimits.exerciseFileMb;
2026-03-09 11:18:22 +01:00
const initialQuestionsLength = $questionnaire.questions.length;
type QuestionDndEvent = CustomEvent<{ items: Question[] }>;
2026-03-09 11:18:22 +01:00
interface Props {
shouldDeleteExercise?: boolean;
exerciseId: string;
goBack?: () => void;
/** All auto-gradable question types (any course): enforce points &gt; 0 for scoring */
requiresPositivePointsForAutoGrade?: boolean;
selfPacedCourse?: boolean;
/** Public course restricts picker to auto-gradable question types only. */
isPublicCourse?: boolean;
reorderQuestions?: boolean;
2026-03-09 11:18:22 +01:00
}
let {
shouldDeleteExercise = $bindable(false),
exerciseId: _exerciseId,
goBack: _goBack = () => {},
requiresPositivePointsForAutoGrade = false,
selfPacedCourse = false,
isPublicCourse = false,
reorderQuestions = false
}: Props = $props();
2026-03-09 11:18:22 +01:00
const availableQuestionTypes = $derived(
isPublicCourse ? QUESTION_TYPES.filter((type) => type.autoGradable) : QUESTION_TYPES
);
2026-03-09 11:18:22 +01:00
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);
2026-03-09 11:18:22 +01:00
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'),
collapseSection: t.get('course.navItem.lessons.exercises.all_exercises.section.collapse_section'),
expandSection: t.get('course.navItem.lessons.exercises.all_exercises.section.expand_section')
});
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')
});
2026-03-09 11:18:22 +01:00
function shouldScrollToLast(questionId, questions) {
const [lastQuestion] = questions.slice(-1);
const currentQuestionsLength = questions.length;
const isLast = lastQuestion.id === questionId;
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));
}
2026-03-09 11:18:22 +01:00
function onInitDeleteClicked(questionId) {
return () => {
deleteConfirmationType = 'question';
2026-03-09 11:18:22 +01:00
questionIdToDelete = questionId;
$deleteConfirmation.open = true;
};
}
function onFinalDeleteClicked() {
if (deleteConfirmationType === 'section' && sectionIdToDelete) {
deleteSection(sectionIdToDelete);
sectionIdToDelete = null;
deleteConfirmationType = 'question';
return;
}
2026-03-09 11:18:22 +01:00
handleRemoveQuestion(questionIdToDelete)();
questionIdToDelete = null;
2026-03-09 11:18:22 +01:00
}
function onCancelDeleteClicked() {
questionIdToDelete = null;
sectionIdToDelete = null;
deleteConfirmationType = 'question';
}
function getQuestionErrorMsg(
errors: ExerciseEditorErrors,
question: Question,
errorKey: 'option' | 'title' | 'points'
) {
2026-03-09 11:18:22 +01:00
return errors[question.id] ? errors[question.id][errorKey] : null;
}
function getSectionErrorMsg(errors: ExerciseEditorErrors, sectionId: string, errorKey: 'title' | 'description') {
return errors[sectionId]?.[errorKey];
2026-03-09 11:18:22 +01:00
}
function makeDefaultOption() {
return {
id: `${new Date().getTime()}-form`,
label: '',
value: '',
isCorrect: false
};
}
function makeTrueFalseOptions(correctValue = true) {
const ts = Date.now();
return [
{ id: `${ts}-true-form`, label: 'True', value: 'true', isCorrect: correctValue },
{ id: `${ts}-false-form`, label: 'False', value: 'false', isCorrect: !correctValue }
];
}
function onQuestionTypeChange(questionId: string | number, value: string) {
if (!value) return;
const nextQuestionType = getQuestionTypeOptionById(Number(value));
const nextQuestionTypeKey = nextQuestionType.key;
questionnaire.update((q) => {
const idx = q.questions.findIndex((qu) => String(qu.id) === String(questionId));
if (idx === -1) return q;
const current = q.questions[idx];
let nextOptions = current.options ?? [];
let nextSettings = current.settings ?? {};
2026-03-09 11:18:22 +01:00
if (nextQuestionTypeKey === QUESTION_TYPE_KEY.TRUE_FALSE) {
const correctValue = (current.settings as { correctValue?: boolean })?.correctValue ?? true;
nextOptions = makeTrueFalseOptions(correctValue);
} else if (questionTypeSupportsOptions(nextQuestionTypeKey)) {
const hasActiveOptions = nextOptions.some((option) => !option.deletedAt);
if (!hasActiveOptions) {
nextOptions = [...nextOptions, makeDefaultOption()];
}
} else {
nextOptions = [];
}
if (nextQuestionTypeKey === QUESTION_TYPE_KEY.VIDEO_RECORDING) {
nextSettings = {
...nextSettings,
maxDurationSeconds: 60,
allowRetakes: true
};
}
2026-03-09 11:18:22 +01:00
const next = [...q.questions];
next[idx] = {
...current,
questionType: nextQuestionType,
questionTypeId: nextQuestionType.id,
settings: nextSettings,
2026-03-09 11:18:22 +01:00
options: nextOptions,
isDirty: true
};
return { ...q, questions: next };
});
}
function onSharedQuestionChange(questionId: string | number, nextQuestion) {
questionnaire.update((q) => {
const idx = q.questions.findIndex((qu) => String(qu.id) === String(questionId));
if (idx === -1) return q;
const current = q.questions[idx];
const mappedOptions = Array.isArray(nextQuestion.options)
? nextQuestion.options.map((option, index) => {
const optionId = option.id ?? current.options?.[index]?.id ?? `${new Date().getTime()}-${index}-form`;
const optionLabel = option.label ?? '';
return {
id: optionId,
label: optionLabel,
value: option.value ?? optionLabel,
isCorrect: option.isCorrect ?? false,
settings:
option.settings && typeof option.settings === 'object' && !Array.isArray(option.settings)
? { ...option.settings }
: undefined
};
})
: (current.options ?? []);
const next = [...q.questions];
next[idx] = {
...current,
title: nextQuestion.title,
settings: nextQuestion.settings ?? {},
options: mappedOptions,
isDirty: true
};
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;
});
2026-03-09 11:18:22 +01:00
</script>
<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}
/>
2026-03-09 11:18:22 +01:00
<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,
platformMaxFileSizeMb,
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}
/>
{/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}
2026-03-09 11:18:22 +01:00
<div class="mb-20 w-full">
{#if Object.values(errors).length}
<div class="mb-4 flex w-full justify-center">
<p class="text-sm text-red-500">{$t('course.navItem.lessons.exercises.all_exercises.edit_mode.error')}</p>
</div>
{/if}
<div class="questions px-6 pt-6">
{#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}`
2026-03-09 11:18:22 +01:00
}}
onconsider={(event) => handleSectionQuestionDndConsider(section.id, event)}
onfinalize={(event) => handleSectionQuestionDndFinalize(section.id, event)}
2026-03-09 11:18:22 +01:00
>
{#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'
: ''}"
2026-03-09 11:18:22 +01:00
>
<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>
2026-03-09 11:18:22 +01:00
<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}
2026-03-09 11:18:22 +01:00
</div>
</div>
<style>
:global(#dnd-action-dragged-el div) {
z-index: 100;
}
</style>