Files
classroomio/apps/dashboard/src/lib/features/course/pages/exercise.svelte
T
Rotimi BestandRotimi Best 55ae6dd4fe feat(dashboard): improve student course sidebar navigation (#814)
* docs(prototypes): add course sidebar navigation HTML prototypes

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* docs(prototypes): add downloadable zip for course sidebar mockups

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* feat(dashboard): improve student course sidebar navigation

Collapse all sections except the active one for students, show section
completion as counts (e.g. 3/4), and replace the footer progress card with
prev/next controls plus a thin progress bar.

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* fix(dashboard): address PR review and remove sidebar prototypes

Remove HTML prototype files from the PR. Match content IDs as exact path
segments for active-item resolution, and use content-neutral prev/next labels.

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* fix(dashboard): keep ClassroomIO brand untranslated in powered-by footer

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* fix(dashboard): restore progress summary in course sidebar footer

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* fix(dashboard): move progress bar below summary in sidebar footer

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* feat(course): add mobile student bottom nav with outline sheet

Implement prototype 3 mobile navigation for course learners: a fixed
bottom bar with progress ring, prev/next controls, and a sheet showing
the progress card plus collapsible course outline with scroll-to-active.

Extends public-course UI primitives (ring bottom nav, collapsible
sidebar sections, mobile sheet progress slot) and wires them into the
authenticated course layout for isCourseLearnerView on lesson/exercise
routes. Hides duplicate header prev/next and progress popover on mobile.

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* fix(course): address review feedback and exclude prototypes

- Add .coderabbit.yaml and .greptile/config.json to skip prototypes/**
- Document prototypes/ as out-of-scope in AGENTS.md and Greptile rules
- Render locked mobile outline rows without href navigation targets
- Replace nested IconButton in collapsible section trigger with chevron icon
- Reset public-course sidebar section collapse on every mobile sheet reopen

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

---------
2026-08-19 11:52:58 +01:00

938 lines
36 KiB
Svelte

<script lang="ts">
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import { Button } from '@cio/ui/base/button';
import * as ButtonGroup from '@cio/ui/base/button-group';
import * as UnderlineTabs from '@cio/ui/custom/underline-tabs';
import EyeIcon from '@lucide/svelte/icons/eye';
import PencilIcon from '@lucide/svelte/icons/pencil';
import { onDestroy, onMount, tick, untrack } from 'svelte';
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
import CirclePlusIcon from '@lucide/svelte/icons/circle-plus';
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
import MoreHorizontalIcon from '@lucide/svelte/icons/more-horizontal';
import * as Page from '@cio/ui/base/page';
import { ContentType } from '@cio/utils/constants/content';
import {
type ExerciseEditorErrors,
handleAddQuestion,
handleAddSection,
getQuestionsForSection,
hasSections,
questionnaire,
questionnaireOrder,
reset,
mapZodErrorsToQuestionErrors,
questionnaireValidation
} from '$features/course/components/exercise/store';
import { ZExerciseUpdate } from '@cio/utils/validation/exercise';
import { mapZodErrorsToTranslations } from '$lib/utils/validation';
import { transformQuestionsToApiFormat } from '$features/course/components/exercise/functions';
import { isOrgStudent, isCourseLearnerView, isStudentExperience } from '$lib/utils/store/app';
import { isMobileStore } from '@cio/ui/hooks/is-mobile.svelte';
import { getCourseProgress } from '$features/course/utils/content';
import { isCourseMobileBottomNavVisible } from '$features/course/utils/mobile-bottom-nav';
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$features/ui/snackbar/store';
import { exerciseApi } from '$features/course/api';
import { courseApi } from '$features/course/api';
import EditMode from '$features/course/components/exercise/edit-mode.svelte';
import ExerciseSettingsTab from '$features/course/components/exercise/exercise-settings-tab.svelte';
import ViewMode from '$features/course/components/exercise/view-mode.svelte';
import Submissions from '$features/course/components/exercise/submissions/submissions.svelte';
import UpdateDescription from '$features/course/components/exercise/update-description.svelte';
import { ContentNavigationActions } from '$features/course/components/lesson';
import {
// RefreshPageData,
RoleBasedSecurity,
UnsavedChanges
} from '$features/ui';
import { isSelfPacedLikeCourse } from '$features/course/utils/compliance-utils';
import { getOrderedNavigableContent } from '$features/course/utils/content';
import { saveExerciseDraft } from '$features/course/utils/exercise-draft';
import { onUpgradeCheckoutHandoff } from '$lib/utils/store/upgrade-modal';
import {
hydrateExercisePageData
// , refreshExercisePageData
} from '$features/course/utils/exercise-page-utils';
import StudentContentLockedNotice from '$features/course/components/student-content-locked-notice.svelte';
import {
getQuestionTypeId,
getQuestionTypeKeyFromId
} 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, isAutoGradableQuestionTypeId } 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;
goBack?: () => void;
isFetching?: boolean;
submissions: SubmissionListItem[];
mySubmissions?: SubmissionListItem[];
}
let {
exerciseId = $bindable(''),
goBack = () => {},
isFetching = false,
submissions,
mySubmissions = []
}: Props = $props();
const showMobileBottomNav = $derived(
isCourseMobileBottomNavVisible({
isCourseLearnerView: $isCourseLearnerView,
isMobile: isMobileStore.current,
isLessonOrExercisePage: Boolean(exerciseId),
courseProgress: getCourseProgress(courseApi.course)
})
);
type ExerciseTab = 'questions' | 'settings' | 'submissions';
function normalizeExerciseTab(tabParam: string | null): ExerciseTab {
if (tabParam === 'submissions' || tabParam === 'analytics') {
return 'submissions';
}
if (tabParam === 'settings') {
return 'settings';
}
return 'questions';
}
let preview: boolean = $state(false);
let shouldDeleteExercise = $state(false);
let isSaving = $state(false);
let isDeleting = $state(false);
let reorderQuestions = $state(false);
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');
}
function hasDirtyQuestionnaire() {
const hasDirtyQuestion = ($questionnaire.questions ?? []).some((question) => {
const hasDirtyOption = (question.options ?? []).some((option) => option.isDirty || isTemporaryId(option.id));
return question.isDirty || Boolean(question.deletedAt) || isTemporaryId(question.id) || hasDirtyOption;
});
const hasDirtySection = ($questionnaire.sections ?? []).some(
(section) => section.isDirty || Boolean(section.deletedAt)
);
return Boolean(
$questionnaire.isTitleDirty ||
$questionnaire.isDescriptionDirty ||
$questionnaire.isDueByDirty ||
hasDirtyQuestion ||
hasDirtySection
);
}
async function handleDeleteExercise() {
if (!courseApi.course?.id) return;
isDeleting = true;
await exerciseApi.delete(courseApi.course.id, exerciseId);
if (exerciseApi.success) {
hasUnsavedChanges = false;
courseApi.removeContentItem(exerciseId, ContentType.Exercise);
goBack();
}
isDeleting = false;
shouldDeleteExercise = false;
}
function patchExerciseListItemLocally() {
const nonDeletedQuestionsCount = ($questionnaire.questions || []).filter((question) => !question.deletedAt).length;
courseApi.updateContentItem(exerciseId, ContentType.Exercise, {
title: $questionnaire.title ?? '',
dueBy: $questionnaire.dueBy || null,
questionCount: nonDeletedQuestionsCount
});
}
function getSectionValidationErrors(sections: ExerciseSectionState[]): ExerciseEditorErrors {
const sectionErrors: ExerciseEditorErrors = {};
const requiredFieldMessage = t.get('validations.generic.required_field');
for (const section of sections) {
const nextSectionErrors: ExerciseEditorErrors[string] = {};
if (!section.title?.trim()) {
nextSectionErrors.title = requiredFieldMessage;
}
if (Object.keys(nextSectionErrors).length === 0) continue;
sectionErrors[section.id] = nextSectionErrors;
}
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;
// Transform questionnaire to API format for validation
// Include all non-deleted options (even empty ones) so Zod can catch validation errors
const transformedQuestions = transformQuestionsToApiFormat($questionnaire.questions, {
shouldFilterEmptyLabels: false
});
const activeSections = $questionnaire.sections.filter((section) => !section.deletedAt);
const sectionsPayload = activeSections.map((section) => ({
id: section.id,
title: section.title,
description: section.description,
order: section.order,
colorTheme: section.colorTheme,
afterBehavior: section.afterBehavior,
questionIds: getQuestionsForSection($questionnaire.questions, section.id).map((question) => question.id)
}));
const shouldSyncSections = $questionnaire.sections.length > 0 || hasSections($questionnaire.sections);
const sectionValidationErrors = getSectionValidationErrors(activeSections);
// Validate using Zod schema
const zodResult = ZExerciseUpdate.safeParse({
questions: transformedQuestions,
sections: shouldSyncSections ? sectionsPayload : undefined,
sectionDisplayMode: $questionnaire.sectionDisplayMode
});
if (!zodResult.success || Object.keys(sectionValidationErrors).length > 0) {
const zodErrors = !zodResult.success ? mapZodErrorsToTranslations(zodResult.error, 'exercise') : {};
const questionErrors = mapZodErrorsToQuestionErrors(zodErrors, $questionnaire.questions);
questionnaireValidation.set({
...questionErrors,
...sectionValidationErrors
});
snackbar.error('snackbar.exercise.validation_errors');
return;
}
if (requiresPositivePointsForAutoGrade) {
const zeroPointQuestions = ($questionnaire.questions ?? []).filter(
(question) =>
!question.deletedAt &&
Number(question.points) === 0 &&
isAutoGradableQuestionTypeId(getQuestionTypeId(question))
);
if (zeroPointQuestions.length > 0) {
const pointErrors: Record<string, { option?: string; title?: string; points?: string }> = {};
for (const q of zeroPointQuestions) {
pointErrors[q.id] = {
points: t.get('course.navItem.lessons.exercises.all_exercises.points_required_auto_grade')
};
}
questionnaireValidation.set(pointErrors);
snackbar.error('snackbar.exercise.points_required_auto_grade');
return;
}
}
const passedPolicyCanSave = await runPassedPolicySaveGuard();
if (!passedPolicyCanSave) return;
isSaving = true;
reset();
try {
// Transform questionnaire to API format (filter empty options for API, include deleted items)
const questions = transformQuestionsToApiFormat($questionnaire.questions, {
shouldFilterEmptyLabels: true,
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,
allowMultipleAttempts,
completionPolicy,
passThreshold
});
// Check if there are validation errors from the API
if (Object.keys(exerciseApi.errors || {}).length > 0) {
console.log('Validation errors from API:', exerciseApi.errors);
snackbar.error('snackbar.exercise.validation_errors');
isSaving = false;
return;
}
if (exerciseApi.success) {
if (exerciseApi.exercise) {
hydrateExercisePageData(exerciseApi.exercise, exerciseId);
}
hasUnsavedChanges = false;
patchExerciseListItemLocally();
snackbar.success('snackbar.exercise.success');
}
} catch {
snackbar.error();
}
isSaving = false;
}
onDestroy(() => {
reset();
questionnaire.update((q) => ({ ...q, questions: [] }));
});
onMount(() => {
selectedTab = normalizeExerciseTab(page.url.searchParams.get('tab'));
});
// Upgrading from a premium question type sends the browser to an external checkout. Stash the
// editor so it can be restored on the way back, and stand the leave guard down — with the work
// persisted there is nothing to warn about.
onMount(() =>
onUpgradeCheckoutHandoff(() => {
const courseId = courseApi.course?.id;
if ($isOrgStudent || !courseId || !hasDirtyQuestionnaire()) return;
saveExerciseDraft(courseId, exerciseId, $questionnaire);
hasUnsavedChanges = false;
})
);
$effect(() => {
const nextTab = normalizeExerciseTab(page.url.searchParams.get('tab'));
if (nextTab === untrack(() => selectedTab)) return;
selectedTab = nextTab;
});
$effect(() => {
hasUnsavedChanges = !$isOrgStudent && hasDirtyQuestionnaire();
});
$effect(() => {
const hasActiveSections = hasSections($questionnaire.sections);
if (!hasActiveSections && reorderQuestions) {
reorderQuestions = false;
}
});
$effect(() => {
const currentTab = page.url.searchParams.get('tab') ?? '';
// Prevent self-navigation loops: only update URL when it actually changes.
if (currentTab === selectedTab) return;
untrack(() => {
const url = new URL(page.url);
url.searchParams.set('tab', selectedTab);
goto(resolve(`${url.pathname}${url.search}`, {}), {
replaceState: true,
keepFocus: true,
noScroll: true
});
});
});
$effect(() => {
if ($isOrgStudent) return;
const addNewQ = $questionnaire?.questions?.length < 1;
if (addNewQ) {
untrack(() => {
handleAddQuestion();
});
}
});
const exerciseContentItem = $derived(
getOrderedNavigableContent(courseApi.course).find(
(item) => item.type === ContentType.Exercise && item.id === exerciseId
)
);
const isCourseContentReady = $derived(courseApi.course?.id != null);
const isExerciseTeacherLocked = $derived((exerciseContentItem?.isUnlocked ?? true) === false);
const isExerciseProgressionLocked = $derived(
$isOrgStudent && exerciseContentItem?.accessible === false && !isExerciseTeacherLocked
);
const isExerciseAccessible = $derived.by(() => {
if ($isOrgStudent && (!isCourseContentReady || !exerciseContentItem)) {
return false;
}
return !isExerciseTeacherLocked && !isExerciseProgressionLocked;
});
const exerciseDisplayTitle = $derived(
exerciseContentItem?.title ||
$questionnaire.title ||
t.get('course.navItem.lessons.exercises.all_exercises.heading')
);
const isSelfPacedCourse = $derived(isSelfPacedLikeCourse(courseApi.course?.type));
const isPublicCourse = $derived(courseApi.course?.type === 'PUBLIC');
const activeQuestionTypeIds = $derived(
($questionnaire.questions ?? []).filter((q) => !q.deletedAt).map((q) => getQuestionTypeId(q))
);
function isExerciseFullyAutoGradable(typeIds: number[]): boolean {
return typeIds.length > 0 && typeIds.every((id) => isAutoGradableQuestionType(getQuestionTypeKeyFromId(id)));
}
const isFullyAutoGradable = $derived(isExerciseFullyAutoGradable(activeQuestionTypeIds));
/** When true, every question must have points &gt; 0 before save (for meaningful auto-grading) */
const requiresPositivePointsForAutoGrade = $derived(isFullyAutoGradable);
const teacherAutoGradeBadge = $derived.by(() => {
if ($isOrgStudent || isPublicCourse || activeQuestionTypeIds.length === 0) return null;
return isFullyAutoGradable ? ('auto' as const) : ('manual' as const);
});
async function scrollToExerciseElement(elementId: string) {
await tick();
requestAnimationFrame(() => {
document.getElementById(elementId)?.scrollIntoView({
block: 'start',
behavior: 'smooth',
inline: 'nearest'
});
});
}
$inspect('$isStudentExperience', $isStudentExperience);
$inspect('isExerciseAccessible', isExerciseAccessible);
$inspect('isExerciseAccessible', isExerciseAccessible);
$inspect('mySubmissions', mySubmissions);
function getFirstActiveSectionId() {
const [firstActiveSection] = [...($questionnaire.sections ?? [])]
.filter((section) => !section.deletedAt)
.sort((leftSection, rightSection) => leftSection.order - rightSection.order);
return firstActiveSection?.id ?? null;
}
async function addQuestionFromHeader() {
preview = false;
const questionId = handleAddQuestion(getFirstActiveSectionId());
await scrollToExerciseElement(`exercise-question-${questionId}`);
}
async function addSectionFromHeader() {
preview = false;
const sectionId = handleAddSection();
await scrollToExerciseElement(`exercise-section-${sectionId}`);
}
function togglePreviewMode() {
preview = !preview;
reorderQuestions = false;
}
</script>
<Page.Header isSticky={true} class="ui:z-app-bar top-12! min-h-[36px]">
<Page.HeaderContent>
<Page.Title class="flex flex-col gap-2">
<span>{exerciseDisplayTitle}</span>
<RoleBasedSecurity allowedRoles={[1, 2]}>
{#if teacherAutoGradeBadge === 'auto'}
<Badge variant="outline" class="font-normal">
{$t('course.navItem.lessons.exercises.all_exercises.auto_graded_badge')}
</Badge>
{:else if teacherAutoGradeBadge === 'manual'}
<Badge variant="warning" class="font-normal">
{$t('course.navItem.lessons.exercises.all_exercises.manual_grading_required_badge')}
</Badge>
{/if}
</RoleBasedSecurity>
</Page.Title>
</Page.HeaderContent>
<Page.Action>
<div class="flex items-center gap-2">
{#if $isCourseLearnerView && courseApi.course?.id && exerciseId && !showMobileBottomNav}
<ContentNavigationActions courseId={courseApi.course.id} {exerciseId} />
{/if}
<RoleBasedSecurity allowedRoles={[1, 2]}>
{#if selectedTab === 'questions'}
<div class="right-0 flex w-full items-center justify-end">
<div class="flex flex-wrap items-center justify-end gap-2">
<ButtonGroup.Root>
<Button variant="outline" size="sm" onclick={addQuestionFromHeader}>
<CirclePlusIcon size={16} />
{$t('course.navItem.lessons.exercises.all_exercises.add_question')}
</Button>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button
{...props}
type="button"
variant="outline"
size="icon-sm"
aria-label={$t('course.navItem.lessons.exercises.all_exercises.add')}
>
<ChevronDownIcon size={16} />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item onclick={addSectionFromHeader}>
{$t('course.navItem.lessons.exercises.all_exercises.add_section')}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</ButtonGroup.Root>
<ButtonGroup.Root>
<Button variant="outline" size="sm" onclick={togglePreviewMode}>
{#if preview}
<PencilIcon size={16} />
{$t('course.navItem.lessons.exercises.all_exercises.view_mode.edit')}
{:else}
<EyeIcon size={16} />
{$t('course.navItem.lessons.exercises.all_exercises.preview')}
{/if}
</Button>
</ButtonGroup.Root>
<ButtonGroup.Root>
<Button size="sm" onclick={handleSave} loading={isSaving}>
{$t('course.navItem.lessons.exercises.all_exercises.save')}
</Button>
<ButtonGroup.Separator />
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button
{...props}
type="button"
variant="default"
size="icon-sm"
aria-label={$t('course.navItem.lessons.exercises.all_exercises.more')}
>
<MoreHorizontalIcon class="h-4 w-4" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
{#if hasSections($questionnaire.sections)}
<DropdownMenu.Item onclick={() => (reorderQuestions = !reorderQuestions)}>
{reorderQuestions
? $t('course.navItem.lessons.exercises.all_exercises.reorder_done')
: $t('course.navItem.lessons.exercises.all_exercises.reorder')}
</DropdownMenu.Item>
{:else}
<DropdownMenu.Item onclick={() => ($questionnaireOrder.open = true)}>
{$t('course.navItem.lessons.exercises.all_exercises.reorder')}
</DropdownMenu.Item>
{/if}
<DropdownMenu.Item
disabled={!courseApi.course?.id || !exerciseId}
onclick={() => {
if (courseApi.course?.id && exerciseId) {
exerciseApi.notifyStudents(courseApi.course.id, exerciseId);
}
}}
>
{$t('course.navItem.lessons.exercises.all_exercises.notify.action')}
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item
class="text-red-600 focus:text-red-600 dark:text-red-400"
onclick={() => (shouldDeleteExercise = true)}
>
{$t('course.navItem.lessons.exercises.all_exercises.delete_exercise')}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</ButtonGroup.Root>
</div>
</div>
{/if}
<!-- <RefreshPageData onRefresh={() => refreshExercisePageData(courseApi.course?.id ?? '', exerciseId)} /> -->
</RoleBasedSecurity>
</div>
</Page.Action>
</Page.Header>
<Page.Body>
{#snippet child()}
<div class="overflow-x-hidden pb-20">
{#if $isStudentExperience}
{#if isExerciseAccessible}
<ViewMode {preview} {exerciseId} isFetchingExercise={isFetching} {mySubmissions} />
{:else}
<StudentContentLockedNotice
reason={isExerciseProgressionLocked ? 'progression_locked' : 'teacher_locked'}
contentType={ContentType.Exercise}
/>
{/if}
{:else}
<UnderlineTabs.Root bind:value={selectedTab} class="mb-4">
<UnderlineTabs.List class="grid w-full max-w-lg grid-cols-3">
<UnderlineTabs.Trigger value="questions">
{$t('course.navItem.lessons.exercises.all_exercises.questions')}
</UnderlineTabs.Trigger>
<UnderlineTabs.Trigger value="settings">
{$t('course.navItem.lessons.exercises.all_exercises.settings')}
</UnderlineTabs.Trigger>
<UnderlineTabs.Trigger value="submissions">
{$t('course.navItem.lessons.exercises.all_exercises.analytics.submissions')}
<Badge variant="outline">{submissions.length}</Badge>
</UnderlineTabs.Trigger>
</UnderlineTabs.List>
<UnderlineTabs.Content value="questions" class="mx-auto w-full md:max-w-3xl">
<UpdateDescription {preview} />
{#if !preview}
<EditMode
{exerciseId}
{goBack}
{requiresPositivePointsForAutoGrade}
selfPacedCourse={isSelfPacedCourse}
{isPublicCourse}
{reorderQuestions}
/>
{:else}
<ViewMode {preview} {exerciseId} isFetchingExercise={isFetching} {mySubmissions} />
{/if}
</UnderlineTabs.Content>
<UnderlineTabs.Content value="settings">
<ExerciseSettingsTab
{exerciseId}
onBeforeSave={runPassedPolicySaveGuard}
totalSubmissions={submissions.length}
/>
</UnderlineTabs.Content>
<UnderlineTabs.Content value="submissions">
<Submissions bind:exerciseId {submissions} />
</UnderlineTabs.Content>
</UnderlineTabs.Root>
<Dialog.Root
bind:open={shouldDeleteExercise}
onOpenChange={(isOpen) => {
if (!isOpen) shouldDeleteExercise = false;
}}
>
<Dialog.Content class="w-2/4">
<Dialog.Header>
<Dialog.Title>{$t('course.navItem.lessons.exercises.all_exercises.edit_mode.delete_modal')}</Dialog.Title>
</Dialog.Header>
<form onsubmit={(e) => e.preventDefault()}>
<h1 class="text-xl dark:text-white">
{$t('course.navItem.lessons.exercises.all_exercises.edit_mode.sure')}
</h1>
<div class="mt-5 flex items-center justify-between">
<Button variant="outline" type="submit" onclick={() => (shouldDeleteExercise = false)}>
{$t('course.navItem.lessons.exercises.all_exercises.edit_mode.no')}
</Button>
<Button disabled={isDeleting} onclick={handleDeleteExercise} loading={isDeleting}>
{isDeleting
? $t('course.navItem.lessons.exercises.all_exercises.edit_mode.deleting')
: $t('course.navItem.lessons.exercises.all_exercises.edit_mode.yes')}
</Button>
</div>
</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}
</Page.Body>
<UnsavedChanges bind:hasUnsavedChanges />