2026-03-09 11:18:22 +01:00
|
|
|
<script lang="ts">
|
|
|
|
|
import TrashIcon from '@lucide/svelte/icons/trash';
|
2026-05-09 06:05:03 +01:00
|
|
|
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,
|
2026-05-09 06:05:03 +01:00
|
|
|
handleCode,
|
|
|
|
|
handleAddQuestion,
|
|
|
|
|
handleRemoveSection,
|
|
|
|
|
handleReorderSections,
|
|
|
|
|
handleReorderQuestionsInSection,
|
|
|
|
|
handleUpdateSectionAfterBehavior,
|
|
|
|
|
handleUpdateSectionColorTheme,
|
|
|
|
|
handleUpdateSectionDescription,
|
|
|
|
|
handleUpdateSectionTitle,
|
|
|
|
|
hasSections,
|
|
|
|
|
getQuestionsForSection
|
2026-03-09 11:18:22 +01:00
|
|
|
} from './store';
|
2026-05-09 06:05:03 +01:00
|
|
|
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';
|
2026-05-09 06:05:03 +01:00
|
|
|
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 {
|
2026-05-09 06:05:03 +01:00
|
|
|
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';
|
2026-05-09 06:05:03 +01:00
|
|
|
import SectionEditor from './section-editor.svelte';
|
|
|
|
|
import type { Question } from '$features/course/types';
|
|
|
|
|
import type { ExerciseEditorErrors } from './store';
|
2026-06-29 12:48:51 +01:00
|
|
|
import { getResolvedUploadLimits } from '$lib/utils/config/upload-limits-context';
|
2026-03-09 11:18:22 +01:00
|
|
|
|
2026-05-09 06:05:03 +01:00
|
|
|
const flipDurationMs = 200;
|
2026-06-29 12:48:51 +01:00
|
|
|
const uploadLimits = getResolvedUploadLimits();
|
|
|
|
|
const platformMaxFileSizeMb = uploadLimits.exerciseFileMb;
|
2026-03-09 11:18:22 +01:00
|
|
|
const initialQuestionsLength = $questionnaire.questions.length;
|
2026-05-09 06:05:03 +01:00
|
|
|
type QuestionDndEvent = CustomEvent<{ items: Question[] }>;
|
2026-03-09 11:18:22 +01:00
|
|
|
|
|
|
|
|
interface Props {
|
|
|
|
|
shouldDeleteExercise?: boolean;
|
2026-03-30 08:24:55 +01:00
|
|
|
exerciseId: string;
|
|
|
|
|
goBack?: () => void;
|
2026-06-22 05:43:48 +01:00
|
|
|
/** All auto-gradable question types (any course): enforce points > 0 for scoring */
|
2026-03-30 08:24:55 +01:00
|
|
|
requiresPositivePointsForAutoGrade?: boolean;
|
|
|
|
|
selfPacedCourse?: boolean;
|
2026-05-09 06:05:03 +01:00
|
|
|
/** Public course restricts picker to auto-gradable question types only. */
|
|
|
|
|
isPublicCourse?: boolean;
|
|
|
|
|
reorderQuestions?: boolean;
|
2026-03-09 11:18:22 +01:00
|
|
|
}
|
|
|
|
|
|
2026-03-30 08:24:55 +01:00
|
|
|
let {
|
|
|
|
|
shouldDeleteExercise = $bindable(false),
|
2026-05-09 06:05:03 +01:00
|
|
|
exerciseId: _exerciseId,
|
|
|
|
|
goBack: _goBack = () => {},
|
2026-03-30 08:24:55 +01:00
|
|
|
requiresPositivePointsForAutoGrade = false,
|
2026-05-09 06:05:03 +01:00
|
|
|
selfPacedCourse = false,
|
|
|
|
|
isPublicCourse = false,
|
|
|
|
|
reorderQuestions = false
|
2026-03-30 08:24:55 +01:00
|
|
|
}: Props = $props();
|
2026-03-09 11:18:22 +01:00
|
|
|
|
2026-05-09 06:05:03 +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);
|
2026-05-09 06:05:03 +01:00
|
|
|
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());
|
2026-05-09 06:05:03 +01:00
|
|
|
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'),
|
2026-05-18 03:47:22 +01:00
|
|
|
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')
|
2026-05-09 06:05:03 +01:00
|
|
|
});
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-09 06:05:03 +01:00
|
|
|
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 () => {
|
2026-05-09 06:05:03 +01:00
|
|
|
deleteConfirmationType = 'question';
|
2026-03-09 11:18:22 +01:00
|
|
|
questionIdToDelete = questionId;
|
|
|
|
|
$deleteConfirmation.open = true;
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function onFinalDeleteClicked() {
|
2026-05-09 06:05:03 +01:00
|
|
|
if (deleteConfirmationType === 'section' && sectionIdToDelete) {
|
|
|
|
|
deleteSection(sectionIdToDelete);
|
|
|
|
|
sectionIdToDelete = null;
|
|
|
|
|
deleteConfirmationType = 'question';
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 11:18:22 +01:00
|
|
|
handleRemoveQuestion(questionIdToDelete)();
|
2026-05-09 06:05:03 +01:00
|
|
|
questionIdToDelete = null;
|
2026-03-09 11:18:22 +01:00
|
|
|
}
|
|
|
|
|
|
2026-05-09 06:05:03 +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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-09 06:05:03 +01:00
|
|
|
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 ?? [];
|
2026-05-09 06:05:03 +01:00
|
|
|
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 = [];
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-09 06:05:03 +01:00
|
|
|
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,
|
2026-05-09 06:05:03 +01:00
|
|
|
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 };
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-05-09 06:05:03 +01:00
|
|
|
|
|
|
|
|
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>
|
|
|
|
|
|
2026-05-09 06:05:03 +01:00
|
|
|
<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 />
|
|
|
|
|
|
2026-05-09 06:05:03 +01:00
|
|
|
{#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,
|
2026-06-29 12:48:51 +01:00
|
|
|
platformMaxFileSizeMb,
|
2026-05-09 06:05:03 +01:00
|
|
|
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">
|
2026-05-09 06:05:03 +01:00
|
|
|
{#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
|
|
|
}}
|
2026-05-09 06:05:03 +01:00
|
|
|
onconsider={(event) => handleSectionQuestionDndConsider(section.id, event)}
|
|
|
|
|
onfinalize={(event) => handleSectionQuestionDndFinalize(section.id, event)}
|
2026-03-09 11:18:22 +01:00
|
|
|
>
|
2026-05-09 06:05:03 +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
|
|
|
>
|
2026-05-09 06:05:03 +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
|
|
|
|
2026-05-09 06:05:03 +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>
|