From ad5c8db5a935c2a06cca84a7d884a2a6c4a555c9 Mon Sep 17 00:00:00 2001
From: Victor Jack <67293106+thedon-dev@users.noreply.github.com>
Date: Sat, 15 Aug 2026 18:55:51 +0100
Subject: [PATCH] feat(dashboard): moved course completion deadline to course
settings page (#842)
---
.../course-go-live-readiness.test.ts | 37 ++++
.../lib/features/course/api/course.svelte.ts | 11 +-
.../ceritficate/certificate-settings.svelte | 147 -------------
.../lib/features/course/pages/settings.svelte | 206 ++++++++++++++++--
.../features/course/utils/settings-store.ts | 14 +-
.../features/ui/attention-highlight.svelte | 40 +++-
.../src/lib/utils/translations/da.json | 15 +-
.../src/lib/utils/translations/de.json | 13 +-
.../src/lib/utils/translations/en.json | 17 +-
.../src/lib/utils/translations/es.json | 15 +-
.../src/lib/utils/translations/fr.json | 13 +-
.../src/lib/utils/translations/hi.json | 13 +-
.../src/lib/utils/translations/pl.json | 13 +-
.../src/lib/utils/translations/pt.json | 15 +-
.../src/lib/utils/translations/ru.json | 15 +-
.../src/lib/utils/translations/vi.json | 13 +-
packages/core/src/services/course/course.ts | 31 ++-
.../src/services/course/go-live-readiness.ts | 30 ++-
packages/utils/src/constants/error-codes.ts | 1 +
.../src/functions/compliance-deadline.ts | 38 ++++
packages/utils/src/functions/index.ts | 1 +
.../utils/tests/compliance-deadline.test.ts | 74 +++++++
22 files changed, 528 insertions(+), 244 deletions(-)
create mode 100644 packages/utils/src/functions/compliance-deadline.ts
create mode 100644 packages/utils/tests/compliance-deadline.test.ts
diff --git a/apps/api/src/__tests__/course-go-live-readiness.test.ts b/apps/api/src/__tests__/course-go-live-readiness.test.ts
index 9e293704..724a5846 100644
--- a/apps/api/src/__tests__/course-go-live-readiness.test.ts
+++ b/apps/api/src/__tests__/course-go-live-readiness.test.ts
@@ -177,6 +177,43 @@ describe('evaluateCourseGoLiveReadiness', () => {
expect(readiness.blockers).toEqual([]);
expect(readiness.courseUrl).toBe('https://school.example.com/course/workplace-safety-essentials');
});
+
+ it('blocks publishing a compliance course that has no completion deadline', () => {
+ const readiness = evaluateCourseGoLiveReadiness({
+ course: buildCourse({
+ type: 'COMPLIANCE',
+ certificate: { isDownloadable: true }
+ }) as never,
+ contentItems: [buildContentItem()],
+ organization: {
+ customDomain: 'school.example.com',
+ isCustomDomainVerified: true,
+ siteName: 'school'
+ } as never
+ });
+
+ expect(readiness.ready).toBe(false);
+ expect(readiness.blockers.map((blocker) => blocker.code)).toContain('COMPLIANCE_DEADLINE_MISSING');
+ });
+
+ it('allows publishing a compliance course when a completion deadline is set', () => {
+ const readiness = evaluateCourseGoLiveReadiness({
+ course: buildCourse({
+ type: 'COMPLIANCE',
+ certificate: { deadline: '2026-12-31T23:59:00.000Z' }
+ }) as never,
+ contentItems: [buildContentItem()],
+ organization: {
+ customDomain: 'school.example.com',
+ isCustomDomainVerified: true,
+ siteName: 'school'
+ } as never
+ });
+
+ expect(readiness.ready).toBe(true);
+ expect(readiness.blockers).toEqual([]);
+ expect(readiness.warnings.map((warning) => warning.code)).toContain('COMPLIANCE_REVIEW_RECOMMENDED');
+ });
});
describe('course go-live service', () => {
diff --git a/apps/dashboard/src/lib/features/course/api/course.svelte.ts b/apps/dashboard/src/lib/features/course/api/course.svelte.ts
index a37d6919..7e2e3578 100644
--- a/apps/dashboard/src/lib/features/course/api/course.svelte.ts
+++ b/apps/dashboard/src/lib/features/course/api/course.svelte.ts
@@ -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);
diff --git a/apps/dashboard/src/lib/features/course/components/ceritficate/certificate-settings.svelte b/apps/dashboard/src/lib/features/course/components/ceritficate/certificate-settings.svelte
index f18d6e0b..9271717e 100644
--- a/apps/dashboard/src/lib/features/course/components/ceritficate/certificate-settings.svelte
+++ b/apps/dashboard/src/lib/features/course/components/ceritficate/certificate-settings.svelte
@@ -1,15 +1,10 @@
@@ -101,97 +43,8 @@
{$t('course.navItem.certificates.allow')}
- {#if courseApi.course?.certificate?.isDownloadable && certificateValidationErrors.includes('course.certification.deadline_required')}
- {$t('course.certification.deadline_required')}
- {/if}
-
-
-
-
-
- {$t('course.certification.deadline_label')}
- {#if courseApi.course?.certificate?.isDownloadable}
- *
- {/if}
-
-
- {$t('course.certification.deadline_helper')}
- {#if errors['certificate.deadline']}
- {errors['certificate.deadline']}
- {/if}
- {#if certificateValidationErrors.includes('course.certification.deadline_required')}
- {$t('course.certification.deadline_required')}
- {/if}
-
-
-
-
-
- {$t('course.certification.threshold_label')}
-
- {$t('course.certification.threshold_helper')}
- {#if errors['certificate.threshold']}
- {errors['certificate.threshold']}
- {/if}
-
-
-
-
- {$t('course.certification.final_exercise_label')}
-
- {$t('course.certification.final_exercise_helper')}
- {$t('course.certification.final_exercise_multiple_attempts_note')}
-
-
- {#if courseApi.course?.certificate?.requiredExerciseId}
-
- {$t('course.certification.min_exercise_score_label')}
-
- {$t('course.certification.min_exercise_score_helper')}
- {#if errors['certificate.exerciseMinScorePercent']}
- {errors['certificate.exerciseMinScorePercent']}
- {/if}
-
- {/if}
-
diff --git a/apps/dashboard/src/lib/features/course/pages/settings.svelte b/apps/dashboard/src/lib/features/course/pages/settings.svelte
index 074d5dc5..a5bfa5f7 100644
--- a/apps/dashboard/src/lib/features/course/pages/settings.svelte
+++ b/apps/dashboard/src/lib/features/course/pages/settings.svelte
@@ -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;
+ }
@@ -434,8 +512,8 @@
-