feat: certification rules, content sanitization, handle draft course updates and reset content, better redis logging
This commit is contained in:
@@ -1,14 +1,20 @@
|
||||
<script lang="ts">
|
||||
import IssueCertificateModal from '$features/course/components/ceritficate/issue-certificate-modal.svelte';
|
||||
import { RoleBasedSecurity } from '$features/ui';
|
||||
import Design from '$features/course/components/ceritficate/design.svelte';
|
||||
import CertificateEdit from '$features/course/components/ceritficate/certificate-edit.svelte';
|
||||
import StudentCertificate from '$features/course/components/ceritficate/student-certificate/index.svelte';
|
||||
|
||||
type Props = {
|
||||
errors: Record<string, string>;
|
||||
};
|
||||
|
||||
let { errors }: Props = $props();
|
||||
</script>
|
||||
|
||||
<IssueCertificateModal />
|
||||
|
||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||
<Design />
|
||||
<CertificateEdit {errors} />
|
||||
</RoleBasedSecurity>
|
||||
<RoleBasedSecurity onlyStudent allowedRoles={[3]}>
|
||||
<StudentCertificate />
|
||||
|
||||
@@ -110,20 +110,18 @@
|
||||
{/if}
|
||||
{@render filterControls?.()}
|
||||
|
||||
{#if !isLMS}
|
||||
{#if $courseMetaDeta.view === 'list'}
|
||||
<IconButton onclick={() => setViewPreference('grid')}>
|
||||
<GridIcon size={16} />
|
||||
</IconButton>
|
||||
{:else}
|
||||
<IconButton onclick={() => setViewPreference('list')}>
|
||||
<ListIcon size={16} />
|
||||
</IconButton>
|
||||
{/if}
|
||||
{#if $courseMetaDeta.view === 'list'}
|
||||
<IconButton onclick={() => setViewPreference('grid')}>
|
||||
<GridIcon size={16} />
|
||||
</IconButton>
|
||||
{:else}
|
||||
<IconButton onclick={() => setViewPreference('list')}>
|
||||
<ListIcon size={16} />
|
||||
</IconButton>
|
||||
{/if}
|
||||
</Page.BodyHeader>
|
||||
|
||||
<div class="mx-auto w-full flex-1">
|
||||
<div class="mx-auto mt-4 w-full flex-1">
|
||||
{#if isLoading}
|
||||
<section class={`${isLoading || courses ? 'cards-container' : ''} `}>
|
||||
<CourseCardLoader />
|
||||
@@ -146,15 +144,18 @@
|
||||
<Table.Head class="w-[10%]">{$t('courses.course_card.list_view.type')}</Table.Head>
|
||||
<Table.Head class="w-3/12 min-w-0">{$t('courses.course_card.list_view.tags')}</Table.Head>
|
||||
<Table.Head class="w-[8%]">{$t('courses.course_card.list_view.lessons')}</Table.Head>
|
||||
<Table.Head class="w-[8%]">{$t('courses.course_card.list_view.students')}</Table.Head>
|
||||
<Table.Head class="w-[10%]">{$t('courses.course_card.list_view.published')}</Table.Head>
|
||||
<Table.Head class="w-[6%]"></Table.Head>
|
||||
{#if !isLMS}
|
||||
<Table.Head class="w-[8%]">{$t('courses.course_card.list_view.students')}</Table.Head>
|
||||
<Table.Head class="w-[10%]">{$t('courses.course_card.list_view.published')}</Table.Head>
|
||||
<Table.Head class="w-[6%]"></Table.Head>
|
||||
{/if}
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each courses as courseData (courseData.id)}
|
||||
<CourseList
|
||||
id={courseData.id}
|
||||
slug={courseData.slug ?? ''}
|
||||
title={courseData.title}
|
||||
type={$t(`course.navItem.settings.${courseData.type?.toLowerCase()}`)}
|
||||
description={courseData.description}
|
||||
@@ -167,6 +168,8 @@
|
||||
slug: string;
|
||||
color?: string | null;
|
||||
}>}
|
||||
{isExplore}
|
||||
{isLMS}
|
||||
/>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
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 { IconButton } from '@cio/ui/custom/icon-button';
|
||||
@@ -41,17 +42,19 @@
|
||||
import VideoIcon from '@lucide/svelte/icons/video';
|
||||
import {
|
||||
getQuestionTypeId,
|
||||
getQuestionTypeKeyFromId,
|
||||
getQuestionTypeOptionById
|
||||
} from '$features/course/components/exercise/question-type-utils';
|
||||
import type { SubmissionListItem } from '$features/course/utils/types';
|
||||
import { Badge } from '@cio/ui/base/badge';
|
||||
import { isAutoGradableQuestionType } from '@cio/question-types';
|
||||
|
||||
interface Props {
|
||||
exerciseId?: string;
|
||||
goBack?: () => void;
|
||||
isFetching?: boolean;
|
||||
submissions: SubmissionListItem[];
|
||||
mySubmission: SubmissionListItem | null;
|
||||
mySubmissions?: SubmissionListItem[];
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -59,15 +62,18 @@
|
||||
goBack = () => {},
|
||||
isFetching = false,
|
||||
submissions,
|
||||
mySubmission
|
||||
mySubmissions = []
|
||||
}: Props = $props();
|
||||
|
||||
type ExerciseTab = 'questions' | 'submissions';
|
||||
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';
|
||||
}
|
||||
@@ -109,6 +115,26 @@
|
||||
return;
|
||||
}
|
||||
|
||||
if (requiresPositivePointsForAutoGrade) {
|
||||
const zeroPointQuestions = ($questionnaire.questions ?? []).filter((q) => !q.deletedAt && Number(q.points) === 0);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
isSaving = true;
|
||||
|
||||
reset();
|
||||
@@ -204,11 +230,41 @@
|
||||
)
|
||||
);
|
||||
const isExerciseUnlocked = $derived(exerciseContentItem?.isUnlocked ?? false);
|
||||
|
||||
const isSelfPacedCourse = $derived(courseApi.course?.type === 'SELF_PACED');
|
||||
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)));
|
||||
}
|
||||
|
||||
/** When true, every question must have points > 0 before save */
|
||||
const requiresPositivePointsForAutoGrade = $derived(
|
||||
isSelfPacedCourse && isExerciseFullyAutoGradable(activeQuestionTypeIds)
|
||||
);
|
||||
const teacherAutoGradeBadge = $derived.by(() => {
|
||||
if ($globalStore.isStudent || !isSelfPacedCourse || activeQuestionTypeIds.length === 0) return null;
|
||||
return isExerciseFullyAutoGradable(activeQuestionTypeIds) ? ('auto' as const) : ('manual' as const);
|
||||
});
|
||||
</script>
|
||||
|
||||
<Page.Header isSticky={true} class="z-250! min-h-[36px]">
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>{$questionnaire.title || 'Exercise'}</Page.Title>
|
||||
<Page.Title class="flex flex-wrap items-center gap-2">
|
||||
<span>{$questionnaire.title || 'Exercise'}</span>
|
||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||
{#if teacherAutoGradeBadge === 'auto'}
|
||||
<Badge variant="success" 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">
|
||||
@@ -278,7 +334,7 @@
|
||||
<div class="overflow-x-hidden">
|
||||
{#if $globalStore.isStudent}
|
||||
{#if isExerciseUnlocked}
|
||||
<ViewMode {preview} {exerciseId} isFetchingExercise={isFetching} {mySubmission} />
|
||||
<ViewMode {preview} {exerciseId} isFetchingExercise={isFetching} {mySubmissions} />
|
||||
{:else}
|
||||
<Empty
|
||||
title={$t('course.navItem.lessons.content_locked_title')}
|
||||
@@ -290,10 +346,13 @@
|
||||
{/if}
|
||||
{:else}
|
||||
<UnderlineTabs.Root bind:value={selectedTab} class="mb-4">
|
||||
<UnderlineTabs.List class="grid w-full max-w-xs grid-cols-2">
|
||||
<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')}
|
||||
|
||||
@@ -303,11 +362,20 @@
|
||||
<UnderlineTabs.Content value="questions">
|
||||
<UpdateDescription {preview} />
|
||||
{#if !preview}
|
||||
<EditMode bind:shouldDeleteExercise {exerciseId} {goBack} />
|
||||
<EditMode
|
||||
bind:shouldDeleteExercise
|
||||
{exerciseId}
|
||||
{goBack}
|
||||
{requiresPositivePointsForAutoGrade}
|
||||
selfPacedCourse={isSelfPacedCourse}
|
||||
/>
|
||||
{:else}
|
||||
<ViewMode {preview} {exerciseId} isFetchingExercise={isFetching} {mySubmission} />
|
||||
<ViewMode {preview} {exerciseId} isFetchingExercise={isFetching} {mySubmissions} />
|
||||
{/if}
|
||||
</UnderlineTabs.Content>
|
||||
<UnderlineTabs.Content value="settings">
|
||||
<ExerciseSettingsTab {exerciseId} />
|
||||
</UnderlineTabs.Content>
|
||||
<UnderlineTabs.Content value="submissions">
|
||||
<Submissions bind:exerciseId {submissions} />
|
||||
</UnderlineTabs.Content>
|
||||
|
||||
@@ -35,6 +35,12 @@
|
||||
import { currentOrgDomain, currentOrgPath, isFreePlan } from '$lib/utils/store/org';
|
||||
import { page } from '$app/stores';
|
||||
|
||||
interface Props {
|
||||
hasUnsavedChanges?: boolean;
|
||||
}
|
||||
|
||||
let { hasUnsavedChanges = $bindable(false) }: Props = $props();
|
||||
|
||||
let isLoading = $state(false);
|
||||
let isDeleting = $state(false);
|
||||
let errors: {
|
||||
@@ -45,7 +51,6 @@
|
||||
description: undefined
|
||||
});
|
||||
let avatar: string | undefined;
|
||||
let hasUnsavedChanges = $state(false);
|
||||
let openDeleteModal = $state(false);
|
||||
let selectedTagIds = $state<string[]>([]);
|
||||
let initialTagIds = $state<string[]>([]);
|
||||
|
||||
Reference in New Issue
Block a user