feat: certification rules, content sanitization, handle draft course updates and reset content, better redis logging
This commit is contained in:
@@ -6,7 +6,8 @@
|
||||
|
||||
const isNotFound = $derived(page.status === 404);
|
||||
|
||||
console.error('error message', page.error?.message);
|
||||
console.error('Error message:', page.error?.message);
|
||||
console.error('Error page:', page.url);
|
||||
|
||||
function goHome() {
|
||||
window.location.href = '/';
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import { Confetti } from '$features/ui';
|
||||
import { courseApi } from '$features/course/api';
|
||||
import ContentCreateModal from '$features/course/components/content/content-create-modal.svelte';
|
||||
import CourseCompletionModal from '$features/course/components/ceritficate/course-completion-modal.svelte';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { isOrgAdmin } from '$lib/utils/store/org';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
@@ -98,6 +99,7 @@
|
||||
>
|
||||
<CourseHeader />
|
||||
<ContentCreateModal />
|
||||
<CourseCompletionModal />
|
||||
|
||||
{#if !isCourseReady}
|
||||
<div class="mx-auto flex h-[calc(100vh-56px)] w-full items-center justify-center">
|
||||
|
||||
@@ -1,10 +1,140 @@
|
||||
<script lang="ts">
|
||||
import { CertificatesPage } from '$features/course/pages';
|
||||
import { RefreshPageData } from '$features/ui';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { courseApi } from '$features/course/api';
|
||||
import type { Course } from '$features/course/utils/types';
|
||||
import { parseCertificateThemeId } from '$features/course/utils/certificate-utils';
|
||||
import { isFreePlan, isOrgAdmin } from '$lib/utils/store/org';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import ZapIcon from '@lucide/svelte/icons/zap';
|
||||
import { RefreshPageData, UnsavedChanges } from '$features/ui';
|
||||
import { ZCourseUpdate } from '@cio/utils/validation/course/course';
|
||||
import { validateWithTranslation } from '$lib/utils/validation';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { openUpgradeModal } from '$lib/utils/functions/org';
|
||||
|
||||
let errors = $state<Record<string, string>>({});
|
||||
let hasUnsavedChanges = $state(false);
|
||||
let savedCertificateState = $state<string | null>(null);
|
||||
let savedCertificateStateCourseId = $state<string | null>(null);
|
||||
|
||||
const certificateTheme = $derived(courseApi.course?.certificate?.theme ?? '');
|
||||
|
||||
const currentCertificateState = $derived.by(() => getCertificateStateSnapshot(courseApi.course));
|
||||
|
||||
const userRole = $derived.by(() => {
|
||||
const user = courseApi.group.people.find((person) => person.profileId === $profile.id);
|
||||
return user ? Number(user.roleId) : null;
|
||||
});
|
||||
|
||||
const canEditCertificates = $derived(!!$isOrgAdmin || (userRole !== null && [1, 2].includes(userRole)));
|
||||
|
||||
function getCertificateFormState(course: Course | null) {
|
||||
if (!course) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
description: course.description ?? '',
|
||||
certificate: {
|
||||
isDownloadable: course.certificate?.isDownloadable ?? false,
|
||||
theme: parseCertificateThemeId(course.certificate?.theme) ?? 'professional',
|
||||
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,
|
||||
emailMessage: course.certificate?.emailMessage ?? null
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function getCertificateStateSnapshot(course: Course | null) {
|
||||
const certificateFormState = getCertificateFormState(course);
|
||||
return certificateFormState ? JSON.stringify(certificateFormState) : null;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const course = courseApi.course;
|
||||
const currentSnapshot = getCertificateStateSnapshot(course);
|
||||
|
||||
if (!course?.id || !currentSnapshot) {
|
||||
savedCertificateState = null;
|
||||
savedCertificateStateCourseId = null;
|
||||
hasUnsavedChanges = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (savedCertificateStateCourseId !== course.id || savedCertificateState === null) {
|
||||
savedCertificateStateCourseId = course.id;
|
||||
savedCertificateState = currentSnapshot;
|
||||
}
|
||||
|
||||
hasUnsavedChanges = currentSnapshot !== savedCertificateState;
|
||||
});
|
||||
|
||||
async function saveCertificate() {
|
||||
if (!courseApi.course) return;
|
||||
|
||||
if ($isFreePlan) {
|
||||
errors = {};
|
||||
openUpgradeModal();
|
||||
return;
|
||||
}
|
||||
|
||||
const updatePayload = {
|
||||
description: courseApi.course.description ?? '',
|
||||
certificate: {
|
||||
isDownloadable: courseApi.course.certificate?.isDownloadable ?? false,
|
||||
theme: certificateTheme || 'professional',
|
||||
deadline: courseApi.course.certificate?.deadline ?? null,
|
||||
threshold:
|
||||
typeof courseApi.course.certificate?.threshold === 'number' ? courseApi.course.certificate.threshold : 100,
|
||||
requiredExerciseId: courseApi.course.certificate?.requiredExerciseId ?? null,
|
||||
exerciseMinScorePercent:
|
||||
typeof courseApi.course.certificate?.exerciseMinScorePercent === 'number'
|
||||
? courseApi.course.certificate.exerciseMinScorePercent
|
||||
: courseApi.course.certificate?.requiredExerciseId
|
||||
? 100
|
||||
: null,
|
||||
emailMessage: courseApi.course.certificate?.emailMessage ?? null
|
||||
}
|
||||
};
|
||||
|
||||
const validated = validateWithTranslation(ZCourseUpdate, updatePayload, 'course');
|
||||
if ('errors' in validated) {
|
||||
errors = validated.errors;
|
||||
return;
|
||||
}
|
||||
|
||||
errors = {};
|
||||
|
||||
const updated = await courseApi.update(courseApi.course.id!, validated.data, { showSuccessToast: false });
|
||||
if (updated) {
|
||||
savedCertificateState = getCertificateStateSnapshot(courseApi.course);
|
||||
hasUnsavedChanges = false;
|
||||
snackbar.success('snackbar.course_settings.success.saved');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Qwitcher+Grypen&family=Roboto:wght@300;400;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<UnsavedChanges bind:hasUnsavedChanges />
|
||||
|
||||
<Page.Root class="mx-auto flex w-[calc(95vw-var(--sidebar-width))]!">
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
@@ -13,12 +143,26 @@
|
||||
</Page.Title>
|
||||
</Page.HeaderContent>
|
||||
<Page.Action>
|
||||
<RefreshPageData />
|
||||
<div class="flex w-full justify-end gap-2">
|
||||
{#if canEditCertificates}
|
||||
<Button
|
||||
onclick={saveCertificate}
|
||||
loading={courseApi.isLoading}
|
||||
disabled={courseApi.isLoading || !hasUnsavedChanges}
|
||||
>
|
||||
{#if $isFreePlan}
|
||||
<ZapIcon size={16} class="filled" />
|
||||
{/if}
|
||||
{$t('course.navItem.certificates.save')}
|
||||
</Button>
|
||||
{/if}
|
||||
<RefreshPageData />
|
||||
</div>
|
||||
</Page.Action>
|
||||
</Page.Header>
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<CertificatesPage />
|
||||
<CertificatesPage {errors} />
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</Page.Root>
|
||||
|
||||
@@ -5,7 +5,7 @@ export const load = async ({ params, cookies }) => {
|
||||
const exerciseId = params.exerciseId || '';
|
||||
|
||||
if (!courseId || !exerciseId) {
|
||||
return { courseId, exerciseId, exercise: null, submissions: [], mySubmission: null };
|
||||
return { courseId, exerciseId, exercise: null, submissions: [], mySubmissions: [] };
|
||||
}
|
||||
|
||||
const headers = getApiHeaders(cookies);
|
||||
@@ -26,7 +26,7 @@ export const load = async ({ params, cookies }) => {
|
||||
overviewResult.success && overviewResult.data ? overviewResult.data : { mySubmission: [], allSubmissions: [] };
|
||||
|
||||
const mySubmissionData = Array.isArray(overview.mySubmission) ? overview.mySubmission : [];
|
||||
const mySubmission = mySubmissionData.length > 0 ? mySubmissionData[0] : null;
|
||||
const mySubmissions = mySubmissionData;
|
||||
|
||||
const submissions = Array.isArray(overview.allSubmissions) ? overview.allSubmissions : [];
|
||||
|
||||
@@ -35,6 +35,6 @@ export const load = async ({ params, cookies }) => {
|
||||
exerciseId,
|
||||
exercise,
|
||||
submissions,
|
||||
mySubmission
|
||||
mySubmissions
|
||||
};
|
||||
};
|
||||
|
||||
@@ -63,7 +63,8 @@
|
||||
isDescriptionDirty: false,
|
||||
isDueByDirty: false,
|
||||
questions: questions,
|
||||
totalSubmissions: 0
|
||||
totalSubmissions: 0,
|
||||
allowMultipleAttempts: !!exercise.allowMultipleAttempts
|
||||
});
|
||||
questionnaireMetaData.update((m) => ({ ...m, exerciseId: data.exerciseId }));
|
||||
});
|
||||
@@ -84,7 +85,7 @@
|
||||
goBack={() => goto(resolve(path, {}))}
|
||||
isFetching={false}
|
||||
submissions={data.submissions ?? []}
|
||||
mySubmission={data.mySubmission ?? null}
|
||||
mySubmissions={data.mySubmissions ?? []}
|
||||
/>
|
||||
{/if}
|
||||
</Page.Root>
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
let settingsComponent: CourseSettingsPage | null = $state(null);
|
||||
let isSaving = $state(false);
|
||||
let hasUnsavedChanges = $state(false);
|
||||
let { data } = $props();
|
||||
|
||||
async function handleSave() {
|
||||
@@ -30,7 +31,7 @@
|
||||
</Page.HeaderContent>
|
||||
<Page.Action>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button variant="secondary" loading={isSaving} onclick={handleSave}>
|
||||
<Button variant="secondary" loading={isSaving} disabled={isSaving || !hasUnsavedChanges} onclick={handleSave}>
|
||||
{$t('course.navItem.settings.save')}
|
||||
</Button>
|
||||
<RefreshPageData disabled={isSaving} onRefresh={() => courseApi.refreshCourse(data.courseId, $profile.id)} />
|
||||
@@ -39,7 +40,7 @@
|
||||
</Page.Header>
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<CourseSettingsPage bind:this={settingsComponent} />
|
||||
<CourseSettingsPage bind:this={settingsComponent} bind:hasUnsavedChanges />
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</Page.Root>
|
||||
|
||||
@@ -1,30 +1,41 @@
|
||||
import { classroomio, getApiHeaders } from '$lib/utils/services/api';
|
||||
import {
|
||||
DEFAULT_ORG_AUDIENCE_QUERY,
|
||||
getAudienceQueryFromSearchParams,
|
||||
toAudienceRequestQuery
|
||||
} from '$lib/features/org/utils/audience-query-utils';
|
||||
|
||||
export const load = async ({ parent, cookies }) => {
|
||||
export const load = async ({ parent, cookies, url }) => {
|
||||
const { orgId } = await parent();
|
||||
|
||||
if (!orgId) {
|
||||
return {
|
||||
audience: [],
|
||||
pagination: null,
|
||||
query: DEFAULT_ORG_AUDIENCE_QUERY,
|
||||
courses: []
|
||||
};
|
||||
}
|
||||
|
||||
const headers = getApiHeaders(cookies, orgId);
|
||||
const query = getAudienceQueryFromSearchParams(url.searchParams);
|
||||
|
||||
const [audienceResponse, coursesResponse] = await Promise.all([
|
||||
classroomio.organization.audience.$get({}, headers),
|
||||
classroomio.organization.audience.$get({ query: toAudienceRequestQuery(query)! }, headers),
|
||||
classroomio.organization.courses.$get({ query: { tags: undefined } }, headers)
|
||||
]);
|
||||
|
||||
const audienceData = await audienceResponse.json();
|
||||
const audience = audienceData.success ? audienceData.data : [];
|
||||
const pagination = audienceData.success ? audienceData.pagination : null;
|
||||
|
||||
const coursesData = await coursesResponse.json();
|
||||
const courses = coursesData.success ? coursesData.data : [];
|
||||
|
||||
return {
|
||||
audience,
|
||||
pagination,
|
||||
query,
|
||||
courses
|
||||
};
|
||||
};
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
isLoading = false;
|
||||
}
|
||||
|
||||
const audienceLength = $derived(data.audience?.length || 0);
|
||||
const audienceLength = $derived(data.pagination?.total || 0);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -47,7 +47,7 @@
|
||||
</Page.Header>
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<AudiencePage audience={data.audience} courses={data.courses} />
|
||||
<AudiencePage audience={data.audience} pagination={data.pagination} query={data.query} courses={data.courses} />
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</Page.Root>
|
||||
|
||||
@@ -9,11 +9,6 @@ export const load = async ({ parent, locals, cookies, url }) => {
|
||||
const { orgId } = await parent();
|
||||
|
||||
if (!orgId || !locals.user?.id) {
|
||||
const loadMs = Math.round((performance.now() - loadStart) * 100) / 100;
|
||||
console.log(
|
||||
`[org/[slug]/courses +page.server] load: ${loadMs}ms (skipped: no orgId or user) | PUBLIC_IS_SELFHOSTED=${PUBLIC_IS_SELFHOSTED}`
|
||||
);
|
||||
|
||||
return {
|
||||
courses: []
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user