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';
|
2026-03-30 08:24:55 +01:00
|
|
|
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;
|
2026-03-30 08:24:55 +01:00
|
|
|
exerciseId: string;
|
|
|
|
|
goBack?: () => void;
|
|
|
|
|
/** Self-paced + all auto-gradable question types: enforce points > 0 */
|
|
|
|
|
requiresPositivePointsForAutoGrade?: boolean;
|
|
|
|
|
selfPacedCourse?: boolean;
|
2026-03-09 11:18:22 +01:00
|
|
|
}
|
|
|
|
|
|
2026-03-30 08:24:55 +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">
|
2026-03-13 09:53:05 +01:00
|
|
|
{#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')}
|
2026-03-30 08:24:55 +01:00
|
|
|
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>
|
2026-03-30 08:24:55 +01:00
|
|
|
{#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}
|
2026-03-30 08:24:55 +01:00
|
|
|
<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>
|