From 60056a6236530b05e254b8bd4718f17faf1fd4de Mon Sep 17 00:00:00 2001 From: Victor Jack <67293106+thedon-dev@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:28:32 +0100 Subject: [PATCH] fix: validation of allowing teachers create compliance courses without setting deadline (#778) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fixed Validation of allowing teachers create compliance courses without setting deadline * greptile highlightCertificateSettings decraration suggestion * greptile multiple error bar showing fix suggestion * organization race condition * Delete screenshots/Screenshot 2026-07-22 at 11.52.00 AM.png * deleted unwanted files * code review fixes * Changed AttentionHightlight component to work with just one parameter created fixed Route Enums, Paths and Sections for * Kebab-case naming fix * Changes for code comments --------- Co-authored-by: Rotimi Best --- .../ai-tutor-settings/api/ai-tutor.svelte.ts | 9 ++ .../pages/course-settings.svelte | 23 +++-- .../pages/org-settings.svelte | 18 +++- .../lib/features/course/api/course.svelte.ts | 9 ++ .../ceritficate/certificate-edit.svelte | 13 ++- .../ceritficate/certificate-settings.svelte | 99 ++++++++++++------- .../course-context-menu-content.svelte | 9 +- .../course/components/exercise/store.ts | 2 +- .../lib/features/course/pages/settings.svelte | 90 ++++++++++++----- .../features/ui/attention-highlight.svelte | 72 ++++++++++++++ apps/dashboard/src/lib/features/ui/index.ts | 1 + .../src/lib/routing/go-and-highlight.ts | 25 +++++ apps/dashboard/src/lib/routing/routes.ts | 41 ++++++++ .../src/lib/utils/translations/en.json | 5 + .../(app)/org/[slug]/setup/+page.svelte | 6 +- .../course/[slug]/enroll/+page.svelte | 20 +++- 16 files changed, 359 insertions(+), 83 deletions(-) create mode 100644 apps/dashboard/src/lib/features/ui/attention-highlight.svelte create mode 100644 apps/dashboard/src/lib/routing/go-and-highlight.ts create mode 100644 apps/dashboard/src/lib/routing/routes.ts diff --git a/apps/dashboard/src/lib/features/ai-tutor-settings/api/ai-tutor.svelte.ts b/apps/dashboard/src/lib/features/ai-tutor-settings/api/ai-tutor.svelte.ts index f2994a9f..0c6c64bc 100644 --- a/apps/dashboard/src/lib/features/ai-tutor-settings/api/ai-tutor.svelte.ts +++ b/apps/dashboard/src/lib/features/ai-tutor-settings/api/ai-tutor.svelte.ts @@ -23,13 +23,22 @@ class AiTutorApi extends BaseApiWithErrors { this.loading = true; try { + let settings: OrgAiTutorSettings | null = null; + await this.execute({ requestFn: () => classroomio.organization['ai-tutor'].$get(), logContext: 'fetching org AI tutor settings', onSuccess: (response) => { + settings = response.data; this.orgSettings = response.data; + }, + onError: () => { + settings = null; + this.orgSettings = null; + snackbar.error('aiTutor.snackbar.fetchError'); } }); + return settings; } finally { this.loading = false; } diff --git a/apps/dashboard/src/lib/features/ai-tutor-settings/pages/course-settings.svelte b/apps/dashboard/src/lib/features/ai-tutor-settings/pages/course-settings.svelte index 07356657..0657a2bf 100644 --- a/apps/dashboard/src/lib/features/ai-tutor-settings/pages/course-settings.svelte +++ b/apps/dashboard/src/lib/features/ai-tutor-settings/pages/course-settings.svelte @@ -9,6 +9,7 @@ import { Switch } from '@cio/ui/base/switch'; import { Label } from '@cio/ui/base/label'; import { t } from '$lib/utils/functions/translations'; + import { AttentionHighlight } from '$features/ui'; import { aiTutorApi } from '../api/ai-tutor.svelte'; import TutorSettingsForm from '../components/tutor-settings-form.svelte'; @@ -91,19 +92,23 @@

{$t('aiTutor.state.loading')}

{:else} - - {$t('aiTutor.section.inheritance')} - {$t('aiTutor.section.inheritance_description')} + + + {$t('aiTutor.section.inheritance')} + {$t('aiTutor.section.inheritance_description')} - - - - - + + + + + + {#if !inheritFromOrg} - + + + {/if} {/if} diff --git a/apps/dashboard/src/lib/features/ai-tutor-settings/pages/org-settings.svelte b/apps/dashboard/src/lib/features/ai-tutor-settings/pages/org-settings.svelte index 2b4407df..2b50cf42 100644 --- a/apps/dashboard/src/lib/features/ai-tutor-settings/pages/org-settings.svelte +++ b/apps/dashboard/src/lib/features/ai-tutor-settings/pages/org-settings.svelte @@ -1,5 +1,4 @@ {$t('upgrade.certificate')} 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 0b3742e7..f18d6e0b 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 @@ -7,7 +7,9 @@ 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; @@ -70,6 +72,16 @@ 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)); @@ -85,45 +97,62 @@ }} disabled={$isFreePlan} /> - - {$t('course.navItem.certificates.allow')} - +
+ + {$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')} - - {$t('course.certification.deadline_helper')} - {#if errors['certificate.deadline']} - {errors['certificate.deadline']} - {/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.threshold_label')} + + {$t('course.certification.threshold_helper')} + {#if errors['certificate.threshold']} + {errors['certificate.threshold']} + {/if} + + {$t('course.certification.final_exercise_label')} diff --git a/apps/dashboard/src/lib/features/course/components/course-context-menu-content.svelte b/apps/dashboard/src/lib/features/course/components/course-context-menu-content.svelte index 415e52e0..707d56f8 100644 --- a/apps/dashboard/src/lib/features/course/components/course-context-menu-content.svelte +++ b/apps/dashboard/src/lib/features/course/components/course-context-menu-content.svelte @@ -7,6 +7,8 @@ import { copyPublicCoursePageUrl, openCoursePreview } from '$features/course/utils/course-preview'; import { currentOrgDomain } from '$lib/utils/store/org'; import { t } from '$lib/utils/functions/translations'; + import { goAndHighlight } from '$lib/routing/go-and-highlight'; + import { ROUTE_NAME, ROUTE_SECTIONS } from '$lib/routing/routes'; interface Props { id: string; @@ -65,12 +67,7 @@ } function handlePublishCourse() { - if (page.url.pathname === courseSettingsPath) { - scrollToSettingsSection('publish'); - return; - } - - redirect(`/courses/${id}/settings#publish`); + goAndHighlight(ROUTE_NAME.COURSE_SETTINGS, ROUTE_SECTIONS[ROUTE_NAME.COURSE_SETTINGS].PUBLISH, { id }); } function handleDeleteCourse() { diff --git a/apps/dashboard/src/lib/features/course/components/exercise/store.ts b/apps/dashboard/src/lib/features/course/components/exercise/store.ts index d97938da..ea1bf86e 100644 --- a/apps/dashboard/src/lib/features/course/components/exercise/store.ts +++ b/apps/dashboard/src/lib/features/course/components/exercise/store.ts @@ -418,7 +418,7 @@ export function handleReorderQuestionsInSection(exerciseSectionId: string, order .map((questionId) => questionById.get(String(questionId))) .filter( (question): question is Question => - Boolean(question) && !question.deletedAt && question.exerciseSectionId === exerciseSectionId + Boolean(question) && !question?.deletedAt && question?.exerciseSectionId === exerciseSectionId ); const missingSectionQuestions = sectionQuestions.filter( (question) => !orderedQuestionIdSet.has(String(question.id)) diff --git a/apps/dashboard/src/lib/features/course/pages/settings.svelte b/apps/dashboard/src/lib/features/course/pages/settings.svelte index f9b83bdc..074d5dc5 100644 --- a/apps/dashboard/src/lib/features/course/pages/settings.svelte +++ b/apps/dashboard/src/lib/features/course/pages/settings.svelte @@ -6,6 +6,7 @@ import { Switch } from '@cio/ui/base/switch'; import * as RadioGroup from '@cio/ui/base/radio-group'; import * as Select from '@cio/ui/base/select'; + import * as Dialog from '@cio/ui/base/dialog'; import RotateCcwIcon from '@lucide/svelte/icons/rotate-ccw'; import ArrowUpRightIcon from '@lucide/svelte/icons/arrow-up-right'; import XIcon from '@lucide/svelte/icons/x'; @@ -16,8 +17,10 @@ import { TextareaField } from '@cio/ui/custom/textarea-field'; import { InputField } from '@cio/ui/custom/input-field'; import * as Field from '@cio/ui/base/field'; - import { UpgradeBanner, UnsavedChanges, UploadWidget, TextEditor } from '$features/ui'; + 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 Copy from '@lucide/svelte/icons/copy'; @@ -48,6 +51,7 @@ let isLoading = $state(false); let isDeleting = $state(false); + let openCertificateDeadlineDialog = $state(false); let errors: { title: string | undefined; description: string | undefined; @@ -158,6 +162,38 @@ isDeleting = false; } + function onPublishToggle(checked: boolean) { + if (!checked) { + $settings.isPublished = false; + hasUnsavedChanges = true; + return; + } + + const isCourseCompliance = $settings.type === 'COMPLIANCE'; + const hasCertificateDeadline = !!courseApi.course?.certificate?.deadline; + + if (isCourseCompliance && !hasCertificateDeadline) { + openCertificateDeadlineDialog = true; + return; + } + + // Otherwise, publish normally + $settings.isPublished = true; + $settings.allowNewStudents = true; + hasUnsavedChanges = true; + } + + async function goToCertificateSettings() { + 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' + }); + } + export async function handleSave() { if (!$settings.courseTitle) { errors.title = $t('snackbar.course_settings.error.title'); @@ -347,17 +383,6 @@ loadCourseTags(courseId); }); - $effect(() => { - const sectionId = $page.url.hash.replace('#', '').trim(); - if (!sectionId) { - return; - } - - requestAnimationFrame(() => { - document.getElementById(sectionId)?.scrollIntoView({ behavior: 'smooth', block: 'start' }); - }); - }); - const selectedTagChips = $derived.by(() => { const allTags = tagApi.tagGroups.flatMap((group) => group.tags.map((tag) => ({ @@ -397,6 +422,25 @@ + + + + {$t('course.navItem.settings.certificate_deadline_required')} + + {$t('course.navItem.settings.certificate_deadline_required_description')} + + + + + + + + + {$t('course.navItem.settings.cover_image')} @@ -848,20 +892,14 @@ {$t('course.navItem.settings.publish')} {$t('course.navItem.settings.determines')} - - { - $settings.isPublished = checked; - if (checked) $settings.allowNewStudents = true; - hasUnsavedChanges = true; - }} - /> - - + + + + + + {#if showLockedContentNotice} diff --git a/apps/dashboard/src/lib/features/ui/attention-highlight.svelte b/apps/dashboard/src/lib/features/ui/attention-highlight.svelte new file mode 100644 index 00000000..aa7188b6 --- /dev/null +++ b/apps/dashboard/src/lib/features/ui/attention-highlight.svelte @@ -0,0 +1,72 @@ + + +
+ {#if children} + {@render children()} + {/if} +
diff --git a/apps/dashboard/src/lib/features/ui/index.ts b/apps/dashboard/src/lib/features/ui/index.ts index 5bf0e094..e9b4dbf5 100644 --- a/apps/dashboard/src/lib/features/ui/index.ts +++ b/apps/dashboard/src/lib/features/ui/index.ts @@ -1,5 +1,6 @@ export { default as ActivityCard } from './analytics/activity-card.svelte'; export { default as AppHeader } from './navigation/app-header.svelte'; +export { default as AttentionHighlight } from './attention-highlight.svelte'; export { default as AuthUI } from './auth-ui.svelte'; export { default as Backdrop } from './backdrop.svelte'; export { default as CloseButton } from './close-btn.svelte'; diff --git a/apps/dashboard/src/lib/routing/go-and-highlight.ts b/apps/dashboard/src/lib/routing/go-and-highlight.ts new file mode 100644 index 00000000..f97d5e3c --- /dev/null +++ b/apps/dashboard/src/lib/routing/go-and-highlight.ts @@ -0,0 +1,25 @@ +import { goto } from '$app/navigation'; +import { resolve } from '$app/paths'; +import { ROUTE_PATHS, type ROUTE_NAME, type RouteValues, type SectionsFor } from './routes'; + +export function goAndHighlight( + routeName: R, + sectionName: SectionsFor, + routeValues: RouteValues +) { + let path: string = ROUTE_PATHS[routeName]; + const query = new URLSearchParams(); + + for (const [key, value] of Object.entries(routeValues)) { + if (value == null) continue; + const token = `[${key}]`; + if (path.includes(token)) { + path = path.replace(token, encodeURIComponent(value)); + } else { + query.set(key, value); + } + } + + query.set('highlight', sectionName); + goto(resolve(`${path}?${query.toString()}`, {})); +} diff --git a/apps/dashboard/src/lib/routing/routes.ts b/apps/dashboard/src/lib/routing/routes.ts new file mode 100644 index 00000000..0890d4e5 --- /dev/null +++ b/apps/dashboard/src/lib/routing/routes.ts @@ -0,0 +1,41 @@ +export enum ROUTE_NAME { + COURSE = 'COURSE', + COURSE_SETTINGS = 'COURSE_SETTINGS', + COURSE_CERTIFICATE = 'COURSE_CERTIFICATE', + COURSE_AI_TUTOR = 'COURSE_AI_TUTOR' +} + +export const ROUTE_PATHS = { + [ROUTE_NAME.COURSE]: '/courses/[id]', + [ROUTE_NAME.COURSE_SETTINGS]: '/courses/[id]/settings', + [ROUTE_NAME.COURSE_CERTIFICATE]: '/courses/[id]/certificates', + [ROUTE_NAME.COURSE_AI_TUTOR]: '/courses/[id]/ai-tutor' +} as const; + +export const ROUTE_SECTIONS = { + [ROUTE_NAME.COURSE_SETTINGS]: { + PUBLISH: 'publish' + }, + + [ROUTE_NAME.COURSE_CERTIFICATE]: { + THRESHOLD: 'cert-threshold', + CERT_DEADLINE: 'cert-deadline', + CERT_EMAIL_MESSAGE: 'cert-email-message' + }, + + [ROUTE_NAME.COURSE_AI_TUTOR]: { + INHERITANCE: 'inheritance', + TUTOR_SETTINGS: 'tutor-settings' + } +} as const; + +export type SectionsFor = R extends keyof typeof ROUTE_SECTIONS + ? (typeof ROUTE_SECTIONS)[R][keyof (typeof ROUTE_SECTIONS)[R]] + : never; + +export type ExtractParams = T extends `${string}[${infer Param}]${infer Rest}` + ? Param | ExtractParams + : never; + +export type RouteValues = Record, string> & + Record; diff --git a/apps/dashboard/src/lib/utils/translations/en.json b/apps/dashboard/src/lib/utils/translations/en.json index 26e126ce..8352cb4f 100644 --- a/apps/dashboard/src/lib/utils/translations/en.json +++ b/apps/dashboard/src/lib/utils/translations/en.json @@ -2028,6 +2028,8 @@ "rules_heading": "Rules", "deadline_label": "Certification deadline", "deadline_helper": "Students must meet the completion percentage before this datetime (local time). Leave empty for no deadline.", + "deadline_required": "Certificate deadline is required when certificates are downloadable.", + "deadline_required_enroll": "Compliance courses require a certificate deadline before learners can be enrolled.", "threshold_label": "Minimum completion (%)", "threshold_helper": "Combined lesson and exercise progress required to earn the certificate (0–100). Default is 100%.", "final_exercise_label": "Final exercise (optional)", @@ -3649,6 +3651,9 @@ "determines": "This determines if your course displays on your landing page", "published": "Published", "unpublished": "Unpublished", + "certificate_deadline_required": "Set Certificate Deadline First", + "certificate_deadline_required_description": "Compliance courses require a certificate deadline before learners can be enrolled. Please set a deadline in the certificate settings before publishing.", + "go_to_certificate_settings": "Go to Certificate Settings", "locked_content": { "title": "{count} course item(s) are locked", "description": "Enrolled students won't be able to take locked lessons or exercises until you unlock them.", diff --git a/apps/dashboard/src/routes/(app)/org/[slug]/setup/+page.svelte b/apps/dashboard/src/routes/(app)/org/[slug]/setup/+page.svelte index c33f8d71..059b7620 100644 --- a/apps/dashboard/src/routes/(app)/org/[slug]/setup/+page.svelte +++ b/apps/dashboard/src/routes/(app)/org/[slug]/setup/+page.svelte @@ -17,6 +17,8 @@ import { profile } from '$lib/utils/store/user'; import { t } from '$lib/utils/functions/translations'; import { setupProgressApi } from '$features/setup/api/setup-progress.svelte'; + import { ROUTE_NAME, ROUTE_SECTIONS } from '$lib/routing/routes'; + import { goAndHighlight } from '$lib/routing/go-and-highlight'; let { data } = $props(); @@ -96,7 +98,9 @@ const courses = setupProgressApi.progress.courses || []; const courseId = courses[0]?.id; if (courseId) { - goto(`/courses/${courseId}/settings`); + goAndHighlight(ROUTE_NAME.COURSE_SETTINGS, ROUTE_SECTIONS[ROUTE_NAME.COURSE_SETTINGS].PUBLISH, { + id: courseId + }); } } else { snackbar.info('setup.info_course'); diff --git a/apps/dashboard/src/routes/(org-site)/course/[slug]/enroll/+page.svelte b/apps/dashboard/src/routes/(org-site)/course/[slug]/enroll/+page.svelte index 1c68f931..c9eae6bb 100644 --- a/apps/dashboard/src/routes/(org-site)/course/[slug]/enroll/+page.svelte +++ b/apps/dashboard/src/routes/(org-site)/course/[slug]/enroll/+page.svelte @@ -22,6 +22,7 @@ let enrollmentSucceeded = $state(false); let verificationEmailSent = $state(false); let enrolledPendingVerification = $state(false); + let enrollmentError = $state(null); const session = authClient.useSession(); const sessionReady = $derived(!$session.isPending && !$session.isRefetching); @@ -110,6 +111,7 @@ enrollmentInFlight = true; loading = true; + enrollmentError = null; let navigatingAway = false; @@ -118,7 +120,7 @@ const result = await courseApi.enroll(data.course.id, body); if (!result?.data) { - snackbar.error('snackbar.invite.failed_join'); + enrollmentError = t.get('snackbar.invite.failed_join'); return; } @@ -147,6 +149,10 @@ navigatingAway = true; await goto(resolve(getStudentCourseContinuePath(data.course.id), {})); + } catch (error) { + console.error('[enroll] unexpected error during enrollment:', error); + enrollmentError = t.get('snackbar.invite.failed_join'); + snackbar.error('snackbar.invite.failed_join'); } finally { enrollmentInFlight = false; if (!navigatingAway) { @@ -186,7 +192,15 @@ } $effect(() => { - if (!sessionReady || !isLoggedIn || !canJoinCourse || enrollmentInFlight || loading || enrollmentSucceeded) { + if ( + !sessionReady || + !isLoggedIn || + !canJoinCourse || + enrollmentInFlight || + loading || + enrollmentSucceeded || + enrollmentError + ) { return; } @@ -245,6 +259,8 @@ > {$t('course.navItem.landing_page.enroll_page.back_to_course')} + {:else if enrollmentError} +

{enrollmentError}

{:else if !canJoinCourse}

{getBlockedMessage()}

{/if}