* docs(prototypes): add course sidebar navigation HTML prototypes Co-authored-by: Rotimi Best <[email protected]> * docs(prototypes): add downloadable zip for course sidebar mockups Co-authored-by: Rotimi Best <[email protected]> * 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 <[email protected]> * 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 <[email protected]> * fix(dashboard): keep ClassroomIO brand untranslated in powered-by footer Co-authored-by: Rotimi Best <[email protected]> * fix(dashboard): restore progress summary in course sidebar footer Co-authored-by: Rotimi Best <[email protected]> * fix(dashboard): move progress bar below summary in sidebar footer Co-authored-by: Rotimi Best <[email protected]> * 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 <[email protected]> * 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 <[email protected]> ---------
938 lines
36 KiB
Svelte
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 > 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 />
|