Files
classroomio/apps/dashboard/src/lib/features/course/components/exercise/edit-mode.svelte
T
20e34e29dd feat(exercise): add open-ended thumbs up/down question type (#780)
* feat(ui): add THUMBS question type renderer

- Add seven new renderer files under renderers/thumbs/:
  thumbs-correct.ts, take.svelte, edit.svelte, preview.svelte,
  thumbs-review-toggle.svelte, review.svelte, submission.svelte
- Register THUMBS in renderer-contract.ts with edit/take/preview/submission/review
- Export ThumbsEdit, ThumbsTake, ThumbsPreview, ThumbsSubmission,
  ThumbsReview from exercise-question/index.ts
- Add THUMBS_FIXTURE, four thumbs label keys, and Thumbs story
  in storybook exercise-question files
- Add THUMBS case to question-type-modes.svelte submission builder

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* feat(question-types): add THUMBS contracts, scoring, and seed

Introduce the THUMBS question type (id 14) with boolean answer data,
codec, auto-grading, Zod validation, DB seed/schema docs, and binary
normalize support shared with TRUE_FALSE. Dashboard wiring is deferred.

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* feat(dashboard): wire THUMBS question type into exercise UI

Connect THUMBS option sync in edit/view modes, labels, picker icon,
and locale strings so the existing contracts surface in the dashboard.

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* fix(ui): polish thumbs question UI and move premium upgrade icon

Widen thumbs Yes/No toggles, inherit selected icon color via custom class,
replace badge-based review correctness with solid fill states and bare
Check/X icons, add a success pill in preview, and place the premium
upgrade icon at the end of the question type select row.

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* fix(ui): align thumbs review colors with prototypes

Tune correct/incorrect selected states so review buttons use dark
emerald/red content colors on solid fills, matching the design comps.

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* fix(ui): address remaining PR review comments

Use a shared timestamp local in thumbs edit fallback options.
Move border-beam animation-delay after the animation shorthand.
Alias --spacing to --ui-spacing for tw-animate compatibility.

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* fix(db): sync question_type sequence before seeding new types

Avoid duplicate key errors when question_type_id_seq lags behind rows
inserted via restore or explicit ids (e.g. THUMBS on restored databases).

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* fix(exercise): resolve thumbs review label ICU crash

Remove {value} ICU placeholder from thumbs.review.correct_answer_label
since labels are resolved at map-build time without context. Append the
dynamic yes/no label in thumbs-review-toggle instead.

Also bind question points through the questionnaire store index to avoid
the Svelte non-reactive property binding warning in edit-mode.

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* docs(agent): add THUMBS guidance and sync question type docs

Add shared agent question-type guide helpers in @cio/question-types and
use them across the teacher prompt, add/update_questions tool schemas,
legacy ai-assistant schemas, and MCP exercise tools.

- Document when to use THUMBS (Yes/No sentiment) vs TRUE_FALSE (factual)
- Fix stale MCP question type ids and add THUMBS=14
- Exclude disabled MATCHING/HOTSPOT from enabled type lists

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* fix(thumbs): clarify auto-grading and add editable option labels

THUMBS is auto-graded like TRUE_FALSE; the difference is thumbs UI with
customizable Yes/No-style labels and the teacher selecting the correct
option. Render take/preview/review from question.options labels and add
label inputs in the thumbs edit renderer. Update agent and MCP docs to
stop implying THUMBS is manual or non-gradable.

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* chore(mcp): bump version to 0.0.10

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* fix(question-types): make THUMBS open-ended with no correct answer

THUMBS is a sentiment/poll question type, not an auto-graded binary like
TRUE_FALSE. Remove correct-answer UI, scoring, and validation; update agent
and MCP docs accordingly.

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* fix(dashboard): stop treating THUMBS as auto-graded in editor validation

Normalize thumbs questions on load/save, show an Open-ended badge instead
of Auto, and only enforce points/correct-answer rules on auto-gradable types.

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* revert(dashboard): remove unsolicited open-ended grading badge

THUMBS uses the existing Manual badge like other non-auto-gradable types.

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* fix(exercise): show question title validation errors in editor

Display mapped title errors beneath the question title input instead of
only highlighting the question card border. Pass the exercise entity to
mapZodErrorsToTranslations so messages resolve to validations.exercise.*
keys, and use a translated snackbar key for save validation failures.

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* fix(mcp): build workspace deps before MCP publish bundle

The MCP esbuild step imports @cio/question-types and @cio/utils dist
outputs. CI failed on a clean checkout because mcp:build only built the
MCP package itself. Build those dependencies first so 0.0.10 can publish.

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>
2026-07-31 14:59:38 +01:00

600 lines
23 KiB
Svelte

<script lang="ts">
import TrashIcon from '@lucide/svelte/icons/trash';
import GripVerticalIcon from '@lucide/svelte/icons/grip-vertical';
import InfoIcon from '@lucide/svelte/icons/info';
import { dndzone } from 'svelte-dnd-action';
import { flip } from 'svelte/animate';
import { tick } from 'svelte';
import {
questionnaire,
deleteConfirmation,
questionnaireValidation,
handleRemoveQuestion,
handleCode,
handleAddQuestion,
handleRemoveSection,
handleReorderSections,
handleReorderQuestionsInSection,
handleUpdateSectionAfterBehavior,
handleUpdateSectionColorTheme,
handleUpdateSectionDescription,
handleUpdateSectionTitle,
hasSections,
getQuestionsForSection
} from './store';
import { QUESTION_TYPES } from '$features/ui/question/constants';
import { ExerciseQuestion } from '@cio/ui';
import { Badge } from '@cio/ui/base/badge';
import * as Tooltip from '@cio/ui/base/tooltip';
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';
import DeleteConfirmationModal from './delete-confirmation.svelte';
import { QuestionContainer } from '$features/course/components';
import { uploadImage } from '$lib/utils/services/upload';
import {
getExerciseEditorQuestionTypeLabel,
getQuestionTypeKey,
getQuestionTypeOptionById,
questionTypeSupportsOptions,
toExerciseQuestionModel
} from './question-type-utils';
import {
QUESTION_TYPE_KEY,
isAutoGradableQuestionTypeId,
resolveTrueFalseCorrectValue,
syncTrueFalseOptions
} 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';
const flipDurationMs = 200;
const uploadLimits = getResolvedUploadLimits();
const platformMaxFileSizeMb = uploadLimits.exerciseFileMb;
const initialQuestionsLength = $questionnaire.questions.length;
type QuestionDndEvent = CustomEvent<{ items: Question[] }>;
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;
}
let {
shouldDeleteExercise = $bindable(false),
exerciseId: _exerciseId,
goBack: _goBack = () => {},
requiresPositivePointsForAutoGrade = 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'),
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')
});
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));
}
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 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 getSectionErrorMsg(errors: ExerciseEditorErrors, sectionId: string, errorKey: 'title' | 'description') {
return errors[sectionId]?.[errorKey];
}
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 makeThumbsOptions() {
const ts = Date.now();
return [
{ id: `${ts}-yes-form`, label: 'Yes', value: 'true', isCorrect: false },
{ id: `${ts}-no-form`, label: 'No', value: 'false', isCorrect: false }
];
}
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 ?? {};
if (nextQuestionTypeKey === QUESTION_TYPE_KEY.TRUE_FALSE) {
const correctValue = (current.settings as { correctValue?: boolean })?.correctValue ?? true;
nextSettings = { ...nextSettings, correctValue };
nextOptions = makeTrueFalseOptions(correctValue);
} else if (nextQuestionTypeKey === QUESTION_TYPE_KEY.THUMBS) {
nextSettings = { ...nextSettings };
nextOptions = makeThumbsOptions();
} 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
};
}
const next = [...q.questions];
next[idx] = {
...current,
questionType: nextQuestionType,
questionTypeId: nextQuestionType.id,
settings: nextSettings,
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 ?? []);
let nextSettings = nextQuestion.settings ?? {};
let nextOptions = mappedOptions;
const currentQuestionTypeKey = getQuestionTypeKey(current);
if (currentQuestionTypeKey === QUESTION_TYPE_KEY.TRUE_FALSE) {
const correctValue = resolveTrueFalseCorrectValue(nextSettings, nextOptions);
nextSettings = { ...nextSettings, correctValue };
nextOptions = syncTrueFalseOptions(nextOptions, correctValue);
}
const next = [...q.questions];
next[idx] = {
...current,
title: nextQuestion.title,
settings: nextSettings,
options: nextOptions,
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;
}
function isQuestionAutoGradable(question: Question) {
const questionTypeId = Number(question.questionTypeId ?? question.questionType?.id);
if (!Number.isFinite(questionTypeId)) return false;
return isAutoGradableQuestionTypeId(questionTypeId);
}
$effect(() => {
if (reorderQuestions && !previousReorderQuestions) {
syncReorderQuestionItems();
}
if (!reorderQuestions && previousReorderQuestions) {
reorderQuestionItemsBySectionId = {};
}
previousReorderQuestions = reorderQuestions;
});
</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}
/>
<OrderModal />
{#snippet gradingBadge(question)}
{@const autoGradable = isQuestionAutoGradable(question)}
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger>
{#snippet child({ props })}
<Badge
{...props}
variant="outline"
class="absolute top-0 right-4 z-10 translate-y-[-50%] gap-1 bg-white font-normal dark:bg-black"
>
{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')}
<InfoIcon class="h-3 w-3" aria-hidden="true" />
</Badge>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content side="top" sideOffset={6} class="max-w-xs">
{autoGradable
? $t('course.navItem.lessons.exercises.all_exercises.edit_mode.question_type_auto_gradable_description')
: $t('course.navItem.lessons.exercises.all_exercises.edit_mode.question_type_manual_grading_description')}
</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
{/snippet}
{#snippet questionEditor(question, index)}
{@const questionStoreIndex = $questionnaire.questions.findIndex((item) => item.id === question.id)}
<QuestionContainer
elementId={getQuestionElementId(question.id)}
key={String(question.id ?? `new-${index}`)}
onClose={onInitDeleteClicked(question.id)}
scrollToQuestion={shouldScrollToLast(question.id, $questionnaire.questions)}
bind:points={$questionnaire.questions[questionStoreIndex].points}
hasError={!!errors[question.id]}
errorMsg={getQuestionErrorMsg(errors, question, 'points')}
pointsHint={isQuestionAutoGradable(question) && requiresPositivePointsForAutoGrade
? $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}
{@render gradingBadge(question)}
<div class="mt-2 flex flex-col">
<ExerciseQuestion.QuestionRenderer
showContainer={false}
titleError={getQuestionErrorMsg(errors, question, 'title')}
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}
<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}`
}}
onconsider={(event) => handleSectionQuestionDndConsider(section.id, event)}
onfinalize={(event) => handleSectionQuestionDndFinalize(section.id, event)}
>
{#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'
: ''}"
>
<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>
<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>
<style>
:global(#dnd-action-dragged-el div) {
z-index: 100;
}
</style>