feat(dashboard): moved course completion deadline to course settings page (#842)
This commit is contained in:
@@ -29,7 +29,7 @@ import { resolve } from '$app/paths';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { coursesApi } from './courses.svelte';
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import { ROLE, ErrorCodes } from '@cio/utils/constants';
|
||||
import { ContentType } from '@cio/utils/constants/content';
|
||||
import type { CourseMembers } from '../utils/types';
|
||||
|
||||
@@ -326,11 +326,11 @@ export class CourseApi extends BaseApiWithErrors {
|
||||
typeof result === 'object' &&
|
||||
result !== null &&
|
||||
'code' in result &&
|
||||
result.code === 'VALIDATION_ERROR' &&
|
||||
result.code === ErrorCodes.VALIDATION_ERROR &&
|
||||
'field' in result &&
|
||||
result.field === 'certificate.deadline'
|
||||
) {
|
||||
snackbar.error('course.certification.deadline_required_enroll');
|
||||
snackbar.error('course.certification.deadline_invalid');
|
||||
} else if (typeof result === 'object' && result !== null && 'error' in result) {
|
||||
snackbar.error((result as { error: string }).error);
|
||||
}
|
||||
@@ -497,6 +497,11 @@ export class CourseApi extends BaseApiWithErrors {
|
||||
snackbar.error('Failed to update course');
|
||||
return;
|
||||
}
|
||||
if ('code' in result && result.code === ErrorCodes.COMPLIANCE_DEADLINE_REQUIRED) {
|
||||
this.errors['certificate.deadline'] = t.get('course.certification.deadline_required');
|
||||
snackbar.error('course.certification.deadline_required');
|
||||
return;
|
||||
}
|
||||
if ('error' in result && 'field' in result && result.field) {
|
||||
this.errors[result.field] = result.error;
|
||||
snackbar.error(result.error);
|
||||
|
||||
-147
@@ -1,15 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { Switch } from '@cio/ui/base/switch';
|
||||
import { Input } from '@cio/ui/base/input';
|
||||
import { Textarea } from '@cio/ui/base/textarea';
|
||||
import * as Field from '@cio/ui/base/field';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { isFreePlan } from '$lib/utils/store/org';
|
||||
import { getOrderedNavigableContent } from '$features/course/utils/content';
|
||||
import type { CourseCertificate } from '$features/course/utils/types';
|
||||
import { ContentType } from '@cio/utils/constants/content';
|
||||
import { AttentionHighlight } from '$features/ui';
|
||||
|
||||
type Props = {
|
||||
errors: Record<string, string>;
|
||||
@@ -17,14 +12,6 @@
|
||||
|
||||
let { errors }: Props = $props();
|
||||
|
||||
function isoToDatetimeLocal(iso: string | null | undefined): string {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
function updateCertificate(patch: Partial<NonNullable<typeof courseApi.course>['certificate']>) {
|
||||
if (!courseApi.course) return;
|
||||
|
||||
@@ -34,54 +21,9 @@
|
||||
};
|
||||
}
|
||||
|
||||
function onCertDeadlineChange(e: Event) {
|
||||
const v = (e.currentTarget as HTMLInputElement).value;
|
||||
updateCertificate({ deadline: v ? new Date(v).toISOString() : null });
|
||||
}
|
||||
|
||||
const certExercises = $derived(
|
||||
getOrderedNavigableContent(courseApi.course).filter((item) => item.type === ContentType.Exercise)
|
||||
);
|
||||
|
||||
const finalExerciseSelectValue = $derived(courseApi.course?.certificate?.requiredExerciseId ?? 'none');
|
||||
|
||||
function onFinalExerciseChange(e: Event) {
|
||||
const v = (e.currentTarget as HTMLSelectElement).value;
|
||||
if (!v || v === 'none') {
|
||||
updateCertificate({ requiredExerciseId: null, exerciseMinScorePercent: null });
|
||||
return;
|
||||
}
|
||||
|
||||
const currentMin = courseApi.course?.certificate?.exerciseMinScorePercent;
|
||||
updateCertificate({
|
||||
requiredExerciseId: v,
|
||||
exerciseMinScorePercent: currentMin ?? 100
|
||||
});
|
||||
}
|
||||
|
||||
function onThresholdInput(e: Event) {
|
||||
const n = Number((e.currentTarget as HTMLInputElement).value);
|
||||
updateCertificate({ threshold: Number.isFinite(n) ? Math.min(100, Math.max(0, n)) : 100 });
|
||||
}
|
||||
|
||||
function onMinExerciseScoreInput(e: Event) {
|
||||
const n = Number((e.currentTarget as HTMLInputElement).value);
|
||||
updateCertificate({ exerciseMinScorePercent: Number.isFinite(n) ? Math.min(100, Math.max(0, n)) : 100 });
|
||||
}
|
||||
|
||||
function onEmailMessageInput(e: Event) {
|
||||
updateCertificate({ emailMessage: (e.currentTarget as HTMLTextAreaElement).value || null });
|
||||
}
|
||||
|
||||
function validateCertificateSettings(certificate: CourseCertificate | null | undefined): string[] {
|
||||
const errors: string[] = [];
|
||||
if (certificate?.isDownloadable && !certificate?.deadline) {
|
||||
errors.push('course.certification.deadline_required');
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
const certificateValidationErrors = $derived(validateCertificateSettings(courseApi.course?.certificate));
|
||||
</script>
|
||||
|
||||
<Field.Group class="w-full max-w-md! px-2">
|
||||
@@ -101,97 +43,8 @@
|
||||
<Field.Label for="certificate-downloadable" class="text-gray-600">
|
||||
{$t('course.navItem.certificates.allow')}
|
||||
</Field.Label>
|
||||
{#if courseApi.course?.certificate?.isDownloadable && certificateValidationErrors.includes('course.certification.deadline_required')}
|
||||
<Field.Error class="mt-1">{$t('course.certification.deadline_required')}</Field.Error>
|
||||
{/if}
|
||||
</div>
|
||||
</Field.Field>
|
||||
|
||||
<Field.Group>
|
||||
<AttentionHighlight id="cert-deadline">
|
||||
<Field.Field>
|
||||
<Field.Label for="cert-deadline">
|
||||
{$t('course.certification.deadline_label')}
|
||||
{#if courseApi.course?.certificate?.isDownloadable}
|
||||
<span class="ui:text-red-600">*</span>
|
||||
{/if}
|
||||
</Field.Label>
|
||||
<Input
|
||||
id="cert-deadline"
|
||||
type="datetime-local"
|
||||
class="w-full"
|
||||
value={isoToDatetimeLocal(courseApi.course?.certificate?.deadline ?? null)}
|
||||
onchange={onCertDeadlineChange}
|
||||
disabled={$isFreePlan}
|
||||
/>
|
||||
<Field.Description>{$t('course.certification.deadline_helper')}</Field.Description>
|
||||
{#if errors['certificate.deadline']}
|
||||
<Field.Error>{errors['certificate.deadline']}</Field.Error>
|
||||
{/if}
|
||||
{#if certificateValidationErrors.includes('course.certification.deadline_required')}
|
||||
<Field.Error>{$t('course.certification.deadline_required')}</Field.Error>
|
||||
{/if}
|
||||
</Field.Field>
|
||||
</AttentionHighlight>
|
||||
|
||||
<AttentionHighlight id="cert-threshold">
|
||||
<Field.Field>
|
||||
<Field.Label for="cert-threshold">{$t('course.certification.threshold_label')}</Field.Label>
|
||||
<Input
|
||||
id="cert-threshold"
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
class="w-full"
|
||||
value={String(courseApi.course?.certificate?.threshold ?? 100)}
|
||||
oninput={onThresholdInput}
|
||||
disabled={$isFreePlan}
|
||||
/>
|
||||
<Field.Description>{$t('course.certification.threshold_helper')}</Field.Description>
|
||||
{#if errors['certificate.threshold']}
|
||||
<Field.Error>{errors['certificate.threshold']}</Field.Error>
|
||||
{/if}
|
||||
</Field.Field>
|
||||
</AttentionHighlight>
|
||||
|
||||
<Field.Field>
|
||||
<Field.Label for="cert-final-exercise">{$t('course.certification.final_exercise_label')}</Field.Label>
|
||||
<select
|
||||
id="cert-final-exercise"
|
||||
class="ui:border-input ui:bg-background ui:text-foreground ui:ring-offset-background ui:shadow-xs ui:flex ui:h-9 ui:w-full ui:min-w-0 ui:rounded-md ui:border ui:px-3 ui:py-1 ui:text-base ui:outline-none ui:transition-[color,box-shadow] ui:disabled:cursor-not-allowed ui:disabled:opacity-50 ui:md:text-sm ui:focus-visible:border-ring ui:focus-visible:ring-ring/50 ui:focus-visible:ring-[3px]"
|
||||
value={finalExerciseSelectValue}
|
||||
onchange={onFinalExerciseChange}
|
||||
disabled={$isFreePlan}
|
||||
>
|
||||
<option value="none">{$t('course.certification.final_exercise_none')}</option>
|
||||
{#each certExercises as item (item.id)}
|
||||
<option value={item.id}>{item.title}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<Field.Description>{$t('course.certification.final_exercise_helper')}</Field.Description>
|
||||
<Field.Description>{$t('course.certification.final_exercise_multiple_attempts_note')}</Field.Description>
|
||||
</Field.Field>
|
||||
|
||||
{#if courseApi.course?.certificate?.requiredExerciseId}
|
||||
<Field.Field>
|
||||
<Field.Label for="cert-min-exercise-score">{$t('course.certification.min_exercise_score_label')}</Field.Label>
|
||||
<Input
|
||||
id="cert-min-exercise-score"
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
class="w-full"
|
||||
value={String(courseApi.course?.certificate?.exerciseMinScorePercent ?? 100)}
|
||||
oninput={onMinExerciseScoreInput}
|
||||
disabled={$isFreePlan}
|
||||
/>
|
||||
<Field.Description>{$t('course.certification.min_exercise_score_helper')}</Field.Description>
|
||||
{#if errors['certificate.exerciseMinScorePercent']}
|
||||
<Field.Error>{errors['certificate.exerciseMinScorePercent']}</Field.Error>
|
||||
{/if}
|
||||
</Field.Field>
|
||||
{/if}
|
||||
</Field.Group>
|
||||
</Field.Set>
|
||||
|
||||
<Field.Separator />
|
||||
|
||||
@@ -16,13 +16,13 @@
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
import { TextareaField } from '@cio/ui/custom/textarea-field';
|
||||
import { InputField } from '@cio/ui/custom/input-field';
|
||||
import { Input } from '@cio/ui/base/input';
|
||||
import * as Field from '@cio/ui/base/field';
|
||||
import { UpgradeBanner, UnsavedChanges, UploadWidget, TextEditor, AttentionHighlight } from '$features/ui';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { goAndHighlight } from '$lib/routing/go-and-highlight';
|
||||
import { ROUTE_NAME, ROUTE_SECTIONS } from '$lib/routing/routes';
|
||||
|
||||
import { settings } from '$features/course/utils/settings-store';
|
||||
import { getOrderedNavigableContent } from '$features/course/utils/content';
|
||||
import Copy from '@lucide/svelte/icons/copy';
|
||||
import * as Alert from '@cio/ui/base/alert';
|
||||
import LockOpenIcon from '@lucide/svelte/icons/lock-open';
|
||||
@@ -31,8 +31,9 @@
|
||||
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 { generateSlug, isPublishedComplianceMissingDeadline } from '@cio/utils/functions';
|
||||
import { DEFAULT_COMPLIANCE_SETTINGS } from '../utils/compliance-utils';
|
||||
import { ContentType } from '@cio/utils/constants/content';
|
||||
import { DeleteModal } from '$features/ui';
|
||||
import { contentApi, courseApi } from '$features/course/api';
|
||||
import { collectLockedContentItems } from '$features/course/utils/content-lock-utils';
|
||||
@@ -52,6 +53,7 @@
|
||||
let isLoading = $state(false);
|
||||
let isDeleting = $state(false);
|
||||
let openCertificateDeadlineDialog = $state(false);
|
||||
let completionDeadlineTrigger = $state(0);
|
||||
let errors: {
|
||||
title: string | undefined;
|
||||
description: string | undefined;
|
||||
@@ -169,10 +171,13 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const isCourseCompliance = $settings.type === 'COMPLIANCE';
|
||||
const hasCertificateDeadline = !!courseApi.course?.certificate?.deadline;
|
||||
|
||||
if (isCourseCompliance && !hasCertificateDeadline) {
|
||||
if (
|
||||
isPublishedComplianceMissingDeadline({
|
||||
type: $settings.type,
|
||||
isPublished: true,
|
||||
deadline: $settings.certificate.deadline
|
||||
})
|
||||
) {
|
||||
openCertificateDeadlineDialog = true;
|
||||
return;
|
||||
}
|
||||
@@ -183,15 +188,9 @@
|
||||
hasUnsavedChanges = true;
|
||||
}
|
||||
|
||||
async function goToCertificateSettings() {
|
||||
function goToCompletionDeadline() {
|
||||
openCertificateDeadlineDialog = false;
|
||||
const courseId = courseApi.course?.id;
|
||||
if (!courseId) return;
|
||||
|
||||
goAndHighlight(ROUTE_NAME.COURSE_CERTIFICATE, ROUTE_SECTIONS[ROUTE_NAME.COURSE_CERTIFICATE].CERT_DEADLINE, {
|
||||
id: courseId,
|
||||
tab: 'settings'
|
||||
});
|
||||
completionDeadlineTrigger += 1;
|
||||
}
|
||||
|
||||
export async function handleSave() {
|
||||
@@ -215,6 +214,17 @@
|
||||
|
||||
if (!courseApi.course) return;
|
||||
|
||||
if (
|
||||
isPublishedComplianceMissingDeadline({
|
||||
type: $settings.type,
|
||||
isPublished: $settings.isPublished,
|
||||
deadline: $settings.certificate.deadline
|
||||
})
|
||||
) {
|
||||
openCertificateDeadlineDialog = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if ($settings.isPublished && !courseApi.course.slug) {
|
||||
courseApi.course.slug = generateSlug($settings.courseTitle, { appendTimestamp: true });
|
||||
}
|
||||
@@ -240,7 +250,16 @@
|
||||
slug: courseApi.course.slug ?? undefined,
|
||||
compliance:
|
||||
$settings.type === 'COMPLIANCE' ? (courseApi.course.compliance ?? DEFAULT_COMPLIANCE_SETTINGS) : undefined,
|
||||
callout: $settings.type === 'PUBLIC' ? sanitizeCalloutForSave($settings.callout) : null
|
||||
callout: $settings.type === 'PUBLIC' ? sanitizeCalloutForSave($settings.callout) : null,
|
||||
certificate: {
|
||||
...(courseApi.course.certificate ?? {}),
|
||||
deadline: $settings.certificate.deadline,
|
||||
threshold: $settings.certificate.threshold,
|
||||
requiredExerciseId: $settings.certificate.requiredExerciseId,
|
||||
exerciseMinScorePercent: $settings.certificate.requiredExerciseId
|
||||
? $settings.certificate.exerciseMinScorePercent
|
||||
: null
|
||||
}
|
||||
};
|
||||
|
||||
const normalizedSelectedTagIds = normalizeTagIds(selectedTagIds);
|
||||
@@ -293,7 +312,18 @@
|
||||
isContentGroupingEnabled: course.metadata?.isContentGroupingEnabled ?? true,
|
||||
progressionMode: course.metadata?.progressionMode ?? 'free',
|
||||
callout: normalizeCallout(course.callout),
|
||||
welcomeEmailMessage: course.metadata?.welcomeEmailMessage ?? ''
|
||||
welcomeEmailMessage: course.metadata?.welcomeEmailMessage ?? '',
|
||||
certificate: {
|
||||
deadline: course.certificate?.deadline ?? null,
|
||||
threshold: typeof course.certificate?.threshold === 'number' ? course.certificate.threshold : 100,
|
||||
requiredExerciseId: course.certificate?.requiredExerciseId ?? null,
|
||||
exerciseMinScorePercent:
|
||||
typeof course.certificate?.exerciseMinScorePercent === 'number'
|
||||
? course.certificate.exerciseMinScorePercent
|
||||
: course.certificate?.requiredExerciseId
|
||||
? 100
|
||||
: null
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -416,6 +446,54 @@
|
||||
});
|
||||
|
||||
let courseLink = $derived(courseApi.course?.slug ? `${$currentOrgDomain}/course/${courseApi.course.slug}` : '#');
|
||||
|
||||
const certExercises = $derived(
|
||||
getOrderedNavigableContent(courseApi.course).filter((item) => item.type === ContentType.Exercise)
|
||||
);
|
||||
|
||||
const finalExerciseTitle = $derived(
|
||||
certExercises.find((item) => item.id === $settings.certificate.requiredExerciseId)?.title
|
||||
);
|
||||
|
||||
function isoToDatetimeLocal(iso: string | null | undefined): string {
|
||||
if (!iso) return '';
|
||||
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) return '';
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||
}
|
||||
|
||||
function onCompletionDeadlineChange(e: Event) {
|
||||
const value = (e.currentTarget as HTMLInputElement).value;
|
||||
$settings.certificate.deadline = value ? new Date(value).toISOString() : null;
|
||||
hasUnsavedChanges = true;
|
||||
}
|
||||
|
||||
function onThresholdInput(e: Event) {
|
||||
const value = Number((e.currentTarget as HTMLInputElement).value);
|
||||
$settings.certificate.threshold = Number.isFinite(value) ? Math.min(100, Math.max(0, value)) : 100;
|
||||
hasUnsavedChanges = true;
|
||||
}
|
||||
|
||||
function onFinalExerciseChange(value: string) {
|
||||
$settings.certificate.requiredExerciseId = value && value !== 'none' ? value : null;
|
||||
|
||||
if (!$settings.certificate.requiredExerciseId) {
|
||||
$settings.certificate.exerciseMinScorePercent = null;
|
||||
} else if (typeof $settings.certificate.exerciseMinScorePercent !== 'number') {
|
||||
$settings.certificate.exerciseMinScorePercent = 100;
|
||||
}
|
||||
|
||||
hasUnsavedChanges = true;
|
||||
}
|
||||
|
||||
function onMinExerciseScoreInput(e: Event) {
|
||||
const value = Number((e.currentTarget as HTMLInputElement).value);
|
||||
$settings.certificate.exerciseMinScorePercent = Number.isFinite(value) ? Math.min(100, Math.max(0, value)) : 100;
|
||||
hasUnsavedChanges = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<UnsavedChanges bind:hasUnsavedChanges />
|
||||
@@ -434,8 +512,8 @@
|
||||
<Button variant="outline" onclick={() => (openCertificateDeadlineDialog = false)}>
|
||||
{$t('cancel')}
|
||||
</Button>
|
||||
<Button onclick={goToCertificateSettings}>
|
||||
{$t('course.navItem.settings.go_to_certificate_settings')}
|
||||
<Button onclick={goToCompletionDeadline}>
|
||||
{$t('course.navItem.settings.go_to_completion_deadline')}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
@@ -595,6 +673,96 @@
|
||||
<p class="ui:mt-1 ui:text-destructive/90">{courseApi.errors.type}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Field.Group class="mt-3">
|
||||
<AttentionHighlight id="course-completion-deadline" trigger={completionDeadlineTrigger}>
|
||||
<Field.Field>
|
||||
<Field.Label>
|
||||
{$t('course.navItem.settings.completion_deadline_label')}
|
||||
</Field.Label>
|
||||
<Input
|
||||
id="course-completion-deadline"
|
||||
type="datetime-local"
|
||||
class="w-full"
|
||||
value={isoToDatetimeLocal($settings.certificate.deadline)}
|
||||
onchange={onCompletionDeadlineChange}
|
||||
/>
|
||||
<Field.Description>{$t('course.navItem.settings.completion_deadline_helper')}</Field.Description>
|
||||
{#if courseApi.errors['certificate.deadline']}
|
||||
<Field.Error>{courseApi.errors['certificate.deadline']}</Field.Error>
|
||||
{/if}
|
||||
</Field.Field>
|
||||
</AttentionHighlight>
|
||||
|
||||
<Field.Field>
|
||||
<Field.Label for="course-completion-threshold">
|
||||
{$t('course.certification.threshold_label')}
|
||||
</Field.Label>
|
||||
<Input
|
||||
id="course-completion-threshold"
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
class="w-full"
|
||||
value={String($settings.certificate.threshold)}
|
||||
oninput={onThresholdInput}
|
||||
/>
|
||||
<Field.Description>{$t('course.certification.threshold_helper')}</Field.Description>
|
||||
{#if courseApi.errors['certificate.threshold']}
|
||||
<Field.Error>{courseApi.errors['certificate.threshold']}</Field.Error>
|
||||
{/if}
|
||||
</Field.Field>
|
||||
|
||||
<Field.Field>
|
||||
<Field.Label for="course-final-exercise">
|
||||
{$t('course.certification.final_exercise_label')}
|
||||
</Field.Label>
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={$settings.certificate.requiredExerciseId ?? 'none'}
|
||||
onValueChange={onFinalExerciseChange}
|
||||
>
|
||||
<Select.Trigger class="w-full">
|
||||
{finalExerciseTitle ?? $t('course.certification.final_exercise_none')}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Group>
|
||||
<Select.Item value="none" label={$t('course.certification.final_exercise_none')}>
|
||||
{$t('course.certification.final_exercise_none')}
|
||||
</Select.Item>
|
||||
{#each certExercises as item (item.id)}
|
||||
<Select.Item value={item.id} label={item.title}>
|
||||
{item.title}
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Group>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
<Field.Description>{$t('course.certification.final_exercise_helper')}</Field.Description>
|
||||
<Field.Description>{$t('course.certification.final_exercise_multiple_attempts_note')}</Field.Description>
|
||||
</Field.Field>
|
||||
|
||||
{#if $settings.certificate.requiredExerciseId}
|
||||
<Field.Field>
|
||||
<Field.Label for="course-min-exercise-score">
|
||||
{$t('course.certification.min_exercise_score_label')}
|
||||
</Field.Label>
|
||||
<Input
|
||||
id="course-min-exercise-score"
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
class="w-full"
|
||||
value={String($settings.certificate.exerciseMinScorePercent ?? 100)}
|
||||
oninput={onMinExerciseScoreInput}
|
||||
/>
|
||||
<Field.Description>{$t('course.certification.min_exercise_score_helper')}</Field.Description>
|
||||
{#if courseApi.errors['certificate.exerciseMinScorePercent']}
|
||||
<Field.Error>{courseApi.errors['certificate.exerciseMinScorePercent']}</Field.Error>
|
||||
{/if}
|
||||
</Field.Field>
|
||||
{/if}
|
||||
</Field.Group>
|
||||
</Field.Set>
|
||||
|
||||
<Field.Separator />
|
||||
|
||||
@@ -16,6 +16,12 @@ type CourseSettings = {
|
||||
progressionMode: 'free' | 'sequential';
|
||||
callout: TCourseCallout | null;
|
||||
welcomeEmailMessage: string;
|
||||
certificate: {
|
||||
deadline: string | null;
|
||||
threshold: number;
|
||||
requiredExerciseId: string | null;
|
||||
exerciseMinScorePercent: number | null;
|
||||
};
|
||||
};
|
||||
|
||||
export const settings = writable<CourseSettings>({
|
||||
@@ -36,5 +42,11 @@ export const settings = writable<CourseSettings>({
|
||||
isContentGroupingEnabled: true,
|
||||
progressionMode: 'free',
|
||||
callout: null,
|
||||
welcomeEmailMessage: ''
|
||||
welcomeEmailMessage: '',
|
||||
certificate: {
|
||||
deadline: null,
|
||||
threshold: 100,
|
||||
requiredExerciseId: null,
|
||||
exerciseMinScorePercent: null
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user