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

304 lines
12 KiB
Svelte
Raw Normal View History

2026-03-09 11:18:22 +01:00
<script lang="ts">
import * as Select from '@cio/ui/base/select';
import TrashIcon from '@lucide/svelte/icons/trash';
import {
questionnaire,
deleteConfirmation,
questionnaireValidation,
handleRemoveQuestion,
handleCode
} 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';
2026-03-09 11:18:22 +01:00
import { ExerciseQuestion } from '@cio/ui';
import OrderModal from './order-modal.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 {
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';
const initialQuestionsLength = $questionnaire.questions.length;
interface Props {
shouldDeleteExercise?: boolean;
exerciseId: string;
goBack?: () => void;
/** Self-paced + all auto-gradable question types: enforce points &gt; 0 */
requiresPositivePointsForAutoGrade?: boolean;
selfPacedCourse?: boolean;
2026-03-09 11:18:22 +01:00
}
let {
shouldDeleteExercise = $bindable(false),
exerciseId,
goBack = () => {},
requiresPositivePointsForAutoGrade = false,
selfPacedCourse = false
}: Props = $props();
2026-03-09 11:18:22 +01:00
let questionIdToDelete = $state(null);
let errors = $derived($questionnaireValidation);
const questionLabels = $derived(getExerciseQuestionLabels());
function shouldScrollToLast(questionId, questions) {
const [lastQuestion] = questions.slice(-1);
const currentQuestionsLength = questions.length;
const isLast = lastQuestion.id === questionId;
return isLast && initialQuestionsLength !== currentQuestionsLength;
}
function onInitDeleteClicked(questionId) {
return () => {
questionIdToDelete = questionId;
$deleteConfirmation.open = true;
};
}
function onFinalDeleteClicked() {
handleRemoveQuestion(questionIdToDelete)();
}
function getQuestionErrorMsg(errors, question, errorKey) {
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 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 ?? [];
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 = [];
}
const next = [...q.questions];
next[idx] = {
...current,
questionType: nextQuestionType,
questionTypeId: nextQuestionType.id,
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 };
});
}
</script>
<DeleteConfirmationModal onCancel={() => (questionIdToDelete = null)} onDelete={onFinalDeleteClicked} />
<OrderModal />
<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">
{#each $questionnaire.questions as question, index (question.id)}
2026-03-09 11:18:22 +01:00
{#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}
2026-03-09 11:18:22 +01:00
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()}
<Select.Root
type="single"
value={question.questionTypeId?.toString()}
onValueChange={(value) => onQuestionTypeChange(question.id, value)}
>
<Select.Trigger class="w-[180px]">
{getQuestionTypeLabel(question?.questionType)}
</Select.Trigger>
<Select.Content>
{#each QUESTION_TYPES as type (type.key)}
2026-03-09 11:18:22 +01:00
{#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>
2026-03-09 11:18:22 +01:00
{/if}
{/each}
</Select.Content>
</Select.Root>
{/snippet}
</ExerciseQuestion.QuestionRenderer>
{#if getQuestionErrorMsg(errors, question, 'option')}
<p class="text-sm text-red-500">{getQuestionErrorMsg(errors, question, 'option')}</p>
{/if}
</div>
</QuestionContainer>
{/if}
{/each}
</div>
</div>
<style>
:global(#dnd-action-dragged-el div) {
z-index: 100;
}
</style>