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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

---------
This commit is contained in:
Rotimi Best
2026-08-19 11:47:43 +01:00
committed by GitHub
parent 1ed12622ab
commit bf3a7e7057
12 changed files with 210 additions and 53 deletions
@@ -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'}
<div class="mt-2 flex flex-wrap items-center gap-2">
{#if complianceStatusKey}
{#if showComplianceStatusBadge && complianceStatusKey}
<Badge variant={complianceStatusVariant}>
{$t(complianceStatusKey)}
</Badge>
@@ -0,0 +1,31 @@
<script lang="ts">
import { Button } from '@cio/ui/base/button';
import * as Dialog from '@cio/ui/base/dialog';
import { t } from '$lib/utils/functions/translations';
interface Props {
open?: boolean;
onGoToDeadline: () => void;
}
let { open = $bindable(false), onGoToDeadline }: Props = $props();
</script>
<Dialog.Root bind:open>
<Dialog.Content class="max-w-md" onCloseAutoFocus={(e) => e.preventDefault()}>
<Dialog.Header>
<Dialog.Title>{$t('course.navItem.settings.certificate_deadline_required')}</Dialog.Title>
<Dialog.Description>
{$t('course.navItem.settings.certificate_deadline_required_description')}
</Dialog.Description>
</Dialog.Header>
<Dialog.Footer>
<Button variant="outline" onclick={() => (open = false)}>
{$t('cancel')}
</Button>
<Button onclick={onGoToDeadline}>
{$t('course.navItem.settings.go_to_completion_deadline')}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
@@ -1,10 +1,13 @@
<script lang="ts">
import { tick } from 'svelte';
import { Button } from '@cio/ui/base/button';
import * as Dialog from '@cio/ui/base/dialog';
import { courseApi } from '$features/course/api';
import { publishCourse } from '$features/course/utils/publish-course';
import { openCoursePreview } from '$features/course/utils/course-preview';
import { goToCourseCompletionDeadline } from '$features/course/utils/compliance-deadline';
import { t } from '$lib/utils/functions/translations';
import CertificateDeadlineRequiredDialog from './certificate-deadline-required-dialog.svelte';
interface Props {
open?: boolean;
@@ -14,6 +17,7 @@
let { open = $bindable(false), currentOrgDomain = '' }: Props = $props();
let isPublishing = $state(false);
let openDeadlineDialog = $state(false);
async function handlePublishCourse() {
const course = courseApi.course;
@@ -23,10 +27,17 @@
isPublishing = true;
const published = await publishCourse(course);
const result = await publishCourse(course);
isPublishing = false;
if (!published) {
if (!result.ok && result.reason === 'missing_deadline') {
open = false;
await tick();
openDeadlineDialog = true;
return;
}
if (!result.ok) {
return;
}
@@ -38,6 +49,18 @@
currentOrgDomain
});
}
async function goToCompletionDeadline() {
const courseId = courseApi.course?.id;
openDeadlineDialog = false;
if (!courseId) {
return;
}
await tick();
goToCourseCompletionDeadline(courseId);
}
</script>
<Dialog.Root
@@ -48,7 +71,7 @@
}
}}
>
<Dialog.Content class="w-[calc(100%-2rem)] max-w-md! p-4">
<Dialog.Content class="w-[calc(100%-2rem)] max-w-md! p-4" onCloseAutoFocus={(e) => e.preventDefault()}>
<Dialog.Header>
<Dialog.Title>{$t('course.view_course_site.unpublished_title')}</Dialog.Title>
<Dialog.Description>{$t('course.view_course_site.unpublished_description')}</Dialog.Description>
@@ -64,3 +87,5 @@
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<CertificateDeadlineRequiredDialog bind:open={openDeadlineDialog} onGoToDeadline={goToCompletionDeadline} />
@@ -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 @@
<DeleteModal onDelete={handleDeleteCourse} bind:open={openDeleteModal} />
<Dialog.Root bind:open={openCertificateDeadlineDialog}>
<Dialog.Content class="max-w-md">
<Dialog.Header>
<Dialog.Title>{$t('course.navItem.settings.certificate_deadline_required')}</Dialog.Title>
<Dialog.Description>
{$t('course.navItem.settings.certificate_deadline_required_description')}
</Dialog.Description>
</Dialog.Header>
<Dialog.Footer>
<Button variant="outline" onclick={() => (openCertificateDeadlineDialog = false)}>
{$t('cancel')}
</Button>
<Button onclick={goToCompletionDeadline}>
{$t('course.navItem.settings.go_to_completion_deadline')}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<CertificateDeadlineRequiredDialog bind:open={openCertificateDeadlineDialog} onGoToDeadline={goToCompletionDeadline} />
<Field.Group class="w-full max-w-md! px-2">
<Field.Set>
@@ -705,7 +689,10 @@
{/if}
<Field.Group class="mt-3">
<AttentionHighlight id="course-completion-deadline" trigger={completionDeadlineTrigger}>
<AttentionHighlight
id={ROUTE_SECTIONS[ROUTE_NAME.COURSE_SETTINGS].COMPLETION_DEADLINE}
trigger={completionDeadlineTrigger}
>
<Field.Field>
<Field.Label>
{$t('course.navItem.settings.completion_deadline_label')}
@@ -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<Course, 'type'> & {
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
});
}
@@ -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<typeof shouldShowStudentCourseComplianceStatusBadge>[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);
});
});
@@ -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) {
@@ -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<boolean> {
export type PublishCourseResult = { ok: true } | { ok: false; reason: 'missing_deadline' | 'failed' };
export async function publishCourse(course: Course): Promise<PublishCourseResult> {
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<boolean> {
{ 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 };
}