From bf3a7e7057e8e93c216d91d41805db80bca71297 Mon Sep 17 00:00:00 2001 From: Rotimi Best Date: Wed, 19 Aug 2026 11:47:43 +0100 Subject: [PATCH] fix(dashboard): hide compliance in-progress badge and enforce deadline on view-site publish (#913) * fix(dashboard): hide in-progress badge on compliance learner cards Keep the due date on My Learning and LMS current-course cards, and only show compliance status badges for states that need attention. Co-authored-by: best * fix(dashboard): enforce compliance deadline when publishing from view course site Reuse the settings deadline-required dialog and navigate to the completion deadline section so unpublished-site publish matches settings and the API. Co-authored-by: best * fix(dashboard): keep attention highlight visible after deadline navigation Prevent goto and dialog focus restoration from scrolling away from the completion deadline section before the highlight pulse can render. Co-authored-by: best * fix(dashboard): keep deadline attention highlight visible longer Use onMount plus afterNavigate so the completion deadline ring is not lost to navigation scroll races, and show a stronger primary ring for five seconds. Co-authored-by: best * test(dashboard): use Jest APIs in compliance badge tests Drop the Vitest import so the new test matches the dashboard's declared Jest runner and @types/jest globals. Co-authored-by: best --------- --- .../features/course/components/card.svelte | 10 ++- ...ertificate-deadline-required-dialog.svelte | 31 ++++++++ .../view-course-site-unpublished-modal.svelte | 31 +++++++- .../lib/features/course/pages/settings.svelte | 27 ++----- .../course/utils/compliance-deadline.ts | 22 ++++++ .../course/utils/compliance-utils.test.ts | 28 +++++++ .../features/course/utils/compliance-utils.ts | 6 ++ .../features/course/utils/publish-course.ts | 21 +++++- .../features/lms/components/learning.svelte | 7 +- .../features/ui/attention-highlight.svelte | 75 ++++++++++++++----- .../src/lib/routing/go-and-highlight.ts | 2 +- apps/dashboard/src/lib/routing/routes.ts | 3 +- 12 files changed, 210 insertions(+), 53 deletions(-) create mode 100644 apps/dashboard/src/lib/features/course/components/certificate-deadline-required-dialog.svelte create mode 100644 apps/dashboard/src/lib/features/course/utils/compliance-deadline.ts create mode 100644 apps/dashboard/src/lib/features/course/utils/compliance-utils.test.ts diff --git a/apps/dashboard/src/lib/features/course/components/card.svelte b/apps/dashboard/src/lib/features/course/components/card.svelte index 15ada2eb..2d423ac6 100644 --- a/apps/dashboard/src/lib/features/course/components/card.svelte +++ b/apps/dashboard/src/lib/features/course/components/card.svelte @@ -23,7 +23,8 @@ import { getStudentCourseComplianceDate, getStudentCourseComplianceStatusKey, - getStudentCourseComplianceStatusVariant + getStudentCourseComplianceStatusVariant, + shouldShowStudentCourseComplianceStatusBadge } from '$features/course/utils/compliance-utils'; import CardDropdown from './card-dropdown.svelte'; import CoursePublishBadge from './course-publish-badge.svelte'; @@ -195,6 +196,11 @@ ? getStudentCourseComplianceStatusKey(course as UserEnrolledCourses[number]) : null ); + const showComplianceStatusBadge = $derived( + isLMS && type === 'COMPLIANCE' && !isExplore + ? shouldShowStudentCourseComplianceStatusBadge(course as UserEnrolledCourses[number]) + : false + ); const complianceStatusVariant = $derived( isLMS && type === 'COMPLIANCE' && !isExplore ? getStudentCourseComplianceStatusVariant(course as UserEnrolledCourses[number]) @@ -300,7 +306,7 @@ {#if type === 'COMPLIANCE'}
- {#if complianceStatusKey} + {#if showComplianceStatusBadge && complianceStatusKey} {$t(complianceStatusKey)} diff --git a/apps/dashboard/src/lib/features/course/components/certificate-deadline-required-dialog.svelte b/apps/dashboard/src/lib/features/course/components/certificate-deadline-required-dialog.svelte new file mode 100644 index 00000000..f7f5f386 --- /dev/null +++ b/apps/dashboard/src/lib/features/course/components/certificate-deadline-required-dialog.svelte @@ -0,0 +1,31 @@ + + + + e.preventDefault()}> + + {$t('course.navItem.settings.certificate_deadline_required')} + + {$t('course.navItem.settings.certificate_deadline_required_description')} + + + + + + + + diff --git a/apps/dashboard/src/lib/features/course/components/view-course-site-unpublished-modal.svelte b/apps/dashboard/src/lib/features/course/components/view-course-site-unpublished-modal.svelte index 81b45306..6832090f 100644 --- a/apps/dashboard/src/lib/features/course/components/view-course-site-unpublished-modal.svelte +++ b/apps/dashboard/src/lib/features/course/components/view-course-site-unpublished-modal.svelte @@ -1,10 +1,13 @@ - + e.preventDefault()}> {$t('course.view_course_site.unpublished_title')} {$t('course.view_course_site.unpublished_description')} @@ -64,3 +87,5 @@ + + diff --git a/apps/dashboard/src/lib/features/course/pages/settings.svelte b/apps/dashboard/src/lib/features/course/pages/settings.svelte index 953e6510..ebf2720f 100644 --- a/apps/dashboard/src/lib/features/course/pages/settings.svelte +++ b/apps/dashboard/src/lib/features/course/pages/settings.svelte @@ -6,12 +6,12 @@ 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'; import ReorderMaterialTabs from '$features/course/components/reorder-material-tabs.svelte'; + import CertificateDeadlineRequiredDialog from '$features/course/components/certificate-deadline-required-dialog.svelte'; import { CourseTagPicker } from '$features/course/components'; import { IconButton } from '@cio/ui/custom/icon-button'; import { TextareaField } from '@cio/ui/custom/textarea-field'; @@ -43,6 +43,7 @@ import { handleOpenWidget } from '$features/ui/course-landing-page/store'; import { currentOrgDomain, currentOrgPath, isFreePlan } from '$lib/utils/store/org'; import { page } from '$app/stores'; + import { ROUTE_NAME, ROUTE_SECTIONS } from '$lib/routing/routes'; interface Props { hasUnsavedChanges?: boolean; @@ -519,24 +520,7 @@ - - - - {$t('course.navItem.settings.certificate_deadline_required')} - - {$t('course.navItem.settings.certificate_deadline_required_description')} - - - - - - - - + @@ -705,7 +689,10 @@ {/if} - + {$t('course.navItem.settings.completion_deadline_label')} diff --git a/apps/dashboard/src/lib/features/course/utils/compliance-deadline.ts b/apps/dashboard/src/lib/features/course/utils/compliance-deadline.ts new file mode 100644 index 00000000..8277f9e3 --- /dev/null +++ b/apps/dashboard/src/lib/features/course/utils/compliance-deadline.ts @@ -0,0 +1,22 @@ +import { isPublishedComplianceMissingDeadline } from '@cio/utils/functions'; +import { goAndHighlight } from '$lib/routing/go-and-highlight'; +import { ROUTE_NAME, ROUTE_SECTIONS } from '$lib/routing/routes'; +import type { Course } from './types'; + +type CourseDeadlineSource = Pick & { + certificate?: { deadline?: string | null } | null; +}; + +export function isCourseMissingComplianceDeadline(course: CourseDeadlineSource): boolean { + return isPublishedComplianceMissingDeadline({ + type: course.type, + isPublished: true, + deadline: course.certificate?.deadline + }); +} + +export function goToCourseCompletionDeadline(courseId: string) { + goAndHighlight(ROUTE_NAME.COURSE_SETTINGS, ROUTE_SECTIONS[ROUTE_NAME.COURSE_SETTINGS].COMPLETION_DEADLINE, { + id: courseId + }); +} diff --git a/apps/dashboard/src/lib/features/course/utils/compliance-utils.test.ts b/apps/dashboard/src/lib/features/course/utils/compliance-utils.test.ts new file mode 100644 index 00000000..a4fef9b0 --- /dev/null +++ b/apps/dashboard/src/lib/features/course/utils/compliance-utils.test.ts @@ -0,0 +1,28 @@ +import { shouldShowStudentCourseComplianceStatusBadge, getStudentCourseComplianceStatusKey } from './compliance-utils'; + +function makeCourse(overrides: { type?: string; complianceStatus?: string | null }) { + return { + type: overrides.type ?? 'COMPLIANCE', + complianceStatus: overrides.complianceStatus + } as Parameters[0]; +} + +describe('shouldShowStudentCourseComplianceStatusBadge', () => { + it('hides the in-progress badge on learner course cards', () => { + const course = makeCourse({ complianceStatus: 'in_progress' }); + + expect(shouldShowStudentCourseComplianceStatusBadge(course)).toBe(false); + expect(getStudentCourseComplianceStatusKey(course)).toBe('course.navItem.compliance.status.in_progress'); + }); + + it('still shows overdue and expiring statuses', () => { + expect(shouldShowStudentCourseComplianceStatusBadge(makeCourse({ complianceStatus: 'non_compliant' }))).toBe(true); + expect(shouldShowStudentCourseComplianceStatusBadge(makeCourse({ complianceStatus: 'expiring_soon' }))).toBe(true); + }); + + it('is hidden for non-compliance courses', () => { + expect( + shouldShowStudentCourseComplianceStatusBadge(makeCourse({ type: 'SELF_PACED', complianceStatus: 'in_progress' })) + ).toBe(false); + }); +}); diff --git a/apps/dashboard/src/lib/features/course/utils/compliance-utils.ts b/apps/dashboard/src/lib/features/course/utils/compliance-utils.ts index 4b0bb3bf..afcc5d37 100644 --- a/apps/dashboard/src/lib/features/course/utils/compliance-utils.ts +++ b/apps/dashboard/src/lib/features/course/utils/compliance-utils.ts @@ -98,6 +98,12 @@ export function getStudentCourseComplianceStatusKey(course: StudentEnrolledCours return complianceStatus ? `course.navItem.compliance.status.${complianceStatus}` : null; } +export function shouldShowStudentCourseComplianceStatusBadge(course: StudentEnrolledCourse) { + const complianceStatus = getStudentCourseComplianceStatus(course); + + return !!complianceStatus && complianceStatus !== 'in_progress'; +} + export function getStudentCourseComplianceStatusVariant(course: StudentEnrolledCourse) { const complianceStatus = getStudentCourseComplianceStatus(course); if (!complianceStatus) { diff --git a/apps/dashboard/src/lib/features/course/utils/publish-course.ts b/apps/dashboard/src/lib/features/course/utils/publish-course.ts index aba34016..689fc3c4 100644 --- a/apps/dashboard/src/lib/features/course/utils/publish-course.ts +++ b/apps/dashboard/src/lib/features/course/utils/publish-course.ts @@ -2,10 +2,17 @@ import { generateSlug } from '@cio/utils/functions'; import { isObject } from '$lib/utils/functions/isObject'; import { courseApi } from '$features/course/api'; import type { Course } from '$features/course/utils/types'; +import { isCourseMissingComplianceDeadline } from './compliance-deadline'; -export async function publishCourse(course: Course): Promise { +export type PublishCourseResult = { ok: true } | { ok: false; reason: 'missing_deadline' | 'failed' }; + +export async function publishCourse(course: Course): Promise { if (!course?.id) { - return false; + return { ok: false, reason: 'failed' }; + } + + if (isCourseMissingComplianceDeadline(course)) { + return { ok: false, reason: 'missing_deadline' }; } let slug = course.slug?.trim() ? course.slug : undefined; @@ -28,5 +35,13 @@ export async function publishCourse(course: Course): Promise { { showSuccessToast: true } ); - return !!result; + if (!result) { + if (courseApi.errors['certificate.deadline']) { + return { ok: false, reason: 'missing_deadline' }; + } + + return { ok: false, reason: 'failed' }; + } + + return { ok: true }; } diff --git a/apps/dashboard/src/lib/features/lms/components/learning.svelte b/apps/dashboard/src/lib/features/lms/components/learning.svelte index 88a17bd6..ad4d2697 100644 --- a/apps/dashboard/src/lib/features/lms/components/learning.svelte +++ b/apps/dashboard/src/lib/features/lms/components/learning.svelte @@ -11,7 +11,8 @@ getStudentCourseComplianceDate, getStudentCourseComplianceStatusKey, getStudentCourseComplianceStatusVariant, - getStudentCourseProgressPercent + getStudentCourseProgressPercent, + shouldShowStudentCourseComplianceStatusBadge } from '$features/course/utils/compliance-utils'; import { getStudentCourseContinuePath } from '$features/course/utils/student-course-navigation'; @@ -60,7 +61,7 @@ > {#if highlightedCourses.length > 0}
- {#each highlightedCourses as course} + {#each highlightedCourses as course (course.id)}
- {#if complianceStatusKey} + {#if shouldShowStudentCourseComplianceStatusBadge(course) && complianceStatusKey} {$t(complianceStatusKey)} diff --git a/apps/dashboard/src/lib/features/ui/attention-highlight.svelte b/apps/dashboard/src/lib/features/ui/attention-highlight.svelte index 2bf92bed..4aaa445e 100644 --- a/apps/dashboard/src/lib/features/ui/attention-highlight.svelte +++ b/apps/dashboard/src/lib/features/ui/attention-highlight.svelte @@ -1,8 +1,8 @@ -
+
{#if children} {@render children()} {/if} diff --git a/apps/dashboard/src/lib/routing/go-and-highlight.ts b/apps/dashboard/src/lib/routing/go-and-highlight.ts index f97d5e3c..85aa0277 100644 --- a/apps/dashboard/src/lib/routing/go-and-highlight.ts +++ b/apps/dashboard/src/lib/routing/go-and-highlight.ts @@ -21,5 +21,5 @@ export function goAndHighlight( } query.set('highlight', sectionName); - goto(resolve(`${path}?${query.toString()}`, {})); + goto(resolve(`${path}?${query.toString()}`, {}), { noScroll: true, keepFocus: true }); } diff --git a/apps/dashboard/src/lib/routing/routes.ts b/apps/dashboard/src/lib/routing/routes.ts index 0890d4e5..bb396a1c 100644 --- a/apps/dashboard/src/lib/routing/routes.ts +++ b/apps/dashboard/src/lib/routing/routes.ts @@ -14,7 +14,8 @@ export const ROUTE_PATHS = { export const ROUTE_SECTIONS = { [ROUTE_NAME.COURSE_SETTINGS]: { - PUBLISH: 'publish' + PUBLISH: 'publish', + COMPLETION_DEADLINE: 'course-completion-deadline' }, [ROUTE_NAME.COURSE_CERTIFICATE]: {