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:
Rotimi Best
2026-07-15 03:26:56 +01:00
committed by GitHub
co-authored by Rotimi Best
parent fdc19aac41
commit bfafc685e0
53 changed files with 2026 additions and 285 deletions
@@ -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}
@@ -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 } : {})
};
});
}
@@ -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
}}
@@ -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}
@@ -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')}
@@ -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}
@@ -59,9 +59,17 @@
} from '$features/course/components/exercise/question-type-utils';
import type { SubmissionListItem } from '$features/course/utils/types';
import { Badge } from '@cio/ui/base/badge';
import * as Alert from '@cio/ui/base/alert';
import * as RadioGroup from '@cio/ui/base/radio-group';
import InfoIcon from '@lucide/svelte/icons/info';
import { isAutoGradableQuestionType } from '@cio/question-types';
import * as Dialog from '@cio/ui/base/dialog';
import type { ExerciseSectionState } from '$features/course/components/exercise/store';
import {
getActiveExerciseQuestions,
getManualGradedExerciseQuestions,
getTotalPossibleExercisePoints
} from '$features/course/utils/exercise-progression-utils';
interface Props {
exerciseId?: string;
@@ -100,6 +108,26 @@
let selectedTab = $state<ExerciseTab>('questions');
// eslint-disable-next-line svelte/prefer-writable-derived -- must be writable: cleared before intentional navigations and bound to UnsavedChanges
let hasUnsavedChanges = $state(false);
type PassedPolicySaveAction = 'cancel' | 'save';
type PassedPolicyAttemptChoice = 'retry' | 'single';
type PassedPolicyDialogState = {
open: boolean;
manualQuestions: ReturnType<typeof getManualGradedExerciseQuestions>;
hasManualWarning: boolean;
hasSingleAttemptWarning: boolean;
resolve: ((action: PassedPolicySaveAction) => void) | null;
};
let passedPolicyDialog = $state<PassedPolicyDialogState>({
open: false,
manualQuestions: [],
hasManualWarning: false,
hasSingleAttemptWarning: false,
resolve: null
});
let acknowledgedPassedPolicyReviewSignature = $state<string | null>(null);
let passedPolicyAttemptChoice = $state<PassedPolicyAttemptChoice>('retry');
const passedPolicyReviewStoragePrefix = 'classroomio:passed-policy-review';
function isTemporaryId(id: string | number | undefined) {
return typeof id === 'string' && id.includes('-form');
@@ -167,6 +195,150 @@
return sectionErrors;
}
function getQuestionDisplayTitle(
question: ReturnType<typeof getManualGradedExerciseQuestions>[number],
index: number
) {
return (
question.title?.trim() ||
t.get('course.navItem.lessons.exercises.all_exercises.guard.question_fallback', {
number: index + 1
})
);
}
function setPassedPolicyPointValidationErrors() {
const activeQuestions = getActiveExerciseQuestions($questionnaire.questions ?? []);
const pointErrors: ExerciseEditorErrors = {};
for (const question of activeQuestions) {
pointErrors[question.id] = {
points: t.get('course.navItem.lessons.exercises.all_exercises.guard.zero_points_field')
};
}
questionnaireValidation.set(pointErrors);
snackbar.error('snackbar.exercise.passed_requires_points');
}
function getPassedPolicyReviewStorageKey() {
return `${passedPolicyReviewStoragePrefix}:${courseApi.course?.id ?? 'course'}:${exerciseId}`;
}
function getPassedPolicyReviewSignature(params: {
manualQuestions: ReturnType<typeof getManualGradedExerciseQuestions>;
hasManualWarning: boolean;
hasSingleAttemptWarning: boolean;
}) {
const manualQuestionProfile = params.manualQuestions
.map((question) => String(getQuestionTypeId(question)))
.sort()
.join(',');
return [
'v1',
`manual:${params.hasManualWarning ? manualQuestionProfile : 'none'}`,
`single_attempt:${params.hasSingleAttemptWarning ? 'yes' : 'no'}`
].join('|');
}
function hasAcknowledgedPassedPolicyReview(signature: string) {
if (acknowledgedPassedPolicyReviewSignature === signature) return true;
try {
const storedSignatures = localStorage.getItem(getPassedPolicyReviewStorageKey())?.split('\n') ?? [];
return storedSignatures.includes(signature);
} catch {
return false;
}
}
function acknowledgePassedPolicyReview(signature: string) {
acknowledgedPassedPolicyReviewSignature = signature;
try {
const storedSignatures = localStorage.getItem(getPassedPolicyReviewStorageKey())?.split('\n') ?? [];
const nextSignatures = new Set([...storedSignatures, signature].filter(Boolean));
localStorage.setItem(getPassedPolicyReviewStorageKey(), [...nextSignatures].join('\n'));
} catch {
// localStorage may be full or unavailable
}
}
function openPassedPolicyDialog(params: {
manualQuestions: ReturnType<typeof getManualGradedExerciseQuestions>;
hasManualWarning: boolean;
hasSingleAttemptWarning: boolean;
}) {
passedPolicyAttemptChoice = 'retry';
return new Promise<PassedPolicySaveAction>((resolve) => {
passedPolicyDialog = {
...params,
open: true,
resolve
};
});
}
function closePassedPolicyDialog(action: PassedPolicySaveAction) {
const resolveDialog = passedPolicyDialog.resolve;
passedPolicyDialog = {
open: false,
manualQuestions: [],
hasManualWarning: false,
hasSingleAttemptWarning: false,
resolve: null
};
resolveDialog?.(action);
}
async function runPassedPolicySaveGuard() {
if (($questionnaire.completionPolicy ?? 'submitted') !== 'passed') return true;
const totalPossiblePoints = getTotalPossibleExercisePoints($questionnaire.questions ?? []);
if (totalPossiblePoints <= 0) {
setPassedPolicyPointValidationErrors();
return false;
}
const manualQuestions = getManualGradedExerciseQuestions($questionnaire.questions ?? []);
const hasManualWarning = manualQuestions.length > 0;
const hasSingleAttemptWarning = !$questionnaire.allowMultipleAttempts;
if (!hasManualWarning && !hasSingleAttemptWarning) return true;
const passedPolicyReviewSignature = getPassedPolicyReviewSignature({
manualQuestions,
hasManualWarning,
hasSingleAttemptWarning
});
if (hasAcknowledgedPassedPolicyReview(passedPolicyReviewSignature)) {
return true;
}
const action = await openPassedPolicyDialog({
manualQuestions,
hasManualWarning,
hasSingleAttemptWarning
});
if (action === 'cancel') return false;
if (hasSingleAttemptWarning && passedPolicyAttemptChoice === 'retry') {
questionnaire.update((state) => ({ ...state, allowMultipleAttempts: true }));
const retryEnabledSignature = getPassedPolicyReviewSignature({
manualQuestions,
hasManualWarning,
hasSingleAttemptWarning: false
});
acknowledgePassedPolicyReview(retryEnabledSignature);
}
acknowledgePassedPolicyReview(passedPolicyReviewSignature);
return true;
}
async function handleSave() {
if ($isOrgStudent || !courseApi.course?.id) return;
@@ -226,6 +398,9 @@
}
}
const passedPolicyCanSave = await runPassedPolicySaveGuard();
if (!passedPolicyCanSave) return;
isSaving = true;
reset();
@@ -236,13 +411,20 @@
shouldIncludeDeleted: true
});
const allowMultipleAttempts = !!$questionnaire.allowMultipleAttempts;
const completionPolicy = $questionnaire.completionPolicy ?? 'submitted';
const passThreshold = $questionnaire.passThreshold ?? 100;
await exerciseApi.update(courseApi.course?.id, exerciseId, {
title: $questionnaire.title ?? '',
description: $questionnaire.description ?? '',
dueBy: $questionnaire.dueBy ?? '',
questions: questions,
sections: shouldSyncSections ? sectionsPayload : undefined,
sectionDisplayMode: $questionnaire.sectionDisplayMode
sectionDisplayMode: $questionnaire.sectionDisplayMode,
allowMultipleAttempts,
completionPolicy,
passThreshold
});
// Check if there are validation errors from the API
@@ -575,7 +757,11 @@
{/if}
</UnderlineTabs.Content>
<UnderlineTabs.Content value="settings">
<ExerciseSettingsTab {exerciseId} />
<ExerciseSettingsTab
{exerciseId}
onBeforeSave={runPassedPolicySaveGuard}
totalSubmissions={submissions.length}
/>
</UnderlineTabs.Content>
<UnderlineTabs.Content value="submissions">
<Submissions bind:exerciseId {submissions} />
@@ -610,6 +796,107 @@
</form>
</Dialog.Content>
</Dialog.Root>
<Dialog.Root
open={passedPolicyDialog.open}
onOpenChange={(isOpen) => {
if (!isOpen && passedPolicyDialog.open) closePassedPolicyDialog('cancel');
}}
>
<Dialog.Content class="max-h-[90dvh] w-[min(92vw,36rem)] overflow-y-auto">
<Dialog.Header>
<Dialog.Title>
{passedPolicyDialog.hasSingleAttemptWarning
? $t('course.navItem.lessons.exercises.all_exercises.guard.attempts_title')
: $t('course.navItem.lessons.exercises.all_exercises.guard.title')}
</Dialog.Title>
<Dialog.Description>
{$t('course.navItem.lessons.exercises.all_exercises.guard.description')}
</Dialog.Description>
</Dialog.Header>
<div class="space-y-4">
{#if passedPolicyDialog.hasSingleAttemptWarning}
<RadioGroup.Root
value={passedPolicyAttemptChoice}
onValueChange={(value) => {
if (value === 'retry' || value === 'single') {
passedPolicyAttemptChoice = value;
}
}}
class="gap-3"
>
<label
for="passed-policy-retry"
class="flex cursor-pointer items-start gap-3 rounded-md border p-3 {passedPolicyAttemptChoice ===
'retry'
? 'ui:border-primary'
: ''}"
>
<RadioGroup.Item value="retry" id="passed-policy-retry" class="mt-0.5" />
<div class="flex flex-col gap-1">
<span class="flex flex-wrap items-center gap-2 text-sm font-medium">
{$t('course.navItem.lessons.exercises.all_exercises.guard.retry_option')}
<Badge variant="secondary">
{$t('course.navItem.lessons.exercises.all_exercises.guard.recommended')}
</Badge>
</span>
<span class="ui:text-muted-foreground text-sm">
{$t('course.navItem.lessons.exercises.all_exercises.guard.retry_option_description')}
</span>
</div>
</label>
<label
for="passed-policy-single"
class="flex cursor-pointer items-start gap-3 rounded-md border p-3 {passedPolicyAttemptChoice ===
'single'
? 'ui:border-primary'
: ''}"
>
<RadioGroup.Item value="single" id="passed-policy-single" class="mt-0.5" />
<div class="flex flex-col gap-1">
<span class="text-sm font-medium">
{$t('course.navItem.lessons.exercises.all_exercises.guard.single_attempt_option')}
</span>
<span class="ui:text-muted-foreground text-sm">
{$t('course.navItem.lessons.exercises.all_exercises.guard.single_attempt_option_description')}
</span>
</div>
</label>
</RadioGroup.Root>
{/if}
{#if passedPolicyDialog.hasManualWarning}
<Alert.Root variant="information">
<InfoIcon />
<Alert.Title>
{$t('course.navItem.lessons.exercises.all_exercises.guard.manual_notice_title')}
</Alert.Title>
<Alert.Description>
{$t('course.navItem.lessons.exercises.all_exercises.guard.manual_notice_description')}
<ul class="list-disc pl-5">
{#each passedPolicyDialog.manualQuestions as question, index (question.id)}
<li>{getQuestionDisplayTitle(question, index)}</li>
{/each}
</ul>
</Alert.Description>
</Alert.Root>
{/if}
</div>
<div class="mt-6 flex justify-end gap-2">
<Button variant="outline" type="button" onclick={() => closePassedPolicyDialog('cancel')}>
{$t('course.navItem.lessons.exercises.all_exercises.guard.cancel')}
</Button>
<Button type="button" onclick={() => closePassedPolicyDialog('save')}>
{passedPolicyDialog.hasSingleAttemptWarning
? $t('course.navItem.lessons.exercises.all_exercises.guard.save_continue')
: $t('course.navItem.lessons.exercises.all_exercises.guard.save_anyway')}
</Button>
</div>
</Dialog.Content>
</Dialog.Root>
{/if}
</div>
{/snippet}
@@ -21,15 +21,18 @@
import { settings } from '$features/course/utils/settings-store';
import Copy from '@lucide/svelte/icons/copy';
import * as Alert from '@cio/ui/base/alert';
import LockOpenIcon from '@lucide/svelte/icons/lock-open';
import type { TCourseType } from '@cio/db/types';
import type { Course } from '../utils/types';
import { ContentType } from '@cio/utils/constants/content';
import { t } from '$lib/utils/functions/translations';
import { isObject } from '$lib/utils/functions/isObject';
import { snackbar } from '$features/ui/snackbar/store';
import { generateSlug } from '@cio/utils/functions';
import { DEFAULT_COMPLIANCE_SETTINGS } from '../utils/compliance-utils';
import { DeleteModal } from '$features/ui';
import { courseApi } from '$features/course/api';
import { contentApi, courseApi } from '$features/course/api';
import { tagApi } from '$features/tag/api';
import { uploadImage } from '$lib/utils/services/upload';
import { copyToClipboard } from '$lib/utils/functions/formatYoutubeVideo';
@@ -115,6 +118,49 @@
hasUnsavedChanges = true;
};
let isUnlockingAll = $state(false);
type LockedContentItem = { id: string; type: ContentType.Lesson | ContentType.Exercise };
function collectLockedContentItems(course: Course | null): LockedContentItem[] {
const content = course?.content;
if (!content) return [];
const items = content.grouped ? content.sections.flatMap((section) => section.items) : content.items;
return items
.filter((item) => (item.isUnlocked ?? true) === false)
.map((item) => ({
id: item.id,
type: item.type === ContentType.Exercise ? ContentType.Exercise : ContentType.Lesson
}));
}
const lockedContentItems = $derived(collectLockedContentItems(courseApi.course));
const isLiveClassCourse = $derived(courseApi.course?.type === 'LIVE_CLASS');
const showLockedContentNotice = $derived($settings.isPublished && lockedContentItems.length > 0);
async function handleUnlockAllContent() {
const courseId = courseApi.course?.id;
if (!courseId || lockedContentItems.length === 0 || isUnlockingAll) return;
isUnlockingAll = true;
const itemsToUnlock = [...lockedContentItems];
const didUnlock = await contentApi.updateContent(
courseId,
itemsToUnlock.map((item) => ({ id: item.id, type: item.type, isUnlocked: true }))
);
if (didUnlock) {
for (const item of itemsToUnlock) {
courseApi.updateContentItem(item.id, item.type, { isUnlocked: true });
}
snackbar.success('snackbar.course_settings.success.unlocked_all_content');
}
isUnlockingAll = false;
}
async function handleDeleteCourse() {
if (!courseApi.course) return;
@@ -822,6 +868,30 @@
{$settings.isPublished ? $t('course.navItem.settings.published') : $t('course.navItem.settings.unpublished')}
</Label>
</Field.Field>
{#if showLockedContentNotice}
<Alert.Root variant={isLiveClassCourse ? 'information' : 'warning'}>
<LockOpenIcon />
<Alert.Title>
{$t('course.navItem.settings.locked_content.title', { count: lockedContentItems.length })}
</Alert.Title>
<Alert.Description>
{isLiveClassCourse
? $t('course.navItem.settings.locked_content.description_live')
: $t('course.navItem.settings.locked_content.description')}
<Button
variant="outline"
size="sm"
class="mt-2 w-fit"
loading={isUnlockingAll}
disabled={isUnlockingAll}
onclick={handleUnlockAllContent}
>
{$t('course.navItem.settings.locked_content.unlock_all')}
</Button>
</Alert.Description>
</Alert.Root>
{/if}
</Field.Set>
<Field.Separator />
@@ -215,7 +215,7 @@
<div class="flex items-center overflow-x-scroll">
{#each sections as { id, title, items }, idx (id)}
<div
class="section ui:bg-muted mr-3 h-80 overflow-hidden rounded-md p-3"
class="section ui:bg-muted ui:border-border mr-3 h-80 overflow-hidden rounded-md border p-3"
animate:flip={{ duration: flipDurationMs }}
>
<div class="mb-2 flex items-center">
@@ -235,8 +235,8 @@
{#each items as item (item.id)}
<div
class="{item.isEarly
? 'border-none'
: 'border border-red-700'} mx-0 my-2 w-full rounded-md bg-white px-3 py-3 dark:bg-neutral-800"
? 'ui:border-border'
: 'border-red-700'} ui:bg-card mx-0 my-2 w-full rounded-md border px-3 py-3 shadow-sm"
animate:flip={{ duration: flipDurationMs }}
>
<a
@@ -0,0 +1,141 @@
import { describe, expect, it } from 'vitest';
import type { Question } from '$features/course/types';
import type { SubmissionListItem } from '$features/course/utils/types';
import {
getManualGradedExerciseQuestions,
getTotalPossibleExercisePoints,
groupSubmissionsByStudentAndAttempt,
shouldCompleteExerciseFromSubmission
} from './exercise-progression-utils';
function makeQuestion(overrides: Partial<Question>): Question {
return {
id: overrides.id ?? 1,
title: overrides.title ?? 'Question',
points: overrides.points ?? 1,
questionTypeId: overrides.questionTypeId ?? 1,
questionType: overrides.questionType,
options: [],
...overrides
} as Question;
}
function makeSubmission(overrides: Partial<SubmissionListItem> & { id: string }): SubmissionListItem {
return {
id: overrides.id,
statusId: overrides.statusId ?? 1,
gradingState: overrides.gradingState,
total: overrides.total ?? 0,
createdAt: overrides.createdAt ?? '2026-01-01T00:00:00.000Z',
groupmember: overrides.groupmember ?? {
profile: {
id: 'student-1',
fullname: 'Student One',
avatarUrl: ''
}
},
answers: overrides.answers ?? []
} as unknown as SubmissionListItem;
}
describe('exercise progression helpers', () => {
it('detects manual-graded questions from question type ids', () => {
const questions = [
makeQuestion({ id: 1, questionTypeId: 1 }),
makeQuestion({ id: 2, questionTypeId: 3 }),
makeQuestion({ id: 3, questionTypeId: 8 })
];
expect(getManualGradedExerciseQuestions(questions).map((question) => question.id)).toEqual([2, 3]);
});
it('calculates total possible points from active questions', () => {
const questions = [
makeQuestion({ id: 1, points: 3 }),
makeQuestion({ id: 2, points: 2 }),
makeQuestion({ id: 3, points: 20, deletedAt: '2026-01-01' })
];
expect(getTotalPossibleExercisePoints(questions)).toBe(5);
});
it('decides when a submission completes a passed-policy exercise', () => {
const passingSubmission = makeSubmission({
id: 'passing',
gradingState: 'completed',
total: 8
});
const failingSubmission = makeSubmission({
id: 'failing',
gradingState: 'completed',
total: 6
});
const awaitingManualSubmission = makeSubmission({
id: 'manual',
gradingState: 'awaiting_manual',
total: 10
});
expect(
shouldCompleteExerciseFromSubmission({
completionPolicy: 'passed',
passThreshold: 70,
totalPossiblePoints: 10,
submission: passingSubmission
})
).toBe(true);
expect(
shouldCompleteExerciseFromSubmission({
completionPolicy: 'passed',
passThreshold: 70,
totalPossiblePoints: 10,
submission: failingSubmission
})
).toBe(false);
expect(
shouldCompleteExerciseFromSubmission({
completionPolicy: 'passed',
passThreshold: 70,
totalPossiblePoints: 10,
submission: awaitingManualSubmission
})
).toBe(false);
});
it('treats submitted-policy submissions as complete regardless of grading state', () => {
const submission = makeSubmission({
id: 'submitted',
gradingState: 'awaiting_manual',
total: 0
});
expect(
shouldCompleteExerciseFromSubmission({
completionPolicy: 'submitted',
passThreshold: 100,
totalPossiblePoints: 0,
submission
})
).toBe(true);
});
it('groups submissions by student and orders attempts by submission date', () => {
const olderAttempt = makeSubmission({
id: 'attempt-1',
total: 4,
createdAt: '2026-01-01T00:00:00.000Z'
});
const newerAttempt = makeSubmission({
id: 'attempt-2',
total: 9,
createdAt: '2026-01-02T00:00:00.000Z'
});
const groups = groupSubmissionsByStudentAndAttempt([newerAttempt, olderAttempt]);
expect(groups).toHaveLength(1);
expect(groups[0].attempts.map((attempt) => attempt.submission.id)).toEqual(['attempt-1', 'attempt-2']);
expect(groups[0].attempts.map((attempt) => attempt.attemptNumber)).toEqual([1, 2]);
});
});
@@ -0,0 +1,106 @@
import { isAutoGradableQuestionTypeId } from '@cio/question-types';
import type { Question } from '$features/course/types';
import type { SubmissionListItem } from '$features/course/utils/types';
export type ExerciseCompletionPolicy = 'submitted' | 'passed';
export type ExerciseAttempt = {
attemptNumber: number;
submission: SubmissionListItem;
};
export type ExerciseSubmissionStudentGroup = {
studentKey: string;
studentName: string;
studentAvatarUrl: string;
attempts: ExerciseAttempt[];
};
export function getActiveExerciseQuestions(questions: Question[]): Question[] {
return questions.filter((question) => !question.deletedAt);
}
export function getTotalPossibleExercisePoints(questions: Question[]): number {
return getActiveExerciseQuestions(questions).reduce((total, question) => total + Number(question.points ?? 0), 0);
}
export function getManualGradedExerciseQuestions(questions: Question[]): Question[] {
return getActiveExerciseQuestions(questions).filter((question) => {
const questionTypeId = Number(question.questionTypeId ?? question.questionType?.id);
if (!Number.isFinite(questionTypeId)) return true;
return !isAutoGradableQuestionTypeId(questionTypeId);
});
}
function getSubmissionScore(submission: SubmissionListItem): number {
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);
}
export function shouldCompleteExerciseFromSubmission(params: {
completionPolicy: ExerciseCompletionPolicy | null | undefined;
passThreshold: number | null | undefined;
totalPossiblePoints: number;
submission: SubmissionListItem & { gradingState?: string | null };
}): boolean {
if ((params.completionPolicy ?? 'submitted') === 'submitted') return true;
if (params.submission.gradingState !== 'completed') return false;
if (params.totalPossiblePoints <= 0) return false;
const threshold = Number(params.passThreshold ?? 100);
const scorePercent = (getSubmissionScore(params.submission) / params.totalPossiblePoints) * 100;
return scorePercent >= threshold;
}
function getSubmissionTimestamp(submission: SubmissionListItem): number {
const createdAt = (submission as SubmissionListItem & { createdAt?: string | Date | null }).createdAt;
const timestamp = createdAt ? new Date(createdAt).getTime() : NaN;
return Number.isFinite(timestamp) ? timestamp : 0;
}
function getStudentKey(submission: SubmissionListItem): string {
const profile = submission.groupmember?.profile;
const firstAnswerGroupMemberId = submission.answers?.find((answer) => answer.groupMemberId)?.groupMemberId;
return String(profile?.id ?? firstAnswerGroupMemberId ?? submission.id);
}
export function groupSubmissionsByStudentAndAttempt(
submissionItems: SubmissionListItem[]
): ExerciseSubmissionStudentGroup[] {
const groupByStudent = new Map<string, SubmissionListItem[]>();
for (const submission of submissionItems) {
const studentKey = getStudentKey(submission);
const studentSubmissions = groupByStudent.get(studentKey) ?? [];
studentSubmissions.push(submission);
groupByStudent.set(studentKey, studentSubmissions);
}
return [...groupByStudent.entries()]
.map(([studentKey, studentSubmissions]) => {
const orderedSubmissions = [...studentSubmissions].sort(
(leftSubmission, rightSubmission) =>
getSubmissionTimestamp(leftSubmission) - getSubmissionTimestamp(rightSubmission)
);
const latestSubmission = orderedSubmissions[orderedSubmissions.length - 1];
const profile = latestSubmission?.groupmember?.profile;
return {
studentKey,
studentName: profile?.fullname ?? '',
studentAvatarUrl: profile?.avatarUrl ?? '',
attempts: orderedSubmissions.map((submission, index) => ({
attemptNumber: index + 1,
submission
}))
};
})
.sort((leftGroup, rightGroup) => leftGroup.studentName.localeCompare(rightGroup.studentName));
}