676 lines
25 KiB
Svelte
676 lines
25 KiB
Svelte
<script lang="ts">
|
|
import { get } from 'svelte/store';
|
|
import { untrack } from 'svelte';
|
|
import { fly } from 'svelte/transition';
|
|
import { cubicInOut } from 'svelte/easing';
|
|
import { courseApi } from '$features/course/api';
|
|
import { questionnaire, questionnaireMetaData, resetStudentExerciseTake } from './store';
|
|
import Preview from './preview.svelte';
|
|
import { ExerciseQuestion } from '@cio/ui';
|
|
import * as Alert from '@cio/ui/base/alert';
|
|
import { Badge } from '@cio/ui/base/badge';
|
|
import { Button } from '@cio/ui/base/button';
|
|
import { RoleBasedSecurity } from '$features/ui';
|
|
import { Empty } from '@cio/ui/custom/empty';
|
|
import FileQuestionIcon from '@lucide/svelte/icons/file-question';
|
|
import { Progress } from '@cio/ui/base/progress';
|
|
import { getExerciseQuestionContractKey } from '@cio/question-types';
|
|
import { STATUS } from './constants';
|
|
import { filterOutDeleted } from './functions';
|
|
import { formatAnswers } from '$features/course/utils/functions';
|
|
import {
|
|
getTextareaAnswerCharacterCount,
|
|
toApiPayload,
|
|
type AnswerData,
|
|
validateTextareaAnswer
|
|
} from '@cio/question-types';
|
|
import { exerciseApi, presignApi } from '$features/course/api';
|
|
import type { SubmissionListItem } from '$features/course/utils/types';
|
|
import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
|
|
import { t } from '$lib/utils/functions/translations';
|
|
import { snackbar } from '$features/ui/snackbar/store';
|
|
import { ContentType } from '@cio/utils/constants/content';
|
|
import { getOrderedNavigableContent } from '$features/course/utils/content';
|
|
import { isOrgStudent } from '$lib/utils/store/app';
|
|
import { openCourseCompletionModal } from '$features/course/store/course-completion-modal';
|
|
import { isSelfPacedLikeCourse } from '$features/course/utils/compliance-utils';
|
|
import { toExerciseQuestionModel } from './question-type-utils';
|
|
import { getExerciseQuestionLabels } from './question-labels';
|
|
import axios from 'axios';
|
|
import { IconButton } from '@cio/ui/custom/icon-button';
|
|
import ChevronLeftIcon from '@lucide/svelte/icons/chevron-left';
|
|
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
|
|
|
|
interface Props {
|
|
preview?: boolean;
|
|
exerciseId?: string;
|
|
isFetchingExercise?: boolean;
|
|
/** Current user's submissions (ordered by attempt) */
|
|
mySubmissions?: SubmissionListItem[];
|
|
}
|
|
|
|
let { preview = false, exerciseId = '', isFetchingExercise = false, mySubmissions = [] }: Props = $props();
|
|
|
|
let isSubmitting = $state(false);
|
|
/** When true, do not re-apply a past submission over a fresh take (Try again). */
|
|
let skipHydrateFromSubmissions = $state(false);
|
|
let isLoadingAutoSavedData = $state(false);
|
|
let alreadyCheckedAutoSavedData = $state(false);
|
|
let prevExerciseId = $state('');
|
|
let slideDirection = $state<'next' | 'prev'>('next');
|
|
let selectedTryIndex = $state(-1);
|
|
const questionLabels = $derived(getExerciseQuestionLabels());
|
|
|
|
const submissionList = $derived(Array.isArray(mySubmissions) ? mySubmissions : []);
|
|
|
|
function handleStart() {
|
|
questionnaireMetaData.update((m) => ({ ...m, currentQuestionIndex: m.currentQuestionIndex + 1 }));
|
|
}
|
|
|
|
function normalizeAnswerValue(
|
|
previousValue: AnswerData | undefined,
|
|
nextValue: AnswerData | undefined
|
|
): AnswerData | undefined {
|
|
if (nextValue && typeof nextValue === 'object' && 'type' in nextValue) {
|
|
return nextValue;
|
|
}
|
|
return previousValue;
|
|
}
|
|
|
|
function mapAnswerToApiPayload(question, answerData: AnswerData | null | undefined) {
|
|
const questionId = Number(question.id);
|
|
if (Number.isNaN(questionId) || !answerData) return null;
|
|
return toApiPayload(answerData, questionId);
|
|
}
|
|
|
|
async function onSubmit(id, value) {
|
|
if (!courseApi.course?.id) return;
|
|
|
|
const { answers } = $questionnaireMetaData;
|
|
const { questions } = $questionnaire;
|
|
const prevAnswer = answers[id];
|
|
const formattedAnswer = normalizeAnswerValue(prevAnswer, value);
|
|
|
|
if (!hasAnswerValue(formattedAnswer)) {
|
|
snackbar.error(t.get('course.navItem.lessons.exercises.all_exercises.view_mode.answer_required'));
|
|
return;
|
|
}
|
|
|
|
const question = questions.find((q) => getExerciseQuestionContractKey(toExerciseQuestionModel(q)) === id);
|
|
if (question && !mapAnswerToApiPayload(question, formattedAnswer as AnswerData)) {
|
|
snackbar.error(t.get('course.navItem.lessons.exercises.all_exercises.view_mode.answer_required'));
|
|
return;
|
|
}
|
|
|
|
const newAnswers = { ...answers, [id]: formattedAnswer };
|
|
questionnaireMetaData.update((m) => ({ ...m, answers: newAnswers }));
|
|
|
|
const currentIndex = $questionnaireMetaData.currentQuestionIndex;
|
|
const isLastQuestion = currentIndex === questions.length;
|
|
|
|
if (isLastQuestion) {
|
|
// Stay on last question, show spinner, submit to server
|
|
if (isSubmitting) return;
|
|
isSubmitting = true;
|
|
|
|
const updated = get(questionnaireMetaData);
|
|
localStorage.removeItem(`autosave-exercise-${exerciseId}`);
|
|
const totalPossibleGrade = getTotalPossibleGrade($questionnaire.questions);
|
|
|
|
const answersForApi = Object.entries(updated.answers)
|
|
.map(([questionKey, val]) => {
|
|
const question = questions.find(
|
|
(item) => getExerciseQuestionContractKey(toExerciseQuestionModel(item)) === questionKey
|
|
);
|
|
if (!question) return null;
|
|
return mapAnswerToApiPayload(question, val);
|
|
})
|
|
.filter((answer) => answer !== null) as Array<{ questionId: number; optionId?: number; answer?: string }>;
|
|
|
|
if (answersForApi.length === 0) {
|
|
isSubmitting = false;
|
|
snackbar.error(t.get('course.navItem.lessons.exercises.all_exercises.view_mode.answers_required'));
|
|
return;
|
|
}
|
|
|
|
const submitResult = await exerciseApi.submit(courseApi.course.id, exerciseId, answersForApi);
|
|
|
|
if (exerciseApi.success && submitResult?.data) {
|
|
skipHydrateFromSubmissions = false;
|
|
const sub = submitResult.data as unknown as SubmissionListItem & { gradingState?: string };
|
|
if (sub.gradingState === 'completed' && sub.statusId === STATUS.GRADED && Array.isArray(sub.answers)) {
|
|
applyMySubmission(sub);
|
|
} else {
|
|
questionnaireMetaData.update((m) => ({
|
|
...m,
|
|
status: 1,
|
|
totalPossibleGrade,
|
|
grades: {},
|
|
comment: '',
|
|
isFinished: true,
|
|
exerciseId
|
|
}));
|
|
}
|
|
courseApi.updateContentItem(exerciseId, ContentType.Exercise, { isComplete: true });
|
|
|
|
const allContentItems = getOrderedNavigableContent(courseApi.course);
|
|
const allComplete =
|
|
$isOrgStudent && allContentItems.length > 0 && allContentItems.every((item) => item.isComplete);
|
|
|
|
if (allComplete && courseApi.course?.id) {
|
|
openCourseCompletionModal(courseApi.course.id);
|
|
}
|
|
}
|
|
|
|
isSubmitting = false;
|
|
} else {
|
|
// Advance to next question
|
|
slideDirection = 'next';
|
|
questionnaireMetaData.update((m) => ({ ...m, currentQuestionIndex: m.currentQuestionIndex + 1 }));
|
|
const updated = get(questionnaireMetaData);
|
|
localStorage.setItem(`autosave-exercise-${exerciseId}`, JSON.stringify(updated));
|
|
}
|
|
}
|
|
|
|
function onPrevious() {
|
|
slideDirection = 'prev';
|
|
questionnaireMetaData.update((m) => ({ ...m, currentQuestionIndex: m.currentQuestionIndex - 1 }));
|
|
}
|
|
|
|
function getProgressValue(currentQuestionIndex) {
|
|
if ($questionnaireMetaData.isFinished) {
|
|
return 100;
|
|
}
|
|
|
|
return Math.round(((currentQuestionIndex - 1) / $questionnaire.questions.length) * 100) || 0;
|
|
}
|
|
|
|
function getTotalPossibleGrade(questions) {
|
|
return questions.reduce((acc, question) => {
|
|
acc += parseFloat(question.points);
|
|
return acc;
|
|
}, 0);
|
|
}
|
|
|
|
function getAutoSavedData() {
|
|
isLoadingAutoSavedData = true;
|
|
|
|
const stringifiedQuestionnaireMetaData = localStorage.getItem(`autosave-exercise-${exerciseId}`);
|
|
|
|
if (stringifiedQuestionnaireMetaData) {
|
|
const autoSavedData = JSON.parse(stringifiedQuestionnaireMetaData);
|
|
if (autoSavedData) {
|
|
questionnaireMetaData.set(autoSavedData);
|
|
}
|
|
}
|
|
isLoadingAutoSavedData = false;
|
|
alreadyCheckedAutoSavedData = true;
|
|
}
|
|
|
|
async function handleFileUpload(file: File): Promise<{ fileKey: string; fileName: string; fileUrl?: string }> {
|
|
const uploadResult = await presignApi.getDocumentUploadUrl(
|
|
file.name,
|
|
file.type as
|
|
| 'application/pdf'
|
|
| 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|
|
| 'application/msword'
|
|
);
|
|
if (!uploadResult) {
|
|
throw new Error('Failed to get upload URL');
|
|
}
|
|
await axios.put(uploadResult.url, file, {
|
|
headers: { 'Content-Type': file.type },
|
|
maxContentLength: Infinity,
|
|
maxBodyLength: Infinity
|
|
});
|
|
const urls = await presignApi.getDocumentDownloadUrls([uploadResult.fileKey]);
|
|
const fileUrl = urls[uploadResult.fileKey];
|
|
|
|
return { fileKey: uploadResult.fileKey, fileName: file.name, fileUrl };
|
|
}
|
|
|
|
function hasAnswerValue(answerValue: AnswerData | null | undefined): boolean {
|
|
if (answerValue === null || answerValue === undefined) return false;
|
|
if (typeof answerValue === 'object' && 'type' in answerValue) {
|
|
switch (answerValue.type) {
|
|
case 'RADIO':
|
|
case 'TRUE_FALSE':
|
|
case 'NUMERIC':
|
|
case 'STAR':
|
|
return true;
|
|
case 'CHECKBOX':
|
|
return answerValue.optionIds.length > 0;
|
|
case 'TEXTAREA':
|
|
return getTextareaAnswerCharacterCount(answerValue.text) > 0;
|
|
case 'SHORT_ANSWER':
|
|
return answerValue.text.trim().length > 0;
|
|
case 'FILL_BLANK':
|
|
return answerValue.values.length > 0;
|
|
case 'WORD_BANK': {
|
|
const filled = answerValue.filledBlanks ?? [];
|
|
return filled.length > 0 && filled.every((v) => String(v).trim().length > 0);
|
|
}
|
|
case 'FILE_UPLOAD':
|
|
return !!answerValue.fileKey;
|
|
case 'MATCHING':
|
|
return answerValue.pairs.length > 0;
|
|
case 'ORDERING':
|
|
return answerValue.orderedValues.length > 0;
|
|
case 'LINK':
|
|
return answerValue.urls.length > 0;
|
|
case 'HOTSPOT':
|
|
return answerValue.coordinates.length > 0;
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function onSharedAnswerChange(answerValue) {
|
|
if (!sharedCurrentQuestionKey) return;
|
|
|
|
questionnaireMetaData.update((metaData) => ({
|
|
...metaData,
|
|
answers: {
|
|
...metaData.answers,
|
|
[sharedCurrentQuestionKey]: answerValue
|
|
}
|
|
}));
|
|
}
|
|
|
|
function onSharedNext(valueOverride?: AnswerData) {
|
|
if (!sharedCurrentQuestionKey) return;
|
|
const valueToUse = valueOverride !== undefined ? valueOverride : sharedCurrentAnswer;
|
|
if (!hasAnswerValue(valueToUse)) {
|
|
snackbar.error(t.get('course.navItem.lessons.exercises.all_exercises.view_mode.answer_required'));
|
|
return;
|
|
}
|
|
|
|
const textareaError = getTextareaValidationError(valueToUse);
|
|
if (textareaError) {
|
|
snackbar.error(textareaError);
|
|
return;
|
|
}
|
|
|
|
onSubmit(sharedCurrentQuestionKey, valueToUse);
|
|
}
|
|
|
|
function handleEnterKey(e: KeyboardEvent) {
|
|
if (e.key !== 'Enter' || isSubmitting || !currentQuestion) return;
|
|
|
|
const target = e.target as HTMLElement;
|
|
if (target?.tagName === 'TEXTAREA' || isTextEditorTarget(target)) return;
|
|
|
|
e.preventDefault();
|
|
|
|
let valueToUse: AnswerData | undefined = sharedCurrentAnswer as AnswerData | undefined;
|
|
|
|
if (target instanceof HTMLInputElement && sharedQuestionModel) {
|
|
const questionTypeKey = sharedQuestionModel.questionType;
|
|
const trimmed = target.value?.trim() ?? '';
|
|
|
|
if (questionTypeKey === 'SHORT_ANSWER' && trimmed) {
|
|
valueToUse = { type: 'SHORT_ANSWER', text: trimmed };
|
|
onSharedAnswerChange(valueToUse);
|
|
} else if (questionTypeKey === 'NUMERIC' && trimmed) {
|
|
const num = Number(trimmed);
|
|
valueToUse = !Number.isNaN(num) ? { type: 'NUMERIC', value: num } : undefined;
|
|
|
|
if (valueToUse) onSharedAnswerChange(valueToUse);
|
|
}
|
|
}
|
|
if (!hasAnswerValue(valueToUse)) {
|
|
snackbar.error($t('course.navItem.lessons.exercises.all_exercises.view_mode.answer_required'));
|
|
return;
|
|
}
|
|
onSharedNext(valueToUse);
|
|
}
|
|
|
|
function isTextEditorTarget(target: EventTarget | null): boolean {
|
|
if (!(target instanceof HTMLElement)) return false;
|
|
|
|
if (target.isContentEditable) return true;
|
|
|
|
return target.closest('[contenteditable="true"], .ProseMirror') !== null;
|
|
}
|
|
|
|
function getTextareaValidationError(answerValue: AnswerData | null | undefined): string | null {
|
|
if (!sharedQuestionModel || !answerValue || answerValue.type !== 'TEXTAREA') return null;
|
|
|
|
const validation = validateTextareaAnswer(answerValue.text, sharedQuestionModel);
|
|
if (validation.isValid) return null;
|
|
|
|
if (validation.minCharacters !== undefined && validation.maxCharacters !== undefined) {
|
|
return t.get('course.navItem.lessons.exercises.all_exercises.shared_question.textarea.take.range_error', {
|
|
min: validation.minCharacters,
|
|
max: validation.maxCharacters
|
|
});
|
|
}
|
|
|
|
if (validation.reason === 'below_min' && validation.minCharacters !== undefined) {
|
|
return t.get('course.navItem.lessons.exercises.all_exercises.shared_question.textarea.take.min_error', {
|
|
min: validation.minCharacters
|
|
});
|
|
}
|
|
|
|
if (validation.reason === 'above_max' && validation.maxCharacters !== undefined) {
|
|
return t.get('course.navItem.lessons.exercises.all_exercises.shared_question.textarea.take.max_error', {
|
|
max: validation.maxCharacters
|
|
});
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
$effect(() => {
|
|
if (!alreadyCheckedAutoSavedData) {
|
|
getAutoSavedData();
|
|
}
|
|
});
|
|
|
|
const progressValue = $derived(getProgressValue($questionnaireMetaData.currentQuestionIndex));
|
|
const currentQuestion = $derived($questionnaire.questions[$questionnaireMetaData.currentQuestionIndex - 1]);
|
|
const isExerciseLoaded = $derived(alreadyCheckedAutoSavedData && $questionnaire.questions.length > 0);
|
|
const isFinished = $derived(isExerciseLoaded && $questionnaireMetaData.currentQuestionIndex > 0 && !currentQuestion);
|
|
const sharedQuestionModel = $derived(currentQuestion ? toExerciseQuestionModel(currentQuestion) : null);
|
|
const sharedCurrentQuestionKey = $derived(
|
|
sharedQuestionModel ? getExerciseQuestionContractKey(sharedQuestionModel) : null
|
|
);
|
|
const sharedCurrentAnswer = $derived(
|
|
sharedCurrentQuestionKey ? $questionnaireMetaData.answers[sharedCurrentQuestionKey] : undefined
|
|
);
|
|
const canGoNextForSharedQuestion = $derived(hasAnswerValue(sharedCurrentAnswer));
|
|
|
|
const flyX = $derived(slideDirection === 'next' ? 300 : -300);
|
|
const flyOutX = $derived(-flyX);
|
|
|
|
$effect(() => {
|
|
const finished = isFinished;
|
|
|
|
untrack(() => {
|
|
$questionnaireMetaData.isFinished = finished;
|
|
});
|
|
});
|
|
|
|
$effect(() => {
|
|
const progress = progressValue;
|
|
|
|
untrack(() => {
|
|
$questionnaireMetaData.progressValue = progress;
|
|
});
|
|
});
|
|
|
|
$effect(() => {
|
|
if (!exerciseId || isFetchingExercise || skipHydrateFromSubmissions) return;
|
|
if (!submissionList.length || $questionnaireMetaData.isFinished) return;
|
|
let idx = selectedTryIndex;
|
|
if (idx < 0 || idx >= submissionList.length) {
|
|
idx = submissionList.length - 1;
|
|
selectedTryIndex = idx;
|
|
}
|
|
const sub = submissionList[idx];
|
|
if (sub) applyMySubmission(sub);
|
|
});
|
|
|
|
function goPrevTry() {
|
|
if (selectedTryIndex <= 0) return;
|
|
selectedTryIndex -= 1;
|
|
const sub = submissionList[selectedTryIndex];
|
|
if (sub) applyMySubmission(sub);
|
|
}
|
|
|
|
function goNextTry() {
|
|
if (selectedTryIndex >= submissionList.length - 1) return;
|
|
selectedTryIndex += 1;
|
|
const sub = submissionList[selectedTryIndex];
|
|
if (sub) applyMySubmission(sub);
|
|
}
|
|
|
|
function tryAgain() {
|
|
if (!$questionnaire.allowMultipleAttempts) return;
|
|
localStorage.removeItem(`autosave-exercise-${exerciseId}`);
|
|
selectedTryIndex = -1;
|
|
skipHydrateFromSubmissions = true;
|
|
resetStudentExerciseTake();
|
|
}
|
|
|
|
function applyMySubmission(submission: SubmissionListItem) {
|
|
const totalPossibleGrade = getTotalPossibleGrade($questionnaire.questions);
|
|
let finalTotalGrade = 0;
|
|
const grades = (submission.answers ?? []).reduce<Record<string, number>>((acc, answer) => {
|
|
const point = answer.point ?? 0;
|
|
acc[String(answer.questionId)] = point;
|
|
finalTotalGrade += point;
|
|
return acc;
|
|
}, {});
|
|
|
|
const answers = formatAnswers({
|
|
questions: $questionnaire.questions,
|
|
answers: submission.answers ?? []
|
|
});
|
|
|
|
questionnaireMetaData.update((m) => ({
|
|
...m,
|
|
answers,
|
|
totalPossibleGrade,
|
|
currentQuestionIndex: $questionnaire.questions.length,
|
|
isFinished: true,
|
|
status: submission.statusId ?? STATUS.PENDING,
|
|
finalTotalGrade,
|
|
comment: submission.feedback ?? '',
|
|
grades,
|
|
exerciseId
|
|
}));
|
|
}
|
|
|
|
$effect(() => {
|
|
if (prevExerciseId === exerciseId) return;
|
|
if (!!prevExerciseId) {
|
|
isSubmitting = false;
|
|
alreadyCheckedAutoSavedData = false;
|
|
skipHydrateFromSubmissions = false;
|
|
selectedTryIndex = -1;
|
|
}
|
|
prevExerciseId = exerciseId;
|
|
});
|
|
</script>
|
|
|
|
{#if !preview && $questionnaire.questions.length && !$questionnaireMetaData.isFinished && $questionnaireMetaData.currentQuestionIndex > 0}
|
|
<div class="mb-6 flex min-w-0 items-center gap-3">
|
|
<span class="ui:text-muted-foreground shrink-0 text-sm tabular-nums">
|
|
{$questionnaireMetaData.currentQuestionIndex}/{$questionnaire.questions.length}
|
|
</span>
|
|
<Progress class="min-w-0 flex-1" value={$questionnaireMetaData.progressValue} />
|
|
</div>
|
|
{/if}
|
|
|
|
<svelte:window onkeydown={handleEnterKey} />
|
|
|
|
{#if preview}
|
|
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
|
<Preview questions={filterOutDeleted($questionnaire.questions)} questionnaireMetaData={$questionnaireMetaData} />
|
|
</RoleBasedSecurity>
|
|
{:else if !$questionnaire.questions.length}
|
|
<Empty
|
|
title={$t('course.navItem.lessons.exercises.all_exercises.view_mode.no_question')}
|
|
icon={FileQuestionIcon}
|
|
variant="page"
|
|
>
|
|
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
|
<p class="ui:text-primary text-center text-sm">
|
|
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.empty_edit_hint_prefix')}
|
|
|
|
<span class="font-semibold">
|
|
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.edit')}
|
|
</span>
|
|
|
|
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.empty_edit_hint_suffix')}
|
|
</p>
|
|
</RoleBasedSecurity>
|
|
</Empty>
|
|
{:else if $questionnaireMetaData.currentQuestionIndex === 0}
|
|
<RoleBasedSecurity allowedRoles={[3]}>
|
|
<div>
|
|
<h2 class="my-1">{$questionnaire.title}</h2>
|
|
|
|
<div class="flex items-center">
|
|
<p class="mx-2 dark:text-white">
|
|
<strong>{$questionnaire.questions.length}</strong>
|
|
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.questions')}
|
|
</p>
|
|
|
|
|
<p class="mx-2 dark:text-white">
|
|
<strong>{getTotalPossibleGrade($questionnaire.questions)}</strong>
|
|
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.points')}.
|
|
</p>
|
|
|
|
|
<p class="mx-2 dark:text-white">{$t('course.navItem.lessons.exercises.all_exercises.view_mode.all')}</p>
|
|
{#if $questionnaire.dueBy}
|
|
|
|
|
<p class="mx-2 dark:text-white">
|
|
<strong>{$t('course.navItem.lessons.exercises.all_exercises.view_mode.due')}:</strong>
|
|
{new Date($questionnaire.dueBy).toLocaleString()}
|
|
</p>
|
|
{/if}
|
|
</div>
|
|
|
|
<article class="preview prose prose-sm sm:prose mt-3 p-2">
|
|
<SafeHtmlContent
|
|
content={$questionnaire.description ||
|
|
$t('course.navItem.lessons.exercises.all_exercises.view_mode.no_description')}
|
|
/>
|
|
</article>
|
|
|
|
<Button onclick={handleStart} type="button" class="float-right my-5">
|
|
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.start')}
|
|
</Button>
|
|
</div>
|
|
</RoleBasedSecurity>
|
|
{:else if $questionnaireMetaData.isFinished}
|
|
{#if !isLoadingAutoSavedData}
|
|
<div class="flex w-full flex-col items-start lg:flex-row lg:items-center lg:space-x-4">
|
|
{#if STATUS.GRADED === $questionnaireMetaData.status}
|
|
<div class="mb-8 w-full space-y-2">
|
|
<Badge variant="success" title={$t('course.navItem.lessons.exercises.all_exercises.view_mode.status_graded')}>
|
|
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.graded')}
|
|
</Badge>
|
|
|
|
<div class="flex w-full flex-col items-start gap-2 md:flex-row md:items-center">
|
|
{#if $questionnaireMetaData.comment}
|
|
<Alert.Callout
|
|
variant="information"
|
|
title={$t('course.navItem.lessons.exercises.all_exercises.view_mode.instructor_feedback')}
|
|
description={$questionnaireMetaData.comment}
|
|
class="h-fit! w-full! flex-1!"
|
|
/>
|
|
{/if}
|
|
<div class="flex flex-col justify-between gap-2 rounded-md border p-4">
|
|
<p>
|
|
<span class="text-2xl font-bold">{$questionnaireMetaData.finalTotalGrade}/</span>
|
|
|
|
<span class="text-xl">{$questionnaireMetaData.totalPossibleGrade}</span>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{:else if isSelfPacedLikeCourse(courseApi.course?.type)}
|
|
<Badge
|
|
variant="success"
|
|
title={$t('course.navItem.lessons.exercises.all_exercises.view_mode.status_submitted')}
|
|
>
|
|
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.submitted')}
|
|
</Badge>
|
|
{:else}
|
|
<Badge variant="warning" title={$t('course.navItem.lessons.exercises.all_exercises.view_mode.status_pending')}>
|
|
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.pending')}
|
|
</Badge>
|
|
{/if}
|
|
</div>
|
|
|
|
{#if submissionList.length > 1}
|
|
<div class="mb-4 flex flex-wrap items-center gap-2">
|
|
<IconButton
|
|
disabled={selectedTryIndex <= 0}
|
|
onclick={goPrevTry}
|
|
tooltip={$t('course.navItem.lessons.exercises.all_exercises.view_mode.previous_try')}
|
|
>
|
|
<ChevronLeftIcon class="h-4 w-4" />
|
|
</IconButton>
|
|
<span class="ui:text-muted-foreground text-sm">
|
|
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.attempt_counter', {
|
|
current: selectedTryIndex + 1,
|
|
total: submissionList.length
|
|
})}
|
|
</span>
|
|
<IconButton
|
|
disabled={selectedTryIndex >= submissionList.length - 1}
|
|
onclick={goNextTry}
|
|
tooltip={$t('course.navItem.lessons.exercises.all_exercises.view_mode.next_try')}
|
|
>
|
|
<ChevronRightIcon class="h-4 w-4" />
|
|
</IconButton>
|
|
</div>
|
|
{/if}
|
|
|
|
<Preview
|
|
questions={[...$questionnaire.questions].sort(
|
|
(leftQuestion, rightQuestion) => leftQuestion.order - rightQuestion.order
|
|
)}
|
|
questionnaireMetaData={$questionnaireMetaData}
|
|
grades={$questionnaireMetaData.grades}
|
|
disableGrading={true}
|
|
/>
|
|
|
|
<RoleBasedSecurity allowedRoles={[3]}>
|
|
{#if $questionnaire.allowMultipleAttempts}
|
|
<div class="mt-4">
|
|
<Button type="button" variant="secondary" onclick={tryAgain}>
|
|
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.try_again')}
|
|
</Button>
|
|
</div>
|
|
{/if}
|
|
</RoleBasedSecurity>
|
|
{/if}
|
|
{:else if currentQuestion && currentQuestion?.id}
|
|
<div class="flex flex-col gap-4">
|
|
<div class="grid overflow-hidden" style="grid-template-areas: 'slot';">
|
|
{#key currentQuestion.id}
|
|
<div
|
|
id="question"
|
|
class="min-w-0 [grid-area:slot]"
|
|
in:fly={{ x: flyX, duration: 350, easing: cubicInOut }}
|
|
out:fly={{ x: flyOutX, duration: 350, easing: cubicInOut }}
|
|
>
|
|
{#if sharedQuestionModel}
|
|
<ExerciseQuestion.QuestionRenderer
|
|
contract={{
|
|
mode: 'take',
|
|
question: sharedQuestionModel,
|
|
answer: sharedCurrentAnswer,
|
|
labels: questionLabels,
|
|
disabled: isSubmitting,
|
|
onFileUpload: handleFileUpload
|
|
}}
|
|
onAnswerChange={onSharedAnswerChange}
|
|
/>
|
|
{/if}
|
|
</div>
|
|
{/key}
|
|
</div>
|
|
<div>
|
|
<ExerciseQuestion.QuestionNavigation
|
|
canGoBack={$questionnaireMetaData.currentQuestionIndex > 1}
|
|
canGoNext={canGoNextForSharedQuestion && !isSubmitting}
|
|
isLast={$questionnaireMetaData.currentQuestionIndex === $questionnaire.questions.length}
|
|
{isSubmitting}
|
|
previousLabel={t.get('course.navItem.lessons.exercises.all_exercises.previous')}
|
|
nextLabel={t.get('course.navItem.lessons.exercises.all_exercises.next')}
|
|
finishLabel={t.get('course.navItem.lessons.exercises.all_exercises.finish')}
|
|
{onPrevious}
|
|
onNext={onSharedNext}
|
|
/>
|
|
</div>
|
|
</div>
|
|
{/if}
|