fix(exercise): persist completion policy and improve dev build workflow (#754)
* fix(exercise): persist completion policy and pass threshold on save buildExerciseUpdateFields accepted completionPolicy and passThreshold in the validation schema but never mapped them to the database update, so exercise settings appeared to save while reverting on refresh. Co-authored-by: Rotimi Best <[email protected]> * fix(db): build workspace deps before compiling @cio/db imports @cio/utils/notifications, which only exists after @cio/utils is built. Building db in isolation failed when utils dist was missing or stale. - Add a db prebuild step for question-types, utils, and email - Clean dist and tsbuildinfo before utils and email builds so incremental tsc cannot skip emission after dist was removed Co-authored-by: Rotimi Best <[email protected]> * chore(dev): build api workspace deps before api:dev Run turbo build for @cio/api and @cio/jobs-worker dependencies before starting the dev servers, so missing dist/ folders do not break startup. Co-authored-by: Rotimi Best <[email protected]> * feat(course): improve exercise progression grading UX --------- Co-authored-by: Rotimi Best <[email protected]>
This commit is contained in:
co-authored by
Rotimi Best
parent
fdc19aac41
commit
bfafc685e0
+7
-2
@@ -11,6 +11,7 @@
|
||||
import { courseCompletionModal, closeCourseCompletionModal } from '$features/course/store/course-completion-modal';
|
||||
import { getContentRoute } from '$features/course/utils/content';
|
||||
import { formatBlockerMessage } from '$features/course/utils/certificate-utils';
|
||||
import { isFreePlan } from '$lib/utils/store/org';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
let open = $state(false);
|
||||
@@ -84,11 +85,15 @@
|
||||
{:else if activeStep === 'eligible'}
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{$t('course.completion.modal.title')}</Dialog.Title>
|
||||
<Dialog.Description>{$t('course.completion.modal.subtitle')}</Dialog.Description>
|
||||
<Dialog.Description>
|
||||
{$isFreePlan ? $t('course.completion.modal.subtitle_no_certificate') : $t('course.completion.modal.subtitle')}
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<Dialog.Footer class="ui:gap-2">
|
||||
<Button variant="outline" onclick={() => onOpenChange(false)}>{$t('course.completion.modal.later')}</Button>
|
||||
<Button onclick={goToCertificate}>{$t('course.completion.modal.view_certificate')}</Button>
|
||||
{#if !$isFreePlan}
|
||||
<Button onclick={goToCertificate}>{$t('course.completion.modal.view_certificate')}</Button>
|
||||
{/if}
|
||||
</Dialog.Footer>
|
||||
{:else if activeStep === 'not-eligible'}
|
||||
<Dialog.Header>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<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';
|
||||
@@ -24,6 +25,8 @@
|
||||
} 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';
|
||||
@@ -35,11 +38,17 @@
|
||||
import { uploadImage } from '$lib/utils/services/upload';
|
||||
import {
|
||||
getExerciseEditorQuestionTypeLabel,
|
||||
getQuestionTypeKey,
|
||||
getQuestionTypeOptionById,
|
||||
questionTypeSupportsOptions,
|
||||
toExerciseQuestionModel
|
||||
} from './question-type-utils';
|
||||
import { QUESTION_TYPE_KEY } from '@cio/question-types';
|
||||
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';
|
||||
@@ -207,6 +216,7 @@
|
||||
|
||||
if (nextQuestionTypeKey === QUESTION_TYPE_KEY.TRUE_FALSE) {
|
||||
const correctValue = (current.settings as { correctValue?: boolean })?.correctValue ?? true;
|
||||
nextSettings = { ...nextSettings, correctValue };
|
||||
nextOptions = makeTrueFalseOptions(correctValue);
|
||||
} else if (questionTypeSupportsOptions(nextQuestionTypeKey)) {
|
||||
const hasActiveOptions = nextOptions.some((option) => !option.deletedAt);
|
||||
@@ -263,12 +273,21 @@
|
||||
})
|
||||
: (current.options ?? []);
|
||||
|
||||
let nextSettings = nextQuestion.settings ?? {};
|
||||
let nextOptions = mappedOptions;
|
||||
|
||||
if (getQuestionTypeKey(current) === 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: nextQuestion.settings ?? {},
|
||||
options: mappedOptions,
|
||||
settings: nextSettings,
|
||||
options: nextOptions,
|
||||
isDirty: true
|
||||
};
|
||||
|
||||
@@ -331,6 +350,13 @@
|
||||
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();
|
||||
@@ -357,6 +383,33 @@
|
||||
|
||||
<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)}
|
||||
<QuestionContainer
|
||||
elementId={getQuestionElementId(question.id)}
|
||||
@@ -366,7 +419,7 @@
|
||||
bind:points={question.points}
|
||||
hasError={!!errors[question.id]}
|
||||
errorMsg={getQuestionErrorMsg(errors, question, 'points')}
|
||||
pointsHint={requiresPositivePointsForAutoGrade && Number(question.points) === 0
|
||||
pointsHint={requiresPositivePointsForAutoGrade
|
||||
? $t('course.navItem.lessons.exercises.all_exercises.points_required_auto_grade')
|
||||
: null}
|
||||
onPointsChange={() => {
|
||||
@@ -386,6 +439,8 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{@render gradingBadge(question)}
|
||||
|
||||
<div class="mt-2 flex flex-col">
|
||||
<ExerciseQuestion.QuestionRenderer
|
||||
showContainer={false}
|
||||
|
||||
+34
-6
@@ -13,9 +13,11 @@
|
||||
|
||||
type Props = {
|
||||
exerciseId: string;
|
||||
onBeforeSave?: () => Promise<boolean> | boolean;
|
||||
totalSubmissions?: number;
|
||||
};
|
||||
|
||||
let { exerciseId }: Props = $props();
|
||||
let { exerciseId, onBeforeSave = () => true, totalSubmissions = 0 }: Props = $props();
|
||||
|
||||
let saving = $state(false);
|
||||
let slug = $state($questionnaire.slug ?? '');
|
||||
@@ -28,13 +30,21 @@
|
||||
|
||||
async function saveSettings() {
|
||||
if (!courseApi.course?.id) return;
|
||||
const canSave = await onBeforeSave();
|
||||
if (!canSave) return;
|
||||
|
||||
saving = true;
|
||||
|
||||
const allowMultipleAttempts = !!$questionnaire.allowMultipleAttempts;
|
||||
const completionPolicy = $questionnaire.completionPolicy ?? 'submitted';
|
||||
const passThreshold = $questionnaire.passThreshold ?? 100;
|
||||
const slugPayload = isPublicCourse && slug ? slug : undefined;
|
||||
|
||||
await exerciseApi.update(courseApi.course.id, exerciseId, {
|
||||
allowMultipleAttempts: !!$questionnaire.allowMultipleAttempts,
|
||||
completionPolicy: $questionnaire.completionPolicy ?? 'submitted',
|
||||
passThreshold: $questionnaire.passThreshold ?? 100,
|
||||
slug: isPublicCourse && slug ? slug : undefined
|
||||
allowMultipleAttempts,
|
||||
completionPolicy,
|
||||
passThreshold,
|
||||
slug: slugPayload
|
||||
});
|
||||
saving = false;
|
||||
|
||||
@@ -92,7 +102,25 @@
|
||||
</Select.Root>
|
||||
|
||||
{#if ($questionnaire.completionPolicy ?? 'submitted') === 'passed'}
|
||||
<div class="mt-4">
|
||||
<div class="mt-4 space-y-3">
|
||||
<div class="space-y-2 text-sm">
|
||||
<p class="ui:text-muted-foreground">
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.settings_completion_passed_helper')}
|
||||
</p>
|
||||
<p class="text-amber-700 dark:text-amber-300">
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.settings_manual_grading_warning')}
|
||||
</p>
|
||||
{#if !$questionnaire.allowMultipleAttempts}
|
||||
<p class="text-amber-700 dark:text-amber-300">
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.settings_single_attempt_warning')}
|
||||
</p>
|
||||
{/if}
|
||||
{#if totalSubmissions > 0}
|
||||
<p class="text-amber-700 dark:text-amber-300">
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.settings_existing_submissions_warning')}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<InputField
|
||||
type="number"
|
||||
label={$t('course.navItem.lessons.exercises.all_exercises.settings_pass_threshold')}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getQuestionAnswerKey, getQuestionTypeKey, questionTypeSupportsOptions } from './question-type-utils';
|
||||
|
||||
import { QUESTION_TYPE_KEY } from '@cio/question-types';
|
||||
import { QUESTION_TYPE_KEY, resolveTrueFalseCorrectValue, syncTrueFalseOptions } from '@cio/question-types';
|
||||
import type { Question } from '$features/course/types';
|
||||
import type { QuestionnaireMetaData } from './store';
|
||||
import type { TExerciseUpdate } from '@cio/utils/validation/exercise';
|
||||
@@ -153,15 +153,37 @@ export function transformQuestionsToApiFormat(
|
||||
|
||||
const filteredQuestions = shouldIncludeDeleted ? questions : questions.filter((q) => !q.deletedAt);
|
||||
|
||||
return filteredQuestions.map((q, index) => ({
|
||||
id: normalizeId(q.id),
|
||||
question: q.title,
|
||||
points: q.points || 0,
|
||||
questionTypeId: q.questionTypeId,
|
||||
exerciseSectionId: q.exerciseSectionId ?? null,
|
||||
order: typeof (q as { order?: number }).order === 'number' ? (q as { order: number }).order : index + 1,
|
||||
settings: getQuestionSettings(q),
|
||||
options: questionTypeSupportsOptions(getQuestionTypeKey(q)) ? formatOptions(q.options) : [],
|
||||
...(shouldIncludeDeleted && q.deletedAt ? { deletedAt: q.deletedAt } : {})
|
||||
}));
|
||||
return filteredQuestions.map((q, index) => {
|
||||
const questionTypeKey = getQuestionTypeKey(q);
|
||||
const settings = getQuestionSettings(q);
|
||||
const formattedOptions = questionTypeSupportsOptions(questionTypeKey) ? formatOptions(q.options) : [];
|
||||
|
||||
if (questionTypeKey === QUESTION_TYPE_KEY.TRUE_FALSE) {
|
||||
const correctValue = resolveTrueFalseCorrectValue(settings, formattedOptions);
|
||||
|
||||
return {
|
||||
id: normalizeId(q.id),
|
||||
question: q.title,
|
||||
points: q.points || 0,
|
||||
questionTypeId: q.questionTypeId,
|
||||
exerciseSectionId: q.exerciseSectionId ?? null,
|
||||
order: typeof (q as { order?: number }).order === 'number' ? (q as { order: number }).order : index + 1,
|
||||
settings: { ...(settings ?? {}), correctValue },
|
||||
options: syncTrueFalseOptions(formattedOptions, correctValue),
|
||||
...(shouldIncludeDeleted && q.deletedAt ? { deletedAt: q.deletedAt } : {})
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: normalizeId(q.id),
|
||||
question: q.title,
|
||||
points: q.points || 0,
|
||||
questionTypeId: q.questionTypeId,
|
||||
exerciseSectionId: q.exerciseSectionId ?? null,
|
||||
order: typeof (q as { order?: number }).order === 'number' ? (q as { order: number }).order : index + 1,
|
||||
settings,
|
||||
options: formattedOptions,
|
||||
...(shouldIncludeDeleted && q.deletedAt ? { deletedAt: q.deletedAt } : {})
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
+157
-21
@@ -2,24 +2,31 @@
|
||||
import { Spinner } from '@cio/ui/base/spinner';
|
||||
import { ExerciseQuestion } from '@cio/ui';
|
||||
import { UserAvatar } from '@cio/ui/custom/user-avatar';
|
||||
import { Badge } from '@cio/ui/base/badge';
|
||||
import * as Select from '@cio/ui/base/select';
|
||||
|
||||
import { submissions } from './store';
|
||||
import { getQuestionsForSection, questionnaire } from '../store';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import type { ExerciseSubmissions } from './types';
|
||||
import type { Question } from '$features/course/types';
|
||||
import type { AnswerData } from '@cio/question-types';
|
||||
import { toExerciseQuestionModel } from '../question-type-utils';
|
||||
import { getExerciseQuestionLabels } from '../question-labels';
|
||||
import { getExerciseSectionDisplayTitle } from '$features/course/utils/exercise-section-utils';
|
||||
import {
|
||||
shouldCompleteExerciseFromSubmission,
|
||||
type ExerciseSubmissionStudentGroup
|
||||
} from '$features/course/utils/exercise-progression-utils';
|
||||
import type { SubmissionListItem } from '$features/course/utils/types';
|
||||
|
||||
interface Props {
|
||||
isLoading?: boolean;
|
||||
submissionGroups?: ExerciseSubmissionStudentGroup[];
|
||||
}
|
||||
|
||||
let { isLoading = $bindable(false) }: Props = $props();
|
||||
let { isLoading = $bindable(false), submissionGroups = [] }: Props = $props();
|
||||
|
||||
let studentSelected = $state(0);
|
||||
let selectedAttemptByStudentKey = $state<Record<string, number>>({});
|
||||
const questionLabels = $derived(getExerciseQuestionLabels());
|
||||
const sectionFallbackTitle = $derived($t('course.navItem.lessons.exercises.all_exercises.section.fallback_title'));
|
||||
const activeSections = $derived(
|
||||
@@ -32,13 +39,13 @@
|
||||
return Number.isNaN(numericId) ? null : numericId;
|
||||
}
|
||||
|
||||
function getStudentAnswerForQuestion(student: ExerciseSubmissions, question: Question): AnswerData | undefined {
|
||||
if (!student?.answers) return undefined;
|
||||
function getStudentAnswerForQuestion(submission: SubmissionListItem, question: Question): AnswerData | undefined {
|
||||
if (!submission?.answers) return undefined;
|
||||
|
||||
const questionId = toNumericQuestionId(question);
|
||||
if (questionId === null) return undefined;
|
||||
|
||||
const submittedAnswer = student.answers.find((answer) => answer.questionId === questionId);
|
||||
const submittedAnswer = submission.answers.find((answer) => answer.questionId === questionId);
|
||||
if (
|
||||
!submittedAnswer?.answerData ||
|
||||
typeof submittedAnswer.answerData !== 'object' ||
|
||||
@@ -49,12 +56,12 @@
|
||||
return submittedAnswer.answerData;
|
||||
}
|
||||
|
||||
function getSectionScore(student: ExerciseSubmissions, questions: Question[]) {
|
||||
function getSectionScore(submission: SubmissionListItem, questions: Question[]) {
|
||||
return questions.reduce((total, question) => {
|
||||
const questionId = toNumericQuestionId(question);
|
||||
if (questionId === null) return total;
|
||||
|
||||
const answer = student.answers.find((item) => item.questionId === questionId);
|
||||
const answer = submission.answers.find((item) => item.questionId === questionId);
|
||||
return total + (answer?.point ?? 0);
|
||||
}, 0);
|
||||
}
|
||||
@@ -62,13 +69,83 @@
|
||||
function getSectionMaxPoints(questions: Question[]) {
|
||||
return questions.reduce((total, question) => total + Number(question.points ?? 0), 0);
|
||||
}
|
||||
|
||||
function getTotalScore(submission: SubmissionListItem) {
|
||||
const submissionTotal = Number(
|
||||
(submission as SubmissionListItem & { total?: number | string | null }).total ?? NaN
|
||||
);
|
||||
if (Number.isFinite(submissionTotal)) return submissionTotal;
|
||||
|
||||
return submission.answers.reduce((total, answer) => total + Number(answer.point ?? 0), 0);
|
||||
}
|
||||
|
||||
function getTotalPossiblePoints() {
|
||||
return getSectionMaxPoints($questionnaire.questions.filter((question) => !question.deletedAt));
|
||||
}
|
||||
|
||||
function formatSubmittedAt(submission: SubmissionListItem) {
|
||||
const createdAt = (submission as SubmissionListItem & { createdAt?: string | Date | null }).createdAt;
|
||||
if (!createdAt) return t.get('course.navItem.lessons.exercises.all_exercises.analytics.individual.date_unknown');
|
||||
|
||||
return new Date(createdAt).toLocaleString();
|
||||
}
|
||||
|
||||
function getStatusLabel(submission: SubmissionListItem & { gradingState?: string | null }) {
|
||||
if (submission.gradingState === 'awaiting_manual') {
|
||||
return t.get('course.navItem.lessons.exercises.all_exercises.analytics.individual.status_awaiting_manual');
|
||||
}
|
||||
|
||||
if (submission.gradingState === 'completed' || submission.statusId === 3) {
|
||||
return t.get('course.navItem.lessons.exercises.all_exercises.analytics.individual.status_completed');
|
||||
}
|
||||
|
||||
return t.get('course.navItem.lessons.exercises.all_exercises.analytics.individual.status_pending');
|
||||
}
|
||||
|
||||
function getAttemptPassLabel(submission: SubmissionListItem & { gradingState?: string | null }) {
|
||||
return didAttemptCompleteExercise(submission)
|
||||
? ($questionnaire.completionPolicy ?? 'submitted') === 'submitted'
|
||||
? t.get('course.navItem.lessons.exercises.all_exercises.analytics.individual.counts_as_complete')
|
||||
: t.get('course.navItem.lessons.exercises.all_exercises.analytics.individual.passed')
|
||||
: t.get('course.navItem.lessons.exercises.all_exercises.analytics.individual.not_passed');
|
||||
}
|
||||
|
||||
function didAttemptCompleteExercise(submission: SubmissionListItem & { gradingState?: string | null }) {
|
||||
return shouldCompleteExerciseFromSubmission({
|
||||
completionPolicy: $questionnaire.completionPolicy,
|
||||
passThreshold: $questionnaire.passThreshold,
|
||||
totalPossiblePoints: getTotalPossiblePoints(),
|
||||
submission
|
||||
});
|
||||
}
|
||||
|
||||
function getSelectedGroup() {
|
||||
return submissionGroups[studentSelected] ?? submissionGroups[0];
|
||||
}
|
||||
|
||||
function getSelectedAttemptIndex(group: ExerciseSubmissionStudentGroup) {
|
||||
const selectedAttemptIndex = selectedAttemptByStudentKey[group.studentKey] ?? group.attempts.length - 1;
|
||||
if (selectedAttemptIndex < 0 || selectedAttemptIndex >= group.attempts.length) return group.attempts.length - 1;
|
||||
|
||||
return selectedAttemptIndex;
|
||||
}
|
||||
|
||||
function selectAttempt(group: ExerciseSubmissionStudentGroup, value: string) {
|
||||
const selectedAttemptIndex = Number(value);
|
||||
if (Number.isNaN(selectedAttemptIndex)) return;
|
||||
|
||||
selectedAttemptByStudentKey = {
|
||||
...selectedAttemptByStudentKey,
|
||||
[group.studentKey]: selectedAttemptIndex
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if isLoading}
|
||||
<Spinner />
|
||||
{:else if $submissions?.length}
|
||||
{:else if submissionGroups?.length}
|
||||
<div class="mt-2 mb-5 flex w-full gap-1 overflow-auto">
|
||||
{#each $submissions as student, i (student.id)}
|
||||
{#each submissionGroups as studentGroup, i (studentGroup.studentKey)}
|
||||
<button onclick={() => (studentSelected = i)} class="flex w-20 flex-col items-center">
|
||||
<div
|
||||
class={`flex h-12 w-12 items-center justify-center rounded-full ${
|
||||
@@ -76,24 +153,83 @@
|
||||
}`}
|
||||
>
|
||||
<UserAvatar
|
||||
src={student.groupmember?.profile.avatarUrl}
|
||||
src={studentGroup.studentAvatarUrl}
|
||||
alt={$t('course.navItem.lessons.exercises.all_exercises.analytics.individual.student_avatar')}
|
||||
class="m-1 size-10"
|
||||
/>
|
||||
</div>
|
||||
<p class="line-clamp-2 w-20 leading-4 whitespace-pre-wrap">
|
||||
{student.groupmember?.profile.fullname ?? ''}
|
||||
{studentGroup.studentName}
|
||||
</p>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<p class="mb-2 font-medium">
|
||||
{$submissions[studentSelected].groupmember?.profile.fullname ?? ''}'s {$t(
|
||||
'course.navItem.lessons.exercises.all_exercises.analytics.individual.answers'
|
||||
)}
|
||||
</p>
|
||||
{#if $submissions[studentSelected]}
|
||||
{@const selectedGroup = getSelectedGroup()}
|
||||
{#if selectedGroup}
|
||||
{@const selectedAttemptIndex = getSelectedAttemptIndex(selectedGroup)}
|
||||
{@const selectedAttempt = selectedGroup.attempts[selectedAttemptIndex]}
|
||||
{@const selectedSubmission = selectedAttempt.submission}
|
||||
<div class="mb-4 flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.analytics.individual.answers_for', {
|
||||
name: selectedGroup.studentName
|
||||
})}
|
||||
</p>
|
||||
<p class="ui:text-muted-foreground text-sm">
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.analytics.individual.attempts_count', {
|
||||
count: selectedGroup.attempts.length
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="w-full md:w-80">
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={String(selectedAttemptIndex)}
|
||||
onValueChange={(value) => selectAttempt(selectedGroup, value)}
|
||||
>
|
||||
<Select.Trigger class="w-full">
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.analytics.individual.attempt_label', {
|
||||
number: selectedAttempt.attemptNumber
|
||||
})}
|
||||
-
|
||||
{getStatusLabel(selectedSubmission)}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each selectedGroup.attempts as attempt, attemptIndex (attempt.submission.id)}
|
||||
{@const attemptScore = getTotalScore(attempt.submission)}
|
||||
<Select.Item value={String(attemptIndex)}>
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.analytics.individual.attempt_label', {
|
||||
number: attempt.attemptNumber
|
||||
})}
|
||||
-
|
||||
{formatSubmittedAt(attempt.submission)}
|
||||
-
|
||||
{getStatusLabel(attempt.submission)}
|
||||
-
|
||||
{attemptScore}/{getTotalPossiblePoints()}
|
||||
-
|
||||
{getAttemptPassLabel(attempt.submission)}
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4 flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline">{getStatusLabel(selectedSubmission)}</Badge>
|
||||
<Badge variant="secondary">
|
||||
{getTotalScore(selectedSubmission)}/{getTotalPossiblePoints()}
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.points')}
|
||||
</Badge>
|
||||
<Badge variant={didAttemptCompleteExercise(selectedSubmission) ? 'success' : 'warning'}>
|
||||
{getAttemptPassLabel(selectedSubmission)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{#if hasSectionGroups}
|
||||
<div class="space-y-8">
|
||||
{#each activeSections as section, sectionIndex (section.id)}
|
||||
@@ -124,7 +260,7 @@
|
||||
contract={{
|
||||
mode: 'review',
|
||||
question: toExerciseQuestionModel(q),
|
||||
answer: getStudentAnswerForQuestion($submissions[studentSelected], q),
|
||||
answer: getStudentAnswerForQuestion(selectedSubmission, q),
|
||||
labels: questionLabels,
|
||||
disabled: true
|
||||
}}
|
||||
@@ -135,7 +271,7 @@
|
||||
{/each}
|
||||
|
||||
<p class="border-t pt-2 text-sm font-medium">
|
||||
{getSectionScore($submissions[studentSelected], sectionQuestions)}/{getSectionMaxPoints(sectionQuestions)}
|
||||
{getSectionScore(selectedSubmission, sectionQuestions)}/{getSectionMaxPoints(sectionQuestions)}
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.points')}
|
||||
</p>
|
||||
</section>
|
||||
@@ -148,7 +284,7 @@
|
||||
contract={{
|
||||
mode: 'review',
|
||||
question: toExerciseQuestionModel(q),
|
||||
answer: getStudentAnswerForQuestion($submissions[studentSelected], q),
|
||||
answer: getStudentAnswerForQuestion(selectedSubmission, q),
|
||||
labels: questionLabels,
|
||||
disabled: true
|
||||
}}
|
||||
|
||||
+3
-1
@@ -8,6 +8,7 @@
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import type { ExerciseSubmissions } from './types';
|
||||
import type { SubmissionListItem } from '$features/course/utils/types';
|
||||
import { groupSubmissionsByStudentAndAttempt } from '$features/course/utils/exercise-progression-utils';
|
||||
|
||||
interface Props {
|
||||
exerciseId: string;
|
||||
@@ -18,6 +19,7 @@
|
||||
|
||||
type SubmissionTab = 'summary' | 'individual';
|
||||
let currentTab = $state<SubmissionTab>('summary');
|
||||
const submissionGroups = $derived(groupSubmissionsByStudentAndAttempt(submissionsData));
|
||||
|
||||
function normalizeSubmissions(items: SubmissionListItem[]): ExerciseSubmissions[] {
|
||||
return items.map(
|
||||
@@ -66,7 +68,7 @@
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="individual" class="pt-2">
|
||||
<Individual isLoading={false} />
|
||||
<Individual isLoading={false} {submissionGroups} />
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
{:else}
|
||||
|
||||
+2
-2
@@ -40,9 +40,9 @@
|
||||
(): ExerciseQuestionLabels => ({
|
||||
'submission.common.no_answer': $t('course.navItem.lessons.exercises.all_exercises.analytics.individual.no'),
|
||||
'submission.common.other': $t('course.navItem.lessons.exercises.all_exercises.analytics.summary.other'),
|
||||
'submission.chart.responses': $t('course.navItem.lessons.exercises.all_exercises.analytics.summary.responses'),
|
||||
'submission.chart.responses': $t('course.navItem.lessons.exercises.all_exercises.analytics.summary.attempts'),
|
||||
'submission.chart.no_data': $t('course.navItem.lessons.exercises.all_exercises.analytics.summary.no_responses'),
|
||||
'submission.list.responses': $t('course.navItem.lessons.exercises.all_exercises.analytics.summary.responses'),
|
||||
'submission.list.responses': $t('course.navItem.lessons.exercises.all_exercises.analytics.summary.attempts'),
|
||||
'submission.list.no_responses': $t(
|
||||
'course.navItem.lessons.exercises.all_exercises.analytics.summary.no_responses'
|
||||
)
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
import { toExerciseQuestionModel } from './question-type-utils';
|
||||
import { getExerciseQuestionLabels } from './question-labels';
|
||||
import { getExerciseSectionDisplayTitle } from '$features/course/utils/exercise-section-utils';
|
||||
import { shouldCompleteExerciseFromSubmission } from '$features/course/utils/exercise-progression-utils';
|
||||
import axios from 'axios';
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
import ChevronLeftIcon from '@lucide/svelte/icons/chevron-left';
|
||||
@@ -80,7 +81,19 @@
|
||||
const questionLabels = $derived(getExerciseQuestionLabels());
|
||||
const sectionFallbackTitle = $derived($t('course.navItem.lessons.exercises.all_exercises.section.fallback_title'));
|
||||
|
||||
const submissionList = $derived(Array.isArray(mySubmissions) ? mySubmissions : []);
|
||||
/** Submissions created in this session — the `mySubmissions` prop only refreshes on page load. */
|
||||
let localSubmissions = $state<SubmissionListItem[]>([]);
|
||||
|
||||
const submissionList = $derived.by(() => {
|
||||
const loadedSubmissions = Array.isArray(mySubmissions) ? mySubmissions : [];
|
||||
const newSubmissions = localSubmissions.filter(
|
||||
(localSubmission) => !loadedSubmissions.some((loadedSubmission) => loadedSubmission.id === localSubmission.id)
|
||||
);
|
||||
return [...loadedSubmissions, ...newSubmissions];
|
||||
});
|
||||
|
||||
/** Attempt currently shown: -1 means "latest" (e.g. right after submitting). */
|
||||
const viewedAttemptIndex = $derived(selectedTryIndex >= 0 ? selectedTryIndex : submissionList.length - 1);
|
||||
|
||||
function handleStart() {
|
||||
questionnaireMetaData.update((m) => ({
|
||||
@@ -137,6 +150,8 @@
|
||||
if (exerciseApi.success && submitResult?.data) {
|
||||
skipHydrateFromSubmissions = false;
|
||||
const sub = submitResult.data as unknown as SubmissionListItem & { gradingState?: string };
|
||||
localSubmissions = [...localSubmissions, sub];
|
||||
selectedTryIndex = submissionList.length - 1;
|
||||
if (sub.gradingState === 'completed' && sub.statusId === STATUS.GRADED && Array.isArray(sub.answers)) {
|
||||
applyMySubmission(sub);
|
||||
} else {
|
||||
@@ -150,14 +165,26 @@
|
||||
exerciseId
|
||||
}));
|
||||
}
|
||||
courseApi.updateContentItem(exerciseId, ContentType.Exercise, { isComplete: true });
|
||||
const shouldMarkComplete = shouldCompleteExerciseFromSubmission({
|
||||
completionPolicy: $questionnaire.completionPolicy,
|
||||
passThreshold: $questionnaire.passThreshold,
|
||||
totalPossiblePoints: totalPossibleGrade,
|
||||
submission: sub
|
||||
});
|
||||
|
||||
toggleConfetti();
|
||||
setTimeout(toggleConfetti, 3000);
|
||||
courseApi.updateContentItem(exerciseId, ContentType.Exercise, { isComplete: shouldMarkComplete });
|
||||
|
||||
if (shouldMarkComplete) {
|
||||
toggleConfetti();
|
||||
setTimeout(toggleConfetti, 3000);
|
||||
}
|
||||
|
||||
const allContentItems = getOrderedNavigableContent(courseApi.course);
|
||||
const allComplete =
|
||||
$isOrgStudent && allContentItems.length > 0 && allContentItems.every((item) => item.isComplete);
|
||||
shouldMarkComplete &&
|
||||
$isOrgStudent &&
|
||||
allContentItems.length > 0 &&
|
||||
allContentItems.every((item) => item.isComplete);
|
||||
|
||||
if (allComplete && courseApi.course?.id) {
|
||||
const requiredExerciseId = courseApi.course?.certificate?.requiredExerciseId ?? undefined;
|
||||
@@ -165,11 +192,13 @@
|
||||
|
||||
const certRes = await courseApi.getCertificationEvaluation(courseApi.course.id);
|
||||
if (certRes?.data) {
|
||||
if (certRes.data.isNewCompletion) {
|
||||
updateCourseCompletionModal(courseApi.course.id, 'eligible', certRes.data, requiredExerciseId);
|
||||
} else {
|
||||
updateCourseCompletionModal(courseApi.course.id, 'not-eligible', certRes.data, requiredExerciseId);
|
||||
}
|
||||
const hasCompletedCourse = Boolean(certRes.data.eligibleForCertificate || certRes.data.certificateEarnedAt);
|
||||
updateCourseCompletionModal(
|
||||
courseApi.course.id,
|
||||
hasCompletedCourse ? 'eligible' : 'not-eligible',
|
||||
certRes.data,
|
||||
requiredExerciseId
|
||||
);
|
||||
} else {
|
||||
closeCourseCompletionModal();
|
||||
}
|
||||
@@ -288,6 +317,12 @@
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function formatPercent(value: number) {
|
||||
return new Intl.NumberFormat(undefined, {
|
||||
maximumFractionDigits: 1
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
function getAutoSavedData() {
|
||||
isLoadingAutoSavedData = true;
|
||||
|
||||
@@ -606,6 +641,24 @@
|
||||
);
|
||||
const canGoNextForSharedQuestion = $derived(hasAnswerValue(sharedCurrentAnswer));
|
||||
const sectionQuestionTotalPoints = $derived(getTotalPossibleGrade(currentSectionQuestions));
|
||||
const passThresholdPercent = $derived(Number($questionnaire.passThreshold ?? 100));
|
||||
const gradedScorePercent = $derived.by(() => {
|
||||
const finalGrade = Number($questionnaireMetaData.finalTotalGrade);
|
||||
const totalGrade = Number($questionnaireMetaData.totalPossibleGrade);
|
||||
if (!Number.isFinite(finalGrade) || !Number.isFinite(totalGrade) || totalGrade <= 0) return null;
|
||||
|
||||
return (finalGrade / totalGrade) * 100;
|
||||
});
|
||||
const shouldShowPassedCompletionResult = $derived(
|
||||
($questionnaire.completionPolicy ?? 'submitted') === 'passed' &&
|
||||
STATUS.GRADED === $questionnaireMetaData.status &&
|
||||
gradedScorePercent !== null
|
||||
);
|
||||
const didCurrentAttemptPass = $derived(
|
||||
shouldShowPassedCompletionResult && gradedScorePercent !== null && gradedScorePercent >= passThresholdPercent
|
||||
);
|
||||
const gradedScorePercentLabel = $derived(gradedScorePercent === null ? '' : formatPercent(gradedScorePercent));
|
||||
const passThresholdPercentLabel = $derived(formatPercent(passThresholdPercent));
|
||||
const sectionNavigationGroups = $derived(
|
||||
activeSections.map((section, sectionIndex) => {
|
||||
const sectionQuestions = getQuestionsForSection($questionnaire.questions, section.id);
|
||||
@@ -666,15 +719,15 @@
|
||||
});
|
||||
|
||||
function goPrevTry() {
|
||||
if (selectedTryIndex <= 0) return;
|
||||
selectedTryIndex -= 1;
|
||||
if (viewedAttemptIndex <= 0) return;
|
||||
selectedTryIndex = viewedAttemptIndex - 1;
|
||||
const sub = submissionList[selectedTryIndex];
|
||||
if (sub) applyMySubmission(sub);
|
||||
}
|
||||
|
||||
function goNextTry() {
|
||||
if (selectedTryIndex >= submissionList.length - 1) return;
|
||||
selectedTryIndex += 1;
|
||||
if (viewedAttemptIndex >= submissionList.length - 1) return;
|
||||
selectedTryIndex = viewedAttemptIndex + 1;
|
||||
const sub = submissionList[selectedTryIndex];
|
||||
if (sub) applyMySubmission(sub);
|
||||
}
|
||||
@@ -725,6 +778,7 @@
|
||||
alreadyCheckedAutoSavedData = false;
|
||||
skipHydrateFromSubmissions = false;
|
||||
selectedTryIndex = -1;
|
||||
localSubmissions = [];
|
||||
}
|
||||
prevExerciseId = exerciseId;
|
||||
});
|
||||
@@ -842,6 +896,43 @@
|
||||
<span class="text-xl">{$questionnaireMetaData.totalPossibleGrade}</span>
|
||||
</p>
|
||||
</div>
|
||||
{#if shouldShowPassedCompletionResult}
|
||||
<Alert.Root variant={didCurrentAttemptPass ? 'information' : 'warning'} class="h-fit! w-full! flex-1!">
|
||||
<div class="col-start-2 flex w-full flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="min-w-0 space-y-1">
|
||||
<p class="font-medium">
|
||||
{didCurrentAttemptPass
|
||||
? $t('course.navItem.lessons.exercises.all_exercises.view_mode.passed_completion_result_title')
|
||||
: $t(
|
||||
'course.navItem.lessons.exercises.all_exercises.view_mode.not_passed_completion_result_title'
|
||||
)}
|
||||
</p>
|
||||
<p class="ui:text-muted-foreground text-sm">
|
||||
{didCurrentAttemptPass
|
||||
? $t(
|
||||
'course.navItem.lessons.exercises.all_exercises.view_mode.passed_completion_result_description',
|
||||
{
|
||||
scorePercent: gradedScorePercentLabel,
|
||||
requiredPercent: passThresholdPercentLabel
|
||||
}
|
||||
)
|
||||
: $t(
|
||||
'course.navItem.lessons.exercises.all_exercises.view_mode.not_passed_completion_result_description',
|
||||
{
|
||||
scorePercent: gradedScorePercentLabel,
|
||||
requiredPercent: passThresholdPercentLabel
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{#if $questionnaire.allowMultipleAttempts && !didCurrentAttemptPass}
|
||||
<Button type="button" variant="outline" onclick={tryAgain} class="shrink-0">
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.try_again')}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</Alert.Root>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else if isSelfPacedLikeCourse(courseApi.course?.type)}
|
||||
@@ -861,7 +952,7 @@
|
||||
{#if submissionList.length > 1}
|
||||
<div class="mb-4 flex flex-wrap items-center gap-2">
|
||||
<IconButton
|
||||
disabled={selectedTryIndex <= 0}
|
||||
disabled={viewedAttemptIndex <= 0}
|
||||
onclick={goPrevTry}
|
||||
tooltip={$t('course.navItem.lessons.exercises.all_exercises.view_mode.previous_try')}
|
||||
>
|
||||
@@ -869,12 +960,12 @@
|
||||
</IconButton>
|
||||
<span class="ui:text-muted-foreground text-sm">
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.attempt_counter', {
|
||||
current: selectedTryIndex + 1,
|
||||
current: viewedAttemptIndex + 1,
|
||||
total: submissionList.length
|
||||
})}
|
||||
</span>
|
||||
<IconButton
|
||||
disabled={selectedTryIndex >= submissionList.length - 1}
|
||||
disabled={viewedAttemptIndex >= submissionList.length - 1}
|
||||
onclick={goNextTry}
|
||||
tooltip={$t('course.navItem.lessons.exercises.all_exercises.view_mode.next_try')}
|
||||
>
|
||||
@@ -894,7 +985,7 @@
|
||||
/>
|
||||
|
||||
<RoleBasedSecurity allowedRoles={[3]}>
|
||||
{#if $questionnaire.allowMultipleAttempts}
|
||||
{#if $questionnaire.allowMultipleAttempts && !shouldShowPassedCompletionResult}
|
||||
<div class="mt-4">
|
||||
<Button type="button" variant="secondary" onclick={tryAgain}>
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.try_again')}
|
||||
|
||||
+7
-5
@@ -144,11 +144,13 @@
|
||||
|
||||
const certRes = await courseApi.getCertificationEvaluation(courseId);
|
||||
if (certRes?.data) {
|
||||
if (certRes.data.isNewCompletion) {
|
||||
updateCourseCompletionModal(courseId, 'eligible', certRes.data, requiredExerciseId);
|
||||
} else {
|
||||
updateCourseCompletionModal(courseId, 'not-eligible', certRes.data, requiredExerciseId);
|
||||
}
|
||||
const hasCompletedCourse = Boolean(certRes.data.eligibleForCertificate || certRes.data.certificateEarnedAt);
|
||||
updateCourseCompletionModal(
|
||||
courseId,
|
||||
hasCompletedCourse ? 'eligible' : 'not-eligible',
|
||||
certRes.data,
|
||||
requiredExerciseId
|
||||
);
|
||||
} else {
|
||||
closeCourseCompletionModal();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { InputField } from '@cio/ui/custom/input-field';
|
||||
import TrashIcon from '@lucide/svelte/icons/trash';
|
||||
import TriangleAlertIcon from '@lucide/svelte/icons/triangle-alert';
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
import * as Tooltip from '@cio/ui/base/tooltip';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
interface Props {
|
||||
@@ -11,7 +13,7 @@
|
||||
points?: any;
|
||||
hasError?: boolean;
|
||||
errorMsg?: string | null;
|
||||
/** Extra hint under points row (e.g. auto-grade requires non-zero points) */
|
||||
/** Hint shown next to the points input while points is 0 (e.g. auto-grade requires non-zero points) */
|
||||
pointsHint?: string | null;
|
||||
onPointsChange?: any;
|
||||
elementId?: string;
|
||||
@@ -34,6 +36,10 @@
|
||||
|
||||
let ref: HTMLDivElement | undefined = $state();
|
||||
|
||||
// `points` is often bound to a plain store object property, which is not deeply
|
||||
// reactive — a local reactive owner keeps the zero-points warning live while typing.
|
||||
let pointsValue = $state(points);
|
||||
|
||||
$effect(() => {
|
||||
if (ref && scrollToQuestion) {
|
||||
ref.scrollIntoView({
|
||||
@@ -65,15 +71,34 @@
|
||||
</p>
|
||||
<InputField
|
||||
placeholder={$t('course.navItem.lessons.exercises.new_exercise_modal.points')}
|
||||
bind:value={points}
|
||||
bind:value={
|
||||
() => pointsValue,
|
||||
(nextValue) => {
|
||||
pointsValue = nextValue;
|
||||
points = nextValue;
|
||||
}
|
||||
}
|
||||
type="number"
|
||||
onchange={onPointsChange}
|
||||
/>
|
||||
|
||||
{#if Number(pointsValue) === 0}
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger class="ml-2 shrink-0">
|
||||
<TriangleAlertIcon size={16} class="text-amber-500 dark:text-amber-400" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side="top" sideOffset={4}>
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.zero_points_warning')}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if errorMsg}
|
||||
<p class="text-xs text-red-500">{errorMsg}</p>
|
||||
{:else if pointsHint}
|
||||
{:else if pointsHint && Number(pointsValue) === 0}
|
||||
<p class="ui:text-muted-foreground max-w-[min(100%,12rem)] text-xs">{pointsHint}</p>
|
||||
{/if}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user