feat: certification rules, content sanitization, handle draft course updates and reset content, better redis logging
This commit is contained in:
+42
@@ -0,0 +1,42 @@
|
||||
<script lang="ts">
|
||||
import * as Dialog from '@cio/ui/base/dialog';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import type { OrganizationAudienceMember } from '$features/org/utils/types';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
member: OrganizationAudienceMember | null;
|
||||
isDeleting?: boolean;
|
||||
onDelete: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
let { open = $bindable(false), member, isDeleting = false, onDelete }: Props = $props();
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="w-full max-w-md">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{$t('audience.delete.title')}</Dialog.Title>
|
||||
<Dialog.Description>{$t('audience.delete.description')}</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="space-y-4">
|
||||
<p class="text-sm">
|
||||
{$t('audience.delete.confirmation', {
|
||||
email: member?.email ?? '',
|
||||
name: member?.name ?? member?.email ?? ''
|
||||
})}
|
||||
</p>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button variant="outline" onclick={() => (open = false)} disabled={isDeleting}>
|
||||
{$t('audience.delete.cancel')}
|
||||
</Button>
|
||||
<Button variant="destructive" onclick={onDelete} loading={isDeleting} disabled={!member || isDeleting}>
|
||||
{$t('audience.delete.confirm')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -20,16 +20,31 @@
|
||||
selected: boolean;
|
||||
onToggleSelect: () => void;
|
||||
inviteActionEmail: string | null;
|
||||
deletingMemberId: string | null;
|
||||
canDeleteMembers: boolean;
|
||||
onResendInvite: (email: string) => void;
|
||||
onRevokeInvite: (email: string) => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
let { row, memberDetailHref, selected, onToggleSelect, inviteActionEmail, onResendInvite, onRevokeInvite }: Props =
|
||||
$props();
|
||||
let {
|
||||
row,
|
||||
memberDetailHref,
|
||||
selected,
|
||||
onToggleSelect,
|
||||
inviteActionEmail,
|
||||
deletingMemberId,
|
||||
canDeleteMembers,
|
||||
onResendInvite,
|
||||
onRevokeInvite,
|
||||
onDelete
|
||||
}: Props = $props();
|
||||
|
||||
const showInviteActions = $derived(
|
||||
!row.profileId && (canResendAudienceInvite(row.status) || canRevokeAudienceInvite(row.status))
|
||||
);
|
||||
const isActionDisabled = $derived(inviteActionEmail === row.email || deletingMemberId === String(row.id));
|
||||
const showRowActions = $derived(showInviteActions || canDeleteMembers);
|
||||
</script>
|
||||
|
||||
<Table.Row>
|
||||
@@ -55,31 +70,40 @@
|
||||
</Table.Cell>
|
||||
<Table.Cell>{row.createdAt}</Table.Cell>
|
||||
<Table.Cell class="text-right">
|
||||
{#if showInviteActions}
|
||||
{#if showRowActions}
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
class="hover:ui:bg-muted inline-flex items-center justify-center rounded-md p-1.5"
|
||||
aria-label={$t('audience.invite.row_actions_aria')}
|
||||
disabled={inviteActionEmail !== null}
|
||||
disabled={isActionDisabled}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<EllipsisVerticalIcon class="ui:size-4 ui:text-muted-foreground" />
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
{#if canResendAudienceInvite(row.status)}
|
||||
<DropdownMenu.Item disabled={inviteActionEmail === row.email} onclick={() => onResendInvite(row.email)}>
|
||||
<DropdownMenu.Item disabled={isActionDisabled} onclick={() => onResendInvite(row.email)}>
|
||||
{$t('audience.invite.resend')}
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
{#if canRevokeAudienceInvite(row.status)}
|
||||
<DropdownMenu.Item
|
||||
class="ui:text-destructive focus:ui:text-destructive"
|
||||
disabled={inviteActionEmail === row.email}
|
||||
disabled={isActionDisabled}
|
||||
onclick={() => onRevokeInvite(row.email)}
|
||||
>
|
||||
{$t('audience.invite.revoke')}
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
{#if canDeleteMembers}
|
||||
<DropdownMenu.Item
|
||||
class="ui:text-destructive focus:ui:text-destructive"
|
||||
disabled={isActionDisabled}
|
||||
onclick={onDelete}
|
||||
>
|
||||
{$t('audience.delete.action')}
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
{/if}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import * as Select from '@cio/ui/base/select';
|
||||
import { Search } from '@cio/ui/custom/search';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
@@ -8,10 +9,28 @@
|
||||
hasSelection: boolean;
|
||||
selectedCount: number;
|
||||
searchValue?: string;
|
||||
sortValue: string;
|
||||
onSortChange: (value: string) => void;
|
||||
onOpenAssign: () => void;
|
||||
}
|
||||
|
||||
let { hasSelection, selectedCount, searchValue = $bindable(''), onOpenAssign }: Props = $props();
|
||||
let {
|
||||
hasSelection,
|
||||
selectedCount,
|
||||
searchValue = $bindable(''),
|
||||
sortValue,
|
||||
onSortChange,
|
||||
onOpenAssign
|
||||
}: Props = $props();
|
||||
|
||||
const sortOptions = [
|
||||
{ label: t.get('audience.sort.newest'), value: 'createdAt:desc' },
|
||||
{ label: t.get('audience.sort.oldest'), value: 'createdAt:asc' },
|
||||
{ label: t.get('audience.sort.name_asc'), value: 'name:asc' },
|
||||
{ label: t.get('audience.sort.name_desc'), value: 'name:desc' },
|
||||
{ label: t.get('audience.sort.email_asc'), value: 'email:asc' },
|
||||
{ label: t.get('audience.sort.email_desc'), value: 'email:desc' }
|
||||
];
|
||||
</script>
|
||||
|
||||
<Page.BodyHeader>
|
||||
@@ -25,6 +44,22 @@
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<Search placeholder={$t('audience.search_placeholder')} bind:value={searchValue} class="" />
|
||||
<div class="flex w-full flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<Search placeholder={$t('audience.search_placeholder')} bind:value={searchValue} class="w-full md:max-w-sm" />
|
||||
<Select.Root type="single" value={sortValue} onValueChange={onSortChange} name="audience-sort">
|
||||
<Select.Trigger class="w-full md:w-[220px]">
|
||||
{sortOptions.find((option) => option.value === sortValue)?.label ?? $t('audience.sort.label')}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Group>
|
||||
{#each sortOptions as option (option.value)}
|
||||
<Select.Item value={option.value} label={option.label} disabled={option.value === sortValue}>
|
||||
{option.label}
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Group>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
{/if}
|
||||
</Page.BodyHeader>
|
||||
|
||||
@@ -20,8 +20,11 @@
|
||||
isRowSelected: (rowId: string) => boolean;
|
||||
onToggleRow: (rowId: string) => void;
|
||||
inviteActionEmail: string | null;
|
||||
deletingMemberId: string | null;
|
||||
canDeleteMembers: boolean;
|
||||
onResendInvite: (email: string) => void;
|
||||
onRevokeInvite: (email: string) => void;
|
||||
onDeleteRow: (row: OrganizationAudienceMember) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -33,8 +36,11 @@
|
||||
isRowSelected,
|
||||
onToggleRow,
|
||||
inviteActionEmail,
|
||||
deletingMemberId,
|
||||
canDeleteMembers,
|
||||
onResendInvite,
|
||||
onRevokeInvite
|
||||
onRevokeInvite,
|
||||
onDeleteRow
|
||||
}: Props = $props();
|
||||
|
||||
function memberHref(row: OrganizationAudienceMember): string | null {
|
||||
@@ -71,8 +77,11 @@
|
||||
selected={isRowSelected(String(row.id))}
|
||||
onToggleSelect={() => onToggleRow(String(row.id))}
|
||||
{inviteActionEmail}
|
||||
{deletingMemberId}
|
||||
{canDeleteMembers}
|
||||
{onResendInvite}
|
||||
{onRevokeInvite}
|
||||
onDelete={() => onDeleteRow(row)}
|
||||
/>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export { default as AssignCoursesModal } from './assign-courses-modal.svelte';
|
||||
export { default as AudienceMemberRow } from './audience-member-row.svelte';
|
||||
export { default as AudienceDeleteConfirmation } from './audience-delete-confirmation.svelte';
|
||||
export { default as AudiencePagination } from './audience-pagination.svelte';
|
||||
export { default as AudienceTable } from './audience-table.svelte';
|
||||
export { default as AudienceTableSkeleton } from './audience-table-skeleton.svelte';
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import UsersIcon from '@lucide/svelte/icons/users';
|
||||
import { orgApi } from '$features/org/api/org.svelte';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
import { UpgradeBanner } from '$features/ui';
|
||||
import { currentOrgMaxAudience } from '$lib/utils/store/org';
|
||||
import type { OrganizationAudience } from '$features/org/utils/types';
|
||||
import { currentOrgMaxAudience, isOrgAdmin } from '$lib/utils/store/org';
|
||||
import type {
|
||||
OrganizationAudience,
|
||||
OrganizationAudienceMember,
|
||||
OrganizationAudiencePagination,
|
||||
OrganizationAudienceQuery
|
||||
} from '$features/org/utils/types';
|
||||
import AssignCoursesModal from '$features/audience/components/assign-courses-modal.svelte';
|
||||
import AudienceDeleteConfirmation from '$features/audience/components/audience-delete-confirmation.svelte';
|
||||
import AudienceTableToolbar from '$features/audience/components/audience-table-toolbar.svelte';
|
||||
import AudienceTableSkeleton from '$features/audience/components/audience-table-skeleton.svelte';
|
||||
import AudienceTable from '$features/audience/components/audience-table.svelte';
|
||||
import AudiencePagination from '$features/audience/components/audience-pagination.svelte';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import { DEFAULT_ORG_AUDIENCE_QUERY, getAudienceSearchParams } from '$features/org/utils/audience-query-utils';
|
||||
|
||||
interface Course {
|
||||
id: string;
|
||||
@@ -20,15 +28,17 @@
|
||||
|
||||
interface Props {
|
||||
audience?: OrganizationAudience;
|
||||
pagination?: OrganizationAudiencePagination | null;
|
||||
query: OrganizationAudienceQuery;
|
||||
courses?: Course[];
|
||||
}
|
||||
|
||||
let { audience, courses = [] }: Props = $props();
|
||||
let { audience, pagination = null, query, courses = [] }: Props = $props();
|
||||
|
||||
$effect(() => {
|
||||
if (audience) {
|
||||
orgApi.audience = audience;
|
||||
}
|
||||
orgApi.audience = audience ?? [];
|
||||
orgApi.audiencePagination = pagination;
|
||||
selectedIds.clear();
|
||||
});
|
||||
|
||||
const headers = $derived([
|
||||
@@ -39,11 +49,19 @@
|
||||
]);
|
||||
|
||||
let inviteActionEmail = $state<string | null>(null);
|
||||
let deletingMemberId = $state<string | null>(null);
|
||||
let deleteCandidate = $state<OrganizationAudienceMember | null>(null);
|
||||
let deleteDialogOpen = $state(false);
|
||||
let searchValue = $state(query.search ?? '');
|
||||
let sortValue = $state(`${query.sortBy}:${query.sortOrder}`);
|
||||
|
||||
async function handleResendInvite(email: string) {
|
||||
inviteActionEmail = email;
|
||||
try {
|
||||
await orgApi.resendAudienceInvite({ email });
|
||||
const result = await orgApi.resendAudienceInvite({ email });
|
||||
if (result) {
|
||||
await refreshAudience();
|
||||
}
|
||||
} finally {
|
||||
inviteActionEmail = null;
|
||||
}
|
||||
@@ -52,32 +70,22 @@
|
||||
async function handleRevokeInvite(email: string) {
|
||||
inviteActionEmail = email;
|
||||
try {
|
||||
await orgApi.revokeAudienceInvite({ email });
|
||||
const result = await orgApi.revokeAudienceInvite({ email });
|
||||
if (result) {
|
||||
await refreshAudience();
|
||||
}
|
||||
} finally {
|
||||
inviteActionEmail = null;
|
||||
}
|
||||
}
|
||||
|
||||
let pageSize = $state(20);
|
||||
let currentPage = $state(1);
|
||||
let searchValue = $state('');
|
||||
let selectedIds = new SvelteSet<string>();
|
||||
let assignModalOpen = $state(false);
|
||||
|
||||
const filteredRows = $derived(
|
||||
searchValue
|
||||
? orgApi.audience.filter((row) =>
|
||||
Object.values(row).some((val) => String(val).toLowerCase().includes(searchValue.toLowerCase()))
|
||||
)
|
||||
: orgApi.audience
|
||||
);
|
||||
|
||||
const totalPages = $derived(Math.ceil(filteredRows.length / pageSize));
|
||||
const startIndex = $derived((currentPage - 1) * pageSize);
|
||||
const endIndex = $derived(startIndex + pageSize);
|
||||
const paginatedRows = $derived(filteredRows.slice(startIndex, endIndex));
|
||||
|
||||
const selectablePageRows = $derived(paginatedRows.filter((row) => row.profileId));
|
||||
const currentPage = $derived(pagination?.page ?? DEFAULT_ORG_AUDIENCE_QUERY.page);
|
||||
const pageSize = $derived(pagination?.limit ?? DEFAULT_ORG_AUDIENCE_QUERY.limit);
|
||||
const totalCount = $derived(pagination?.total ?? 0);
|
||||
const selectablePageRows = $derived(orgApi.audience.filter((row) => row.profileId));
|
||||
|
||||
const allPageSelected = $derived(
|
||||
selectablePageRows.length > 0 && selectablePageRows.every((row) => selectedIds.has(String(row.id)))
|
||||
@@ -117,13 +125,110 @@
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (filteredRows.length > 0 && currentPage > totalPages) {
|
||||
currentPage = 1;
|
||||
}
|
||||
searchValue = query.search ?? '';
|
||||
sortValue = `${query.sortBy}:${query.sortOrder}`;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const normalizedSearch = searchValue.trim() || undefined;
|
||||
const currentSearch = query.search ?? undefined;
|
||||
|
||||
if (normalizedSearch === currentSearch) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
void navigateAudience({
|
||||
...query,
|
||||
page: 1,
|
||||
search: normalizedSearch
|
||||
});
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
});
|
||||
|
||||
async function refreshAudience(pageOverride = currentPage) {
|
||||
await navigateAudience({
|
||||
...query,
|
||||
page: pageOverride
|
||||
});
|
||||
}
|
||||
|
||||
async function navigateAudience(nextQuery: OrganizationAudienceQuery) {
|
||||
const searchParams = getAudienceSearchParams(nextQuery);
|
||||
const nextSearch = searchParams.toString();
|
||||
const currentSearch = page.url.searchParams.toString();
|
||||
|
||||
await goto(`${page.url.pathname}?${nextSearch}`, {
|
||||
replaceState: nextSearch === currentSearch,
|
||||
keepFocus: true,
|
||||
noScroll: true,
|
||||
invalidateAll: true
|
||||
});
|
||||
}
|
||||
|
||||
function handlePageChange(nextPage: number) {
|
||||
if (nextPage === currentPage) {
|
||||
return;
|
||||
}
|
||||
|
||||
void navigateAudience({
|
||||
...query,
|
||||
page: nextPage
|
||||
});
|
||||
}
|
||||
|
||||
function handleSortChange(nextSortValue: string) {
|
||||
if (nextSortValue === sortValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
sortValue = nextSortValue;
|
||||
const [sortBy, sortOrder] = nextSortValue.split(':') as [
|
||||
OrganizationAudienceQuery['sortBy'],
|
||||
OrganizationAudienceQuery['sortOrder']
|
||||
];
|
||||
|
||||
void navigateAudience({
|
||||
...query,
|
||||
page: 1,
|
||||
sortBy,
|
||||
sortOrder
|
||||
});
|
||||
}
|
||||
|
||||
function openDeleteConfirmation(member: OrganizationAudienceMember) {
|
||||
deleteCandidate = member;
|
||||
deleteDialogOpen = true;
|
||||
}
|
||||
|
||||
async function handleDeleteAudienceMember() {
|
||||
if (!deleteCandidate) {
|
||||
return;
|
||||
}
|
||||
|
||||
deletingMemberId = String(deleteCandidate.id);
|
||||
|
||||
try {
|
||||
const result = await orgApi.deleteAudienceMember(Number(deleteCandidate.id));
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
|
||||
selectedIds.delete(String(deleteCandidate.id));
|
||||
|
||||
const nextPage = orgApi.audience.length === 1 && currentPage > 1 ? currentPage - 1 : currentPage;
|
||||
await refreshAudience(nextPage);
|
||||
deleteCandidate = null;
|
||||
deleteDialogOpen = false;
|
||||
} finally {
|
||||
deletingMemberId = null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if orgApi.audience.length >= $currentOrgMaxAudience}
|
||||
{#if totalCount >= $currentOrgMaxAudience}
|
||||
<UpgradeBanner>{$t('audience.upgrade')}</UpgradeBanner>
|
||||
{/if}
|
||||
|
||||
@@ -131,34 +236,30 @@
|
||||
{hasSelection}
|
||||
selectedCount={selectedIds.size}
|
||||
bind:searchValue
|
||||
{sortValue}
|
||||
onSortChange={handleSortChange}
|
||||
onOpenAssign={() => (assignModalOpen = true)}
|
||||
/>
|
||||
|
||||
{#if orgApi.audience.length || orgApi.isLoading}
|
||||
{#if totalCount > 0}
|
||||
<div class="w-full space-y-4">
|
||||
{#if orgApi.isLoading}
|
||||
<AudienceTableSkeleton rowCount={pageSize} />
|
||||
{:else}
|
||||
<AudienceTable
|
||||
{headers}
|
||||
rows={paginatedRows}
|
||||
{allPageSelected}
|
||||
{somePageSelected}
|
||||
onToggleSelectAll={toggleSelectAll}
|
||||
isRowSelected={(id) => selectedIds.has(id)}
|
||||
onToggleRow={toggleRow}
|
||||
{inviteActionEmail}
|
||||
onResendInvite={handleResendInvite}
|
||||
onRevokeInvite={handleRevokeInvite}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<AudiencePagination
|
||||
count={filteredRows.length}
|
||||
perPage={pageSize}
|
||||
page={currentPage}
|
||||
onPageChange={(p) => (currentPage = p)}
|
||||
<AudienceTable
|
||||
{headers}
|
||||
rows={orgApi.audience}
|
||||
{allPageSelected}
|
||||
{somePageSelected}
|
||||
onToggleSelectAll={toggleSelectAll}
|
||||
isRowSelected={(id) => selectedIds.has(id)}
|
||||
onToggleRow={toggleRow}
|
||||
{inviteActionEmail}
|
||||
{deletingMemberId}
|
||||
canDeleteMembers={$isOrgAdmin === true}
|
||||
onResendInvite={handleResendInvite}
|
||||
onRevokeInvite={handleRevokeInvite}
|
||||
onDeleteRow={openDeleteConfirmation}
|
||||
/>
|
||||
|
||||
<AudiencePagination count={totalCount} perPage={pageSize} page={currentPage} onPageChange={handlePageChange} />
|
||||
</div>
|
||||
{:else}
|
||||
<Empty title={$t('audience.no_audience')} description={$t('audience.manage')} icon={UsersIcon} variant="page" />
|
||||
@@ -171,3 +272,10 @@
|
||||
onclose={() => (assignModalOpen = false)}
|
||||
onassigned={handleAssigned}
|
||||
/>
|
||||
|
||||
<AudienceDeleteConfirmation
|
||||
bind:open={deleteDialogOpen}
|
||||
member={deleteCandidate}
|
||||
isDeleting={deletingMemberId !== null}
|
||||
onDelete={handleDeleteAudienceMember}
|
||||
/>
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
EnrollCourseRequest,
|
||||
GetCourseAnalyticsRequest,
|
||||
GetCourseBySlugRequest,
|
||||
GetCertificationEvaluationRequest,
|
||||
GetCourseProgressRequest,
|
||||
GetCourseRequest,
|
||||
UpdateCourseData,
|
||||
@@ -543,6 +544,27 @@ export class CourseApi extends BaseApiWithErrors {
|
||||
});
|
||||
}
|
||||
|
||||
async getCertificationEvaluation(courseId: string) {
|
||||
return this.execute<GetCertificationEvaluationRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId']['certification-evaluation'].$get({
|
||||
param: { courseId }
|
||||
}),
|
||||
logContext: 'fetching certification evaluation',
|
||||
onSuccess: (response) => {
|
||||
if (response.data) {
|
||||
this.success = true;
|
||||
this.errors = {};
|
||||
}
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error('Failed to load certification status');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets course analytics including student progress, completion rates, and grades
|
||||
* @param courseId Course ID
|
||||
@@ -635,7 +657,7 @@ export class CourseApi extends BaseApiWithErrors {
|
||||
const lessonTabsOrder = metadata?.lessonTabsOrder;
|
||||
if (lessonTabsOrder) {
|
||||
const existingTabIds = lessonTabsOrder.map((tab) => tab.id);
|
||||
const allTabs = [
|
||||
const allTabs: Array<{ id: 1 | 2 | 3 | 4; name: string }> = [
|
||||
{ id: 1, name: 'course.navItem.lessons.materials.tabs.note.title' },
|
||||
{ id: 2, name: 'course.navItem.lessons.materials.tabs.slide.title' },
|
||||
{ id: 3, name: 'course.navItem.lessons.materials.tabs.video.title' },
|
||||
@@ -661,9 +683,15 @@ export class CourseApi extends BaseApiWithErrors {
|
||||
};
|
||||
}
|
||||
|
||||
// Ensure certificateTheme has default value
|
||||
if (!data.certificateTheme) {
|
||||
data.certificateTheme = 'professional';
|
||||
if (!data.certificate) {
|
||||
data.certificate = {
|
||||
isDownloadable: false,
|
||||
theme: 'professional'
|
||||
};
|
||||
}
|
||||
|
||||
if (!data.certificate.theme) {
|
||||
data.certificate.theme = 'professional';
|
||||
}
|
||||
|
||||
// Set the course data
|
||||
|
||||
@@ -32,7 +32,7 @@ export class ExerciseApi extends BaseApiWithErrors {
|
||||
logContext: 'fetching exercise',
|
||||
onSuccess: (response) => {
|
||||
if (response.data) {
|
||||
this.exercise = response.data;
|
||||
this.exercise = response.data as Exercise;
|
||||
this.success = true;
|
||||
this.errors = {};
|
||||
}
|
||||
@@ -64,7 +64,7 @@ export class ExerciseApi extends BaseApiWithErrors {
|
||||
logContext: 'creating exercise',
|
||||
onSuccess: (response) => {
|
||||
if (response.data) {
|
||||
this.exercise = response.data;
|
||||
this.exercise = response.data as Exercise;
|
||||
snackbar.success('Exercise created successfully');
|
||||
this.success = true;
|
||||
this.errors = {};
|
||||
@@ -106,7 +106,7 @@ export class ExerciseApi extends BaseApiWithErrors {
|
||||
logContext: 'updating exercise',
|
||||
onSuccess: (response) => {
|
||||
if (response.data) {
|
||||
this.exercise = response.data;
|
||||
this.exercise = response.data as Exercise;
|
||||
this.success = true;
|
||||
this.errors = {};
|
||||
}
|
||||
@@ -163,7 +163,7 @@ export class ExerciseApi extends BaseApiWithErrors {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.execute<SubmitExerciseRequest>({
|
||||
return await this.execute<SubmitExerciseRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId'].exercise[':exerciseId'].submission.$post({
|
||||
param: { courseId, exerciseId },
|
||||
@@ -220,7 +220,7 @@ export class ExerciseApi extends BaseApiWithErrors {
|
||||
logContext: 'creating exercise from template',
|
||||
onSuccess: (response) => {
|
||||
if (response.data) {
|
||||
this.exercise = response.data;
|
||||
this.exercise = response.data as Exercise;
|
||||
snackbar.success('Exercise created successfully');
|
||||
this.success = true;
|
||||
this.errors = {};
|
||||
|
||||
@@ -38,7 +38,6 @@ import {
|
||||
import {
|
||||
ZLessonCommentCreate,
|
||||
ZLessonCommentUpdate,
|
||||
ZLessonCompletionUpdate,
|
||||
ZLessonCreate,
|
||||
ZLessonReorder,
|
||||
ZLessonUpdate
|
||||
@@ -46,8 +45,8 @@ import {
|
||||
|
||||
import type { TLocale } from '@cio/db/types';
|
||||
import { get } from 'svelte/store';
|
||||
import { mediaApi } from '$features/media/api';
|
||||
import { mapZodErrorsToTranslations } from '$lib/utils/validation';
|
||||
import { mediaApi } from '$features/media/api';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
|
||||
@@ -607,17 +606,11 @@ export class LessonApi extends BaseApiWithErrors {
|
||||
* Updates lesson completion
|
||||
*/
|
||||
async updateCompletion(courseId: string, lessonId: string, isComplete: boolean) {
|
||||
const result = ZLessonCompletionUpdate.safeParse({ isComplete });
|
||||
if (!result.success) {
|
||||
this.errors = mapZodErrorsToTranslations(result.error, 'lesson');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.execute<UpdateLessonCompletionRequest>({
|
||||
return this.execute<UpdateLessonCompletionRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId'].lesson[':lessonId'].completion.$put({
|
||||
param: { courseId, lessonId },
|
||||
json: result.data
|
||||
json: { isComplete }
|
||||
}),
|
||||
logContext: 'updating lesson completion',
|
||||
onSuccess: (response) => {
|
||||
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { Label } from '@cio/ui/base/label';
|
||||
import * as RadioGroup from '@cio/ui/base/radio-group';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { Textarea } from '@cio/ui/base/textarea';
|
||||
import * as Field from '@cio/ui/base/field';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { CERTIFICATE_THEME_IDS, type CertificateThemeId } from '$features/course/utils/constants';
|
||||
import { parseCertificateThemeId } from '$features/course/utils/certificate-utils';
|
||||
import { currentOrg, isFreePlan } from '$lib/utils/store/org';
|
||||
import Plain from './templates/plain.svelte';
|
||||
import Professional from './templates/professional.svelte';
|
||||
import BlueBadgePattern from './templates/blue-badge-pattern.svelte';
|
||||
import PurpleBadgePattern from './templates/purple-badge-pattern.svelte';
|
||||
import BlueProfessionalBadge from './templates/blue-professional-badge.svelte';
|
||||
import PurpleProfessionalBadge from './templates/purple-professional-badge.svelte';
|
||||
|
||||
type Props = {
|
||||
errors: Record<string, string>;
|
||||
};
|
||||
|
||||
let { errors }: Props = $props();
|
||||
|
||||
const studentNamePlaceholder = 'Name of student';
|
||||
|
||||
const certificateTheme = $derived(parseCertificateThemeId(courseApi.course?.certificate?.theme));
|
||||
|
||||
const helperText = $derived(
|
||||
`${courseApi.course?.description?.length || 0}/200 ${$t('course.navItem.certificates.characters')}`
|
||||
);
|
||||
|
||||
function onDescriptionInput(e: Event) {
|
||||
if (!courseApi.course) return;
|
||||
courseApi.course.description = (e.currentTarget as HTMLTextAreaElement).value;
|
||||
}
|
||||
|
||||
function goToOrgSettings() {
|
||||
goto(
|
||||
resolve('/org/[slug]/settings', {
|
||||
slug: $currentOrg.siteName || ''
|
||||
})
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mb-3 flex h-4/5 w-full flex-1 flex-col justify-between gap-3 lg:flex-row">
|
||||
<section class="h-full w-full lg:w-2/5">
|
||||
<Field.Group class="w-full max-w-md! px-2">
|
||||
<Field.Set>
|
||||
<Field.Legend>{$t('course.navItem.certificates.certificate_settings')}</Field.Legend>
|
||||
|
||||
<Field.Group>
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('course.navItem.certificates.theme')}</Field.Label>
|
||||
<RadioGroup.Root
|
||||
value={certificateTheme}
|
||||
onValueChange={(value) => {
|
||||
if (!courseApi.course) return;
|
||||
|
||||
courseApi.course.certificate = {
|
||||
...(courseApi.course.certificate ?? {}),
|
||||
theme: value as CertificateThemeId
|
||||
};
|
||||
}}
|
||||
disabled={$isFreePlan}
|
||||
class="mb-6"
|
||||
>
|
||||
<div class="flex flex-wrap justify-between gap-y-5">
|
||||
{#each CERTIFICATE_THEME_IDS as theme (theme)}
|
||||
<div class="mr-3 flex items-start space-x-2">
|
||||
<RadioGroup.Item value={theme} id={theme} />
|
||||
<Label for={theme} class="cursor-pointer">
|
||||
<img src={`/images/certificate_theme_${theme}.png`} alt="" class="h-[82px] w-[110px]" />
|
||||
</Label>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
</Field.Field>
|
||||
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('course.navItem.certificates.logo')}</Field.Label>
|
||||
<Field.Description>
|
||||
{$t('course.navItem.certificates.to_update')}
|
||||
<strong class="font-semibold">{$t('course.navItem.certificates.settings')}</strong>
|
||||
{$t('course.navItem.certificates.and_upload')}
|
||||
</Field.Description>
|
||||
<Button variant="outline" class="mt-3" onclick={goToOrgSettings}>
|
||||
{$t('course.navItem.certificates.goto_settings')}
|
||||
</Button>
|
||||
</Field.Field>
|
||||
|
||||
<Field.Field>
|
||||
<Field.Label for="cert-design-description">{$t('course.navItem.certificates.description')}</Field.Label>
|
||||
<Textarea
|
||||
id="cert-design-description"
|
||||
rows={6}
|
||||
class="w-full"
|
||||
placeholder={$t('course.navItem.certificates.placeholder')}
|
||||
value={courseApi.course?.description ?? ''}
|
||||
oninput={onDescriptionInput}
|
||||
disabled={$isFreePlan}
|
||||
/>
|
||||
<Field.Description>{helperText}</Field.Description>
|
||||
{#if errors.description}
|
||||
<Field.Error>{errors.description}</Field.Error>
|
||||
{/if}
|
||||
</Field.Field>
|
||||
</Field.Group>
|
||||
</Field.Set>
|
||||
</Field.Group>
|
||||
</section>
|
||||
<section class="flex w-full rounded-md lg:w-3/5">
|
||||
<div class="w-[95%]">
|
||||
{#if certificateTheme === 'professional'}
|
||||
<Professional studentName={studentNamePlaceholder} />
|
||||
{:else if certificateTheme === 'plain'}
|
||||
<Plain studentName={studentNamePlaceholder} />
|
||||
{:else if certificateTheme === 'purpleProfessionalBadge'}
|
||||
<PurpleProfessionalBadge studentName={studentNamePlaceholder} />
|
||||
{:else if certificateTheme === 'blueProfessionalBadge'}
|
||||
<BlueProfessionalBadge studentName={studentNamePlaceholder} />
|
||||
{:else if certificateTheme === 'purpleBadgePattern'}
|
||||
<PurpleBadgePattern studentName={studentNamePlaceholder} />
|
||||
{:else if certificateTheme === 'blueBadgePattern'}
|
||||
<BlueBadgePattern studentName={studentNamePlaceholder} />
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
import * as UnderlineTabs from '@cio/ui/custom/underline-tabs';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
import { UpgradeBanner } from '$features/ui';
|
||||
|
||||
import CertificateDesign from './certificate-design.svelte';
|
||||
import CertificateSettings from './certificate-settings.svelte';
|
||||
|
||||
type Props = {
|
||||
errors: Record<string, string>;
|
||||
};
|
||||
|
||||
let { errors }: Props = $props();
|
||||
|
||||
let activeTab = $state('design');
|
||||
</script>
|
||||
|
||||
<UpgradeBanner>{$t('upgrade.certificate')}</UpgradeBanner>
|
||||
|
||||
<main class="px-2 md:-mr-3 md:-ml-3">
|
||||
<UnderlineTabs.Root bind:value={activeTab} class="w-full">
|
||||
<UnderlineTabs.List>
|
||||
<UnderlineTabs.Trigger value="design">
|
||||
{$t('course.navItem.certificates.tab_design')}
|
||||
</UnderlineTabs.Trigger>
|
||||
<UnderlineTabs.Trigger value="settings">
|
||||
{$t('course.navItem.certificates.tab_settings')}
|
||||
</UnderlineTabs.Trigger>
|
||||
</UnderlineTabs.List>
|
||||
<UnderlineTabs.Content value="design" class="mt-4">
|
||||
<CertificateDesign {errors} />
|
||||
</UnderlineTabs.Content>
|
||||
<UnderlineTabs.Content value="settings" class="mt-4">
|
||||
<CertificateSettings {errors} />
|
||||
</UnderlineTabs.Content>
|
||||
</UnderlineTabs.Root>
|
||||
</main>
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
<script lang="ts">
|
||||
import { Switch } from '@cio/ui/base/switch';
|
||||
import { Input } from '@cio/ui/base/input';
|
||||
import { Textarea } from '@cio/ui/base/textarea';
|
||||
import * as Field from '@cio/ui/base/field';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { isFreePlan } from '$lib/utils/store/org';
|
||||
import { getOrderedNavigableContent } from '$features/course/utils/content';
|
||||
import { ContentType } from '@cio/utils/constants/content';
|
||||
|
||||
type Props = {
|
||||
errors: Record<string, string>;
|
||||
};
|
||||
|
||||
let { errors }: Props = $props();
|
||||
|
||||
function isoToDatetimeLocal(iso: string | null | undefined): string {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
function updateCertificate(patch: Partial<NonNullable<typeof courseApi.course>['certificate']>) {
|
||||
if (!courseApi.course) return;
|
||||
|
||||
courseApi.course.certificate = {
|
||||
...(courseApi.course.certificate ?? {}),
|
||||
...patch
|
||||
};
|
||||
}
|
||||
|
||||
function onCertDeadlineChange(e: Event) {
|
||||
const v = (e.currentTarget as HTMLInputElement).value;
|
||||
updateCertificate({ deadline: v ? new Date(v).toISOString() : null });
|
||||
}
|
||||
|
||||
const certExercises = $derived(
|
||||
getOrderedNavigableContent(courseApi.course).filter((item) => item.type === ContentType.Exercise)
|
||||
);
|
||||
|
||||
const finalExerciseSelectValue = $derived(courseApi.course?.certificate?.requiredExerciseId ?? 'none');
|
||||
|
||||
function onFinalExerciseChange(e: Event) {
|
||||
const v = (e.currentTarget as HTMLSelectElement).value;
|
||||
if (!v || v === 'none') {
|
||||
updateCertificate({ requiredExerciseId: null, exerciseMinScorePercent: null });
|
||||
return;
|
||||
}
|
||||
|
||||
const currentMin = courseApi.course?.certificate?.exerciseMinScorePercent;
|
||||
updateCertificate({
|
||||
requiredExerciseId: v,
|
||||
exerciseMinScorePercent: currentMin ?? 100
|
||||
});
|
||||
}
|
||||
|
||||
function onThresholdInput(e: Event) {
|
||||
const n = Number((e.currentTarget as HTMLInputElement).value);
|
||||
updateCertificate({ threshold: Number.isFinite(n) ? Math.min(100, Math.max(0, n)) : 100 });
|
||||
}
|
||||
|
||||
function onMinExerciseScoreInput(e: Event) {
|
||||
const n = Number((e.currentTarget as HTMLInputElement).value);
|
||||
updateCertificate({ exerciseMinScorePercent: Number.isFinite(n) ? Math.min(100, Math.max(0, n)) : 100 });
|
||||
}
|
||||
|
||||
function onEmailMessageInput(e: Event) {
|
||||
updateCertificate({ emailMessage: (e.currentTarget as HTMLTextAreaElement).value || null });
|
||||
}
|
||||
</script>
|
||||
|
||||
<Field.Group class="w-full max-w-md! px-2">
|
||||
<Field.Set>
|
||||
<Field.Legend>{$t('course.certification.rules_heading')}</Field.Legend>
|
||||
|
||||
<Field.Field orientation="horizontal">
|
||||
<Switch
|
||||
id="certificate-downloadable"
|
||||
checked={!!courseApi.course?.certificate?.isDownloadable}
|
||||
onCheckedChange={(checked) => {
|
||||
updateCertificate({ isDownloadable: checked });
|
||||
}}
|
||||
disabled={$isFreePlan}
|
||||
/>
|
||||
<Field.Label for="certificate-downloadable" class="text-gray-600">
|
||||
{$t('course.navItem.certificates.allow')}
|
||||
</Field.Label>
|
||||
</Field.Field>
|
||||
|
||||
<Field.Group>
|
||||
<Field.Field>
|
||||
<Field.Label for="cert-deadline">{$t('course.certification.deadline_label')}</Field.Label>
|
||||
<Input
|
||||
id="cert-deadline"
|
||||
type="datetime-local"
|
||||
class="w-full"
|
||||
value={isoToDatetimeLocal(courseApi.course?.certificate?.deadline ?? null)}
|
||||
onchange={onCertDeadlineChange}
|
||||
disabled={$isFreePlan}
|
||||
/>
|
||||
<Field.Description>{$t('course.certification.deadline_helper')}</Field.Description>
|
||||
{#if errors['certificate.deadline']}
|
||||
<Field.Error>{errors['certificate.deadline']}</Field.Error>
|
||||
{/if}
|
||||
</Field.Field>
|
||||
|
||||
<Field.Field>
|
||||
<Field.Label for="cert-threshold">{$t('course.certification.threshold_label')}</Field.Label>
|
||||
<Input
|
||||
id="cert-threshold"
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
class="w-full"
|
||||
value={String(courseApi.course?.certificate?.threshold ?? 100)}
|
||||
oninput={onThresholdInput}
|
||||
disabled={$isFreePlan}
|
||||
/>
|
||||
<Field.Description>{$t('course.certification.threshold_helper')}</Field.Description>
|
||||
{#if errors['certificate.threshold']}
|
||||
<Field.Error>{errors['certificate.threshold']}</Field.Error>
|
||||
{/if}
|
||||
</Field.Field>
|
||||
|
||||
<Field.Field>
|
||||
<Field.Label for="cert-final-exercise">{$t('course.certification.final_exercise_label')}</Field.Label>
|
||||
<select
|
||||
id="cert-final-exercise"
|
||||
class="ui:border-input ui:bg-background ui:text-foreground ui:ring-offset-background ui:shadow-xs ui:flex ui:h-9 ui:w-full ui:min-w-0 ui:rounded-md ui:border ui:px-3 ui:py-1 ui:text-base ui:outline-none ui:transition-[color,box-shadow] ui:disabled:cursor-not-allowed ui:disabled:opacity-50 ui:md:text-sm ui:focus-visible:border-ring ui:focus-visible:ring-ring/50 ui:focus-visible:ring-[3px]"
|
||||
value={finalExerciseSelectValue}
|
||||
onchange={onFinalExerciseChange}
|
||||
disabled={$isFreePlan}
|
||||
>
|
||||
<option value="none">{$t('course.certification.final_exercise_none')}</option>
|
||||
{#each certExercises as item (item.id)}
|
||||
<option value={item.id}>{item.title}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<Field.Description>{$t('course.certification.final_exercise_helper')}</Field.Description>
|
||||
</Field.Field>
|
||||
|
||||
{#if courseApi.course?.certificate?.requiredExerciseId}
|
||||
<Field.Field>
|
||||
<Field.Label for="cert-min-exercise-score">{$t('course.certification.min_exercise_score_label')}</Field.Label>
|
||||
<Input
|
||||
id="cert-min-exercise-score"
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
class="w-full"
|
||||
value={String(courseApi.course?.certificate?.exerciseMinScorePercent ?? 100)}
|
||||
oninput={onMinExerciseScoreInput}
|
||||
disabled={$isFreePlan}
|
||||
/>
|
||||
<Field.Description>{$t('course.certification.min_exercise_score_helper')}</Field.Description>
|
||||
{#if errors['certificate.exerciseMinScorePercent']}
|
||||
<Field.Error>{errors['certificate.exerciseMinScorePercent']}</Field.Error>
|
||||
{/if}
|
||||
</Field.Field>
|
||||
{/if}
|
||||
</Field.Group>
|
||||
</Field.Set>
|
||||
|
||||
<Field.Separator />
|
||||
|
||||
<Field.Set>
|
||||
<Field.Legend>{$t('course.certification.email_message_label')}</Field.Legend>
|
||||
<Field.Field>
|
||||
<Textarea
|
||||
id="cert-email-message"
|
||||
class="w-full"
|
||||
rows={4}
|
||||
placeholder={$t('course.certification.email_message_placeholder')}
|
||||
value={courseApi.course?.certificate?.emailMessage ?? ''}
|
||||
oninput={onEmailMessageInput}
|
||||
disabled={$isFreePlan}
|
||||
/>
|
||||
{#if errors['certificate.emailMessage']}
|
||||
<Field.Error>{errors['certificate.emailMessage']}</Field.Error>
|
||||
{/if}
|
||||
</Field.Field>
|
||||
</Field.Set>
|
||||
</Field.Group>
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import * as Dialog from '@cio/ui/base/dialog';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { Confetti } from '$features/ui';
|
||||
import { setConfetti } from '$features/ui/confetti/store';
|
||||
import { courseCompletionModal, closeCourseCompletionModal } from '$features/course/store/course-completion-modal';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
let open = $state(false);
|
||||
let activeCourseId = $state('');
|
||||
|
||||
onMount(() => {
|
||||
return courseCompletionModal.subscribe((v) => {
|
||||
if (v?.open && v.courseId) {
|
||||
activeCourseId = v.courseId;
|
||||
open = true;
|
||||
setConfetti(true);
|
||||
setTimeout(() => setConfetti(false), 5500);
|
||||
} else {
|
||||
open = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function onOpenChange(next: boolean) {
|
||||
open = next;
|
||||
if (!next) {
|
||||
closeCourseCompletionModal();
|
||||
}
|
||||
}
|
||||
|
||||
function goToCertificate() {
|
||||
if (!activeCourseId) return;
|
||||
onOpenChange(false);
|
||||
goto(resolve(`/courses/${activeCourseId}/certificates`, {}));
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="pointer-events-none fixed inset-0 z-100">
|
||||
<Confetti />
|
||||
</div>
|
||||
|
||||
<Dialog.Root {open} {onOpenChange}>
|
||||
<Dialog.Content class="ui:max-w-md">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{$t('course.completion.modal.title')}</Dialog.Title>
|
||||
<Dialog.Description>{$t('course.completion.modal.subtitle')}</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<Dialog.Footer class="ui:gap-2">
|
||||
<Button variant="outline" onclick={() => onOpenChange(false)}>{$t('course.completion.modal.later')}</Button>
|
||||
<Button onclick={goToCertificate}>{$t('course.completion.modal.view_certificate')}</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -1,196 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { Label } from '@cio/ui/base/label';
|
||||
import { Switch } from '@cio/ui/base/switch';
|
||||
import ZapIcon from '@lucide/svelte/icons/zap';
|
||||
import * as RadioGroup from '@cio/ui/base/radio-group';
|
||||
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { currentOrg, isFreePlan } from '$lib/utils/store/org';
|
||||
import { saveCertificateValidation } from '$lib/utils/functions/validator';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
|
||||
import Plain from './templates/plain.svelte';
|
||||
import Professional from './templates/professional.svelte';
|
||||
import { TextareaField } from '@cio/ui/custom/textarea-field';
|
||||
import { UpgradeBanner } from '$features/ui';
|
||||
import BlueBadgePattern from './templates/blue-badge-pattern.svelte';
|
||||
import PurpleBadgePattern from './templates/purple-badge-pattern.svelte';
|
||||
import BlueProfessionalBadge from './templates/blue-professional-badge.svelte';
|
||||
import PurpleProfessionalBadge from './templates/purple-professional-badge.svelte';
|
||||
|
||||
const studentNamePlaceholder = 'Name of student';
|
||||
const themes = [
|
||||
'professional',
|
||||
'plain',
|
||||
'purpleProfessionalBadge',
|
||||
'blueProfessionalBadge',
|
||||
'purpleBadgePattern',
|
||||
'blueBadgePattern'
|
||||
];
|
||||
|
||||
let errors = $state({
|
||||
description: ''
|
||||
});
|
||||
|
||||
const helperText = $derived(
|
||||
`${courseApi.course?.description?.length || 0}/200 ${$t('course.navItem.certificates.characters')}`
|
||||
);
|
||||
|
||||
let certificateTheme = $derived<string | undefined>(courseApi.course?.certificateTheme ?? undefined);
|
||||
|
||||
const saveCertificate = async () => {
|
||||
if (!courseApi.course) return;
|
||||
|
||||
// Prevent free plan users from bypassing UI restrictions
|
||||
if ($isFreePlan) {
|
||||
errors.description = 'Certificate customization is only available on paid plans';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = saveCertificateValidation({
|
||||
description: courseApi.course?.description || '',
|
||||
isCertificateDownloadable: courseApi.course?.isCertificateDownloadable || false,
|
||||
certificateTheme: certificateTheme || ''
|
||||
});
|
||||
|
||||
if (result && Object.keys(result).length > 0) {
|
||||
errors.description = $t(result.description) || $t('course.navItem.certificates.description_error');
|
||||
throw new Error(errors.description);
|
||||
}
|
||||
|
||||
errors.description = '';
|
||||
|
||||
await courseApi.update(courseApi.course.id!, {
|
||||
description: courseApi.course?.description || '',
|
||||
isCertificateDownloadable: courseApi.course?.isCertificateDownloadable || false,
|
||||
certificateTheme: certificateTheme || ''
|
||||
});
|
||||
|
||||
snackbar.success('snackbar.course_settings.success.saved');
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
errors.description = error.message;
|
||||
} else {
|
||||
errors.description = $t('course.navItem.certificates.unexpected_error');
|
||||
}
|
||||
}
|
||||
};
|
||||
</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>
|
||||
|
||||
<UpgradeBanner>{$t('upgrade.certificate')}</UpgradeBanner>
|
||||
|
||||
<main class="mt-2 px-2 md:-mr-3 md:-ml-3">
|
||||
<div class="mb-3 flex h-4/5 w-full flex-1 flex-col justify-between gap-3 lg:flex-row">
|
||||
<section class="h-full w-full lg:w-2/5">
|
||||
<strong class="my-2 text-base font-semibold text-black dark:text-gray-100"
|
||||
>{$t('course.navItem.certificates.certificate_settings')}</strong
|
||||
>
|
||||
<p class="my-4 text-xs font-normal dark:text-gray-100">
|
||||
{$t('course.navItem.certificates.theme')}
|
||||
</p>
|
||||
|
||||
<RadioGroup.Root bind:value={certificateTheme} disabled={$isFreePlan} class="mb-10">
|
||||
<div class="flex flex-wrap justify-between gap-y-5">
|
||||
{#each themes as theme}
|
||||
<div class="mr-3 flex items-start space-x-2">
|
||||
<RadioGroup.Item value={theme} id={theme} />
|
||||
<Label for={theme} class="cursor-pointer">
|
||||
<img src={`/images/certificate_theme_${theme}.png`} alt="themes" class="h-[82px] w-[110px]" />
|
||||
</Label>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
|
||||
<div>
|
||||
<p class="my-2 text-xs font-normal text-black dark:text-gray-100">
|
||||
{$t('course.navItem.certificates.logo')}
|
||||
</p>
|
||||
<div>
|
||||
<p class="mt-1 text-base dark:text-gray-100">
|
||||
{$t('course.navItem.certificates.to_update')}
|
||||
<strong class="font-semibold">{$t('course.navItem.certificates.settings')}</strong>
|
||||
{$t('course.navItem.certificates.and_upload')}
|
||||
</p>
|
||||
|
||||
<Button variant="outline" class="mt-3" onclick={() => goto(`/org/${$currentOrg.siteName}/settings`)}>
|
||||
{$t('course.navItem.certificates.goto_settings')}
|
||||
</Button>
|
||||
</div>
|
||||
<span class="my-4">
|
||||
<p class="mt-4 mb-2 text-xs font-normal dark:text-gray-100">
|
||||
{$t('course.navItem.certificates.description')}
|
||||
</p>
|
||||
<TextareaField
|
||||
rows={6}
|
||||
placeholder={$t('course.navItem.certificates.placeholder')}
|
||||
value={courseApi.course?.description}
|
||||
onchange={(e) => (courseApi.course!.description = e.currentTarget.value)}
|
||||
errorMessage={errors.description}
|
||||
disabled={$isFreePlan}
|
||||
helperMessage={helperText}
|
||||
/>
|
||||
</span>
|
||||
<div class="my-4 flex items-center space-x-2">
|
||||
<Switch
|
||||
id="certificate-downloadable"
|
||||
checked={!!courseApi.course?.isCertificateDownloadable}
|
||||
onCheckedChange={(checked) => (courseApi.course!.isCertificateDownloadable = checked)}
|
||||
disabled={$isFreePlan}
|
||||
/>
|
||||
<Label for="certificate-downloadable" class="text-sm font-medium dark:text-gray-100">
|
||||
{$t('course.navItem.certificates.allow')}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="flex w-full items-center justify-center rounded-md bg-gray-100 lg:w-3/5 dark:bg-neutral-800">
|
||||
<div class="certificate-container flex items-center justify-center">
|
||||
{#if certificateTheme === 'professional'}
|
||||
<Professional studentName={studentNamePlaceholder} />
|
||||
{:else if certificateTheme === 'plain'}
|
||||
<Plain studentName={studentNamePlaceholder} />
|
||||
{:else if certificateTheme === 'purpleProfessionalBadge'}
|
||||
<PurpleProfessionalBadge studentName={studentNamePlaceholder} />
|
||||
{:else if certificateTheme === 'blueProfessionalBadge'}
|
||||
<BlueProfessionalBadge studentName={studentNamePlaceholder} />
|
||||
{:else if certificateTheme === 'purpleBadgePattern'}
|
||||
<PurpleBadgePattern studentName={studentNamePlaceholder} />
|
||||
{:else if certificateTheme === 'blueBadgePattern'}
|
||||
<BlueBadgePattern studentName={studentNamePlaceholder} />
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div class="h-1/5">
|
||||
<Button variant="secondary" onclick={saveCertificate} loading={courseApi.isLoading} disabled={$isFreePlan}>
|
||||
{#if $isFreePlan}
|
||||
<ZapIcon size={16} class="filled" />
|
||||
{/if}
|
||||
{$t('course.navItem.certificates.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
p {
|
||||
color: #262626;
|
||||
}
|
||||
.certificate-container {
|
||||
width: 95%;
|
||||
max-width: 95%;
|
||||
}
|
||||
</style>
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
</script>
|
||||
|
||||
<div>
|
||||
{#if courseApi.course?.isCertificateDownloadable === true}
|
||||
{#if courseApi.course?.certificate?.isDownloadable === true}
|
||||
<UnlockedCertificate />
|
||||
{:else}
|
||||
<LockedCertificate />
|
||||
|
||||
+82
-17
@@ -5,17 +5,55 @@
|
||||
import { currentOrg, currentOrgDomain } from '$lib/utils/store/org';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import Empty from '@cio/ui/custom/empty/empty.svelte';
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { classroomio } from '$lib/utils/services/api';
|
||||
import type { CertificationEvaluationData } from '$features/course/utils/types';
|
||||
import { openCourseCompletionModal } from '$features/course/store/course-completion-modal';
|
||||
|
||||
let isLoading = $state(false);
|
||||
let isCourseComplete = $state(false);
|
||||
let evaluation = $state<CertificationEvaluationData | null>(null);
|
||||
|
||||
let courseId = $derived(courseApi.course?.id ?? '');
|
||||
|
||||
function formatBlockerMessage(blocker: CertificationEvaluationData['blockers'][number]): string {
|
||||
const p = blocker.params ?? {};
|
||||
switch (blocker.code) {
|
||||
case 'CERT_PROGRESS':
|
||||
return t.get('course.certification.blocker_progress', {
|
||||
current: Number(p.current ?? 0),
|
||||
required: Number(p.required ?? 0)
|
||||
});
|
||||
case 'CERT_DEADLINE_PASSED':
|
||||
return t.get('course.certification.blocker_deadline');
|
||||
case 'CERT_NO_CONTENT':
|
||||
return t.get('course.certification.blocker_no_content');
|
||||
case 'CERT_FINAL_EXERCISE_NOT_SUBMITTED':
|
||||
return t.get('course.certification.blocker_final_not_submitted', {
|
||||
exerciseTitle: String(p.exerciseTitle ?? '')
|
||||
});
|
||||
case 'CERT_FINAL_EXERCISE_PENDING_GRADE':
|
||||
return t.get('course.certification.blocker_final_pending', {
|
||||
exerciseTitle: String(p.exerciseTitle ?? '')
|
||||
});
|
||||
case 'CERT_FINAL_EXERCISE_SCORE':
|
||||
return t.get('course.certification.blocker_final_score', {
|
||||
exerciseTitle: String(p.exerciseTitle ?? ''),
|
||||
bestPercent: Number(p.bestPercent ?? 0),
|
||||
requiredPercent: Number(p.requiredPercent ?? 0)
|
||||
});
|
||||
case 'CERT_FINAL_EXERCISE_MISCONFIGURED':
|
||||
return t.get('course.certification.blocker_final_misconfigured', {
|
||||
exerciseTitle: String(p.exerciseTitle ?? '')
|
||||
});
|
||||
default:
|
||||
return blocker.code;
|
||||
}
|
||||
}
|
||||
|
||||
const downLoadCertificate = async () => {
|
||||
if (!isCourseComplete || !courseId) return;
|
||||
|
||||
@@ -24,7 +62,7 @@
|
||||
const response = await classroomio.course[':courseId']['download']['certificate']['$post']({
|
||||
param: { courseId },
|
||||
json: {
|
||||
theme: `${courseApi.course?.certificateTheme}`,
|
||||
theme: `${courseApi.course?.certificate?.theme ?? 'professional'}`,
|
||||
studentName: `${$profile.fullname}`,
|
||||
courseName: `${courseApi.course?.title}`,
|
||||
courseDescription: `${courseApi.course?.description}`,
|
||||
@@ -52,22 +90,24 @@
|
||||
isLoading = false;
|
||||
};
|
||||
|
||||
const hasUserCompletedCourse = async () => {
|
||||
const loadEvaluation = async () => {
|
||||
if (!courseId) return;
|
||||
isLoading = true;
|
||||
const res = await courseApi.getCertificationEvaluation(courseId);
|
||||
if (res?.data) {
|
||||
evaluation = res.data;
|
||||
const hasEarned = res.data.certificateEarnedAt != null;
|
||||
isCourseComplete = hasEarned || !!res.data.eligibleForCertificate;
|
||||
|
||||
const response = await courseApi.getProgress(courseId, $profile.id);
|
||||
|
||||
if (courseApi.success && response?.data) {
|
||||
const progress = response.data;
|
||||
isCourseComplete =
|
||||
progress.lessonsCount === progress.lessonsCompleted && progress.exercisesCount === progress.exercisesCompleted;
|
||||
if (res.data.isNewCompletion && courseId) {
|
||||
openCourseCompletionModal(courseId);
|
||||
}
|
||||
}
|
||||
|
||||
isLoading = false;
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
hasUserCompletedCourse();
|
||||
loadEvaluation();
|
||||
});
|
||||
|
||||
let title = $derived(
|
||||
@@ -75,15 +115,40 @@
|
||||
? 'course.navItem.certificates.unlocked_certificate'
|
||||
: 'course.navItem.certificates.complete_to_download_title'
|
||||
);
|
||||
let subtitle = $derived(
|
||||
isCourseComplete
|
||||
? 'course.navItem.certificates.unlocked_certificate_subtitle'
|
||||
: 'course.navItem.certificates.complete_to_download_subtitle'
|
||||
);
|
||||
|
||||
let progressLines = $derived.by(() => {
|
||||
if (!evaluation) {
|
||||
return [
|
||||
t.get('course.certificates.progress_hint', {
|
||||
current: 0,
|
||||
required: courseApi.course?.certificate?.threshold ?? 100
|
||||
})
|
||||
];
|
||||
}
|
||||
const blockers = evaluation.blockers ?? [];
|
||||
if (blockers.length > 0) {
|
||||
return blockers.map((b) => formatBlockerMessage(b));
|
||||
}
|
||||
return [
|
||||
t.get('course.certificates.progress_hint', {
|
||||
current: evaluation.progressPercent,
|
||||
required: evaluation.certificationThreshold
|
||||
})
|
||||
];
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex-1">
|
||||
<Empty title={$t(title)} description={$t(subtitle)} icon={DownloadIcon} variant="page">
|
||||
<Empty title={$t(title)} icon={DownloadIcon} variant="page">
|
||||
<div class="ui:text-muted-foreground mx-auto mb-4 max-w-md space-y-2 text-center text-sm">
|
||||
{#if isCourseComplete}
|
||||
<p>{$t('course.navItem.certificates.unlocked_certificate_subtitle')}</p>
|
||||
{:else}
|
||||
{#each progressLines as line (line)}
|
||||
<p>{line}</p>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
<Button onclick={downLoadCertificate} disabled={!isCourseComplete} loading={isLoading}>
|
||||
<DownloadIcon size={16} />
|
||||
{$t('course.navItem.certificates.download_certificate')}
|
||||
|
||||
+1
-1
@@ -155,7 +155,7 @@
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open={$contentCreateStore.open} onOpenChange={(isOpen) => !isOpen && closeModal()}>
|
||||
<Dialog.Content class="flex max-h-[680px] w-fit! max-w-2xl! min-w-[400px] flex-col overflow-hidden">
|
||||
<Dialog.Content class="flex max-h-[680px] w-full! max-w-xl! min-w-[400px] flex-col overflow-hidden">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{$t('course.navItem.lessons.add_content')}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
|
||||
+1
-1
@@ -252,7 +252,7 @@
|
||||
bind:value={title}
|
||||
autoFocus={true}
|
||||
placeholder={$t('course.navItem.lessons.exercises.new_exercise_modal.title_placeholder')}
|
||||
className="my-4 w-2/4!"
|
||||
className="my-4 w-full"
|
||||
/>
|
||||
</div>
|
||||
{:else if type === EXERCISE_CREATE_TYPE.TEMPLATE}
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
import { ContentType } from '@cio/utils/constants/content';
|
||||
import { PREMIUM_QUESTION_TYPE_KEYS, QUESTION_TYPES } from '$features/ui/question/constants';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { Badge } from '@cio/ui/base/badge';
|
||||
import { ExerciseQuestion } from '@cio/ui';
|
||||
|
||||
import OrderModal from './order-modal.svelte';
|
||||
@@ -40,11 +41,20 @@
|
||||
|
||||
interface Props {
|
||||
shouldDeleteExercise?: boolean;
|
||||
exerciseId: any;
|
||||
goBack?: any;
|
||||
exerciseId: string;
|
||||
goBack?: () => void;
|
||||
/** Self-paced + all auto-gradable question types: enforce points > 0 */
|
||||
requiresPositivePointsForAutoGrade?: boolean;
|
||||
selfPacedCourse?: boolean;
|
||||
}
|
||||
|
||||
let { shouldDeleteExercise = $bindable(false), exerciseId, goBack = () => {} }: Props = $props();
|
||||
let {
|
||||
shouldDeleteExercise = $bindable(false),
|
||||
exerciseId,
|
||||
goBack = () => {},
|
||||
requiresPositivePointsForAutoGrade = false,
|
||||
selfPacedCourse = false
|
||||
}: Props = $props();
|
||||
|
||||
let questionIdToDelete = $state(null);
|
||||
let isDeleting = $state(false);
|
||||
@@ -173,9 +183,10 @@
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!courseApi.course?.id) return;
|
||||
isDeleting = true;
|
||||
|
||||
await exerciseApi.delete(courseApi.course?.id!, exerciseId);
|
||||
await exerciseApi.delete(courseApi.course.id, exerciseId);
|
||||
|
||||
if (exerciseApi.success) {
|
||||
courseApi.removeContentItem(exerciseId, ContentType.Exercise);
|
||||
@@ -234,6 +245,9 @@
|
||||
bind:points={question.points}
|
||||
hasError={!!errors[question.id]}
|
||||
errorMsg={getQuestionErrorMsg(errors, question, 'points')}
|
||||
pointsHint={requiresPositivePointsForAutoGrade && Number(question.points) === 0
|
||||
? $t('course.navItem.lessons.exercises.all_exercises.points_required_auto_grade')
|
||||
: null}
|
||||
onPointsChange={() => {
|
||||
question.isDirty = true;
|
||||
}}
|
||||
@@ -272,7 +286,7 @@
|
||||
{getQuestionTypeLabel(question?.questionType)}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each QUESTION_TYPES as type, i (type.key)}
|
||||
{#each QUESTION_TYPES as type (type.key)}
|
||||
{#if $isFreePlan && PREMIUM_QUESTION_TYPE_KEYS.has(type.key)}
|
||||
<button
|
||||
type="button"
|
||||
@@ -288,7 +302,27 @@
|
||||
<span>{getQuestionTypeLabel(type)}</span>
|
||||
</button>
|
||||
{:else}
|
||||
<Select.Item value={type.id.toString()} label={getQuestionTypeLabel(type)} />
|
||||
<Select.Item value={type.id.toString()} label={getQuestionTypeLabel(type)}>
|
||||
{#snippet children({ selected: _sel })}
|
||||
<span class="flex w-full min-w-0 items-center justify-between gap-2">
|
||||
<span class="truncate">{getQuestionTypeLabel(type)}</span>
|
||||
{#if selfPacedCourse}
|
||||
<Badge
|
||||
variant={type.autoGradable ? 'success' : 'warning'}
|
||||
class="ui:shrink-0 ui:text-[10px] ui:font-normal"
|
||||
>
|
||||
{type.autoGradable
|
||||
? $t(
|
||||
'course.navItem.lessons.exercises.all_exercises.edit_mode.question_type_auto_gradable'
|
||||
)
|
||||
: $t(
|
||||
'course.navItem.lessons.exercises.all_exercises.edit_mode.question_type_manual_grading'
|
||||
)}
|
||||
</Badge>
|
||||
{/if}
|
||||
</span>
|
||||
{/snippet}
|
||||
</Select.Item>
|
||||
{/if}
|
||||
{/each}
|
||||
</Select.Content>
|
||||
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
<script lang="ts">
|
||||
import { Switch } from '@cio/ui/base/switch';
|
||||
import { Label } from '@cio/ui/base/label';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { questionnaire } from './store';
|
||||
import { exerciseApi } from '$features/course/api';
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
|
||||
type Props = {
|
||||
exerciseId: string;
|
||||
};
|
||||
|
||||
let { exerciseId }: Props = $props();
|
||||
|
||||
let saving = $state(false);
|
||||
|
||||
async function saveSettings() {
|
||||
if (!courseApi.course?.id) return;
|
||||
saving = true;
|
||||
|
||||
await exerciseApi.update(courseApi.course.id, exerciseId, {
|
||||
allowMultipleAttempts: !!$questionnaire.allowMultipleAttempts
|
||||
});
|
||||
saving = false;
|
||||
|
||||
if (exerciseApi.success) {
|
||||
snackbar.success('snackbar.exercise.success');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="max-w-xl space-y-6">
|
||||
<div class="flex items-center justify-between gap-4 rounded-lg border border-neutral-200 p-4 dark:border-neutral-700">
|
||||
<div class="space-y-1">
|
||||
<Label for="allow-multiple" class="text-sm font-medium dark:text-gray-100">
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.settings_allow_multiple')}
|
||||
</Label>
|
||||
<p class="ui:text-muted-foreground text-sm">
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.settings_allow_multiple_helper')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="allow-multiple"
|
||||
checked={!!$questionnaire.allowMultipleAttempts}
|
||||
onCheckedChange={(checked) => {
|
||||
questionnaire.update((q) => ({ ...q, allowMultipleAttempts: checked }));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button onclick={saveSettings} loading={saving} disabled={!courseApi.course?.id}>
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.settings_save')}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -54,6 +54,7 @@ export const questionnaire: Writable<{
|
||||
isDescriptionDirty?: boolean;
|
||||
questions: Question[];
|
||||
totalSubmissions: number;
|
||||
allowMultipleAttempts?: boolean;
|
||||
}> = writable({
|
||||
title: '',
|
||||
dueBy: '',
|
||||
@@ -62,7 +63,8 @@ export const questionnaire: Writable<{
|
||||
description: '',
|
||||
isDescriptionDirty: false,
|
||||
questions: [],
|
||||
totalSubmissions: 0
|
||||
totalSubmissions: 0,
|
||||
allowMultipleAttempts: false
|
||||
});
|
||||
|
||||
export function clearQuestionnaireValidation() {
|
||||
@@ -88,6 +90,15 @@ export function reset() {
|
||||
clearQuestionnaireValidation();
|
||||
}
|
||||
|
||||
/** Clears take-exercise state so the student can start another attempt (when allowed). */
|
||||
export function resetStudentExerciseTake() {
|
||||
questionnaireMetaData.set({
|
||||
...initAnswerState,
|
||||
exerciseId: null
|
||||
});
|
||||
clearQuestionnaireValidation();
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps Zod validation errors to UI format: { questionId: { option: 'error message' } }
|
||||
* This is used to display errors in the edit-mode component
|
||||
|
||||
@@ -29,9 +29,10 @@
|
||||
import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { ContentType } from '@cio/utils/constants/content';
|
||||
import { getOrderedNavigableContent } from '$features/course/utils/content';
|
||||
import { globalStore } from '$lib/utils/store/app';
|
||||
import { openCourseCompletionModal } from '$features/course/store/course-completion-modal';
|
||||
import { ContentType } from '@cio/utils/constants/content';
|
||||
import { toExerciseQuestionModel } from './question-type-utils';
|
||||
import { getExerciseQuestionLabels } from './question-labels';
|
||||
import axios from 'axios';
|
||||
@@ -150,17 +151,13 @@
|
||||
}));
|
||||
}
|
||||
courseApi.updateContentItem(exerciseId, ContentType.Exercise, { isComplete: true });
|
||||
if (
|
||||
$globalStore.isStudent &&
|
||||
courseApi.course?.id &&
|
||||
submitResult?.data &&
|
||||
'certification' in submitResult.data
|
||||
) {
|
||||
const certification = (submitResult.data as { certification?: { isNewCompletion?: boolean } | null })
|
||||
.certification;
|
||||
if (certification?.isNewCompletion) {
|
||||
openCourseCompletionModal(courseApi.course.id);
|
||||
}
|
||||
|
||||
const allContentItems = getOrderedNavigableContent(courseApi.course);
|
||||
const allComplete =
|
||||
$globalStore.isStudent && allContentItems.length > 0 && allContentItems.every((item) => item.isComplete);
|
||||
|
||||
if (allComplete && courseApi.course?.id) {
|
||||
openCourseCompletionModal(courseApi.course.id);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+13
-2
@@ -13,6 +13,7 @@
|
||||
import { ContentType } from '@cio/utils/constants/content';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import type { CourseContentItem } from '$features/course/utils/types';
|
||||
import { openCourseCompletionModal } from '$features/course/store/course-completion-modal';
|
||||
|
||||
interface Props {
|
||||
lessonId?: string;
|
||||
@@ -65,7 +66,7 @@
|
||||
const isLessonLocked = $derived(
|
||||
$globalStore.isStudent && currentLessonItem && !(currentLessonItem.isUnlocked ?? false)
|
||||
);
|
||||
const isMarkCompleteDisabled = $derived(isMarkingComplete || isLessonLocked);
|
||||
const isMarkCompleteDisabled = $derived(isMarkingComplete || isLessonLocked || isLessonComplete);
|
||||
|
||||
async function markLessonComplete(currentLessonId: string) {
|
||||
isMarkingComplete = true;
|
||||
@@ -78,8 +79,18 @@
|
||||
await lessonApi.updateCompletion(courseId, currentLessonId, isComplete);
|
||||
|
||||
if (lessonApi.success) {
|
||||
updateCourseContentCompletion(currentLessonId, isComplete);
|
||||
snackbar.success('snackbar.lessons.success.complete_marked');
|
||||
updateCourseContentCompletion(currentLessonId, isComplete);
|
||||
|
||||
const allComplete =
|
||||
$globalStore.isStudent &&
|
||||
isComplete &&
|
||||
navigableContentItems.length > 0 &&
|
||||
navigableContentItems.every((item) => item.isComplete);
|
||||
|
||||
if (allComplete) {
|
||||
openCourseCompletionModal(courseId);
|
||||
}
|
||||
} else {
|
||||
snackbar.error('snackbar.lessons.error.try_later');
|
||||
}
|
||||
|
||||
@@ -12,12 +12,15 @@
|
||||
|
||||
interface Props {
|
||||
id?: string;
|
||||
slug?: string;
|
||||
title?: string;
|
||||
type?: string;
|
||||
description?: string;
|
||||
isPublished?: boolean;
|
||||
totalLessons?: number;
|
||||
totalStudents?: number;
|
||||
isExplore?: boolean;
|
||||
isLMS?: boolean;
|
||||
tags?: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -28,15 +31,32 @@
|
||||
|
||||
let {
|
||||
id = '',
|
||||
slug = '',
|
||||
title = '',
|
||||
type = '',
|
||||
description = '',
|
||||
isPublished = false,
|
||||
totalLessons = 0,
|
||||
totalStudents = 0,
|
||||
isExplore = false,
|
||||
isLMS = false,
|
||||
tags = []
|
||||
}: Props = $props();
|
||||
|
||||
function handleRowClick() {
|
||||
if (isExplore) {
|
||||
goto(resolve(`/course/${slug}`, {}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (isLMS) {
|
||||
goto(resolve(`/courses/${id}/lessons?next=true`, {}));
|
||||
return;
|
||||
}
|
||||
|
||||
goto(resolve(`/courses/[id]`, { id }));
|
||||
}
|
||||
|
||||
function handleCloneCourse(e) {
|
||||
e.stopPropagation();
|
||||
// TODO: Clone course functionality
|
||||
@@ -62,7 +82,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<Table.Row class="cursor-pointer" onclick={() => goto(resolve(`/courses/[id]`, { id }))}>
|
||||
<Table.Row class="cursor-pointer" onclick={handleRowClick}>
|
||||
<Table.Cell class="truncate"><p class="font-semibold">{title}</p></Table.Cell>
|
||||
<Table.Cell class="truncate">
|
||||
<p>{description}</p>
|
||||
@@ -77,34 +97,38 @@
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell>{totalLessons}</Table.Cell>
|
||||
<Table.Cell>{totalStudents}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<CoursePublishBadge {isPublished} />
|
||||
{#if !isLMS}
|
||||
<Table.Cell>{totalStudents}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<CoursePublishBadge {isPublished} />
|
||||
</Table.Cell>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if !isLMS}
|
||||
<Table.Cell class="text-center">
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
class="flex items-center justify-center rounded-md p-1 hover:bg-gray-100"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<EllipsisVerticalIcon size={16} />
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Item onclick={handleCloneCourse}>
|
||||
{$t('courses.course_card.context_menu.clone')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={handleShareCourse}>
|
||||
{$t('courses.course_card.context_menu.share')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={handleInvite}>
|
||||
{$t('courses.course_card.context_menu.invite')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item class="text-red-600" onclick={handleDeleteCourse}>
|
||||
{$t('courses.course_card.context_menu.delete')}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</Table.Cell>
|
||||
{/if}
|
||||
<Table.Cell class="text-center">
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
class="flex items-center justify-center rounded-md p-1 hover:bg-gray-100"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<EllipsisVerticalIcon size={16} />
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Item onclick={handleCloneCourse}>
|
||||
{$t('courses.course_card.context_menu.clone')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={handleShareCourse}>
|
||||
{$t('courses.course_card.context_menu.share')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={handleInvite}>
|
||||
{$t('courses.course_card.context_menu.invite')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item class="text-red-600" onclick={handleDeleteCourse}>
|
||||
{$t('courses.course_card.context_menu.delete')}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
|
||||
+118
-66
@@ -1,11 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { CheckboxField } from '@cio/ui/custom/checkbox-field';
|
||||
import { Search } from '@cio/ui/custom/search';
|
||||
import { MultiSelectList } from '@cio/ui/custom/multi-select-list';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { orgApi } from '$features/org/api/org.svelte';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { DEFAULT_ORG_AUDIENCE_QUERY } from '$features/org/utils/audience-query-utils';
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import type { OrgStudent } from './types';
|
||||
@@ -20,29 +23,39 @@
|
||||
let { courseId, students, isLoading = false, onAssigned }: Props = $props();
|
||||
|
||||
let searchValue = $state('');
|
||||
let isSearching = $state(false);
|
||||
let lastSearchValue = $state('');
|
||||
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let activeSearchRequestId = $state(0);
|
||||
const selectedProfileIds = new SvelteSet<string>();
|
||||
let sendEmail = $state(true);
|
||||
let isSubmitting = $state(false);
|
||||
const SEARCH_DEBOUNCE_MS = 350;
|
||||
|
||||
const filteredStudents = $derived.by(() => {
|
||||
const query = searchValue.trim().toLowerCase();
|
||||
const sortedStudents = [...students].sort((a, b) =>
|
||||
const sortedStudents = $derived.by(() =>
|
||||
[...students].sort((a, b) =>
|
||||
(a.name || a.email || '').localeCompare(b.name || b.email || '', undefined, { sensitivity: 'base' })
|
||||
);
|
||||
|
||||
if (!query) {
|
||||
return sortedStudents;
|
||||
}
|
||||
|
||||
return sortedStudents.filter((student) => {
|
||||
const name = student.name?.toLowerCase() || '';
|
||||
const email = student.email?.toLowerCase() || '';
|
||||
return name.includes(query) || email.includes(query);
|
||||
});
|
||||
});
|
||||
)
|
||||
);
|
||||
const showLoadingState = $derived(isLoading || isSearching);
|
||||
const emptyMessage = $derived(
|
||||
searchValue.trim()
|
||||
? $t('course.navItem.people.invite_modal.no_matching_existing_students')
|
||||
: $t('course.navItem.people.invite_modal.no_existing_students')
|
||||
);
|
||||
|
||||
const selectedCount = $derived(selectedProfileIds.size);
|
||||
|
||||
const multiSelectItems = $derived(
|
||||
sortedStudents
|
||||
.filter((s): s is OrgStudent & { profileId: string } => Boolean(s.profileId))
|
||||
.map((s) => ({
|
||||
id: s.profileId,
|
||||
label: s.name || s.email,
|
||||
description: s.email
|
||||
}))
|
||||
);
|
||||
|
||||
function toggleStudent(profileId: string) {
|
||||
if (selectedProfileIds.has(profileId)) {
|
||||
selectedProfileIds.delete(profileId);
|
||||
@@ -51,8 +64,70 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function searchStudents(value: string) {
|
||||
const normalizedValue = value.trim();
|
||||
if (normalizedValue === lastSearchValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastSearchValue = normalizedValue;
|
||||
const orgId = $currentOrg.id;
|
||||
if (!orgId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = activeSearchRequestId + 1;
|
||||
activeSearchRequestId = requestId;
|
||||
isSearching = true;
|
||||
|
||||
try {
|
||||
await orgApi.getOrgAudience(
|
||||
orgId,
|
||||
{
|
||||
...DEFAULT_ORG_AUDIENCE_QUERY,
|
||||
search: normalizedValue || undefined
|
||||
},
|
||||
{
|
||||
abortPrevious: true
|
||||
}
|
||||
);
|
||||
} finally {
|
||||
if (requestId === activeSearchRequestId) {
|
||||
isSearching = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearchValueChange(value: string) {
|
||||
searchValue = value;
|
||||
|
||||
if (searchTimeout) {
|
||||
clearTimeout(searchTimeout);
|
||||
}
|
||||
|
||||
if (!value.trim()) {
|
||||
void searchStudents(value);
|
||||
return;
|
||||
}
|
||||
|
||||
searchTimeout = setTimeout(() => {
|
||||
void searchStudents(value);
|
||||
}, SEARCH_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
orgApi.cancelAudienceRequest();
|
||||
|
||||
if (!searchTimeout) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(searchTimeout);
|
||||
});
|
||||
|
||||
async function assignStudents() {
|
||||
if (!courseId) return;
|
||||
|
||||
if (selectedProfileIds.size === 0) {
|
||||
snackbar.error('course.navItem.people.invite_modal.select_at_least_one_student');
|
||||
return;
|
||||
@@ -82,56 +157,33 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="rounded-md border p-4">
|
||||
<div class="space-y-1">
|
||||
<p class="text-base font-semibold">{$t('course.navItem.people.invite_modal.existing_students_title')}</p>
|
||||
<p class="ui:text-muted-foreground text-sm">
|
||||
{$t('course.navItem.people.invite_modal.existing_students_description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 space-y-4">
|
||||
<Search placeholder={$t('course.navItem.people.invite_modal.existing_students_search')} bind:value={searchValue} />
|
||||
|
||||
{#if selectedCount > 0}
|
||||
<p class="ui:text-muted-foreground text-sm">{$t('audience.selected_count', { count: selectedCount })}</p>
|
||||
{/if}
|
||||
|
||||
<div class="max-h-72 space-y-2 overflow-y-auto rounded-md border p-3">
|
||||
{#if isLoading}
|
||||
<p class="ui:text-muted-foreground text-sm">
|
||||
{$t('course.navItem.people.invite_modal.loading_existing_students')}
|
||||
</p>
|
||||
{:else if students.length === 0}
|
||||
<p class="ui:text-muted-foreground text-sm">{$t('course.navItem.people.invite_modal.no_existing_students')}</p>
|
||||
{:else if filteredStudents.length === 0}
|
||||
<p class="ui:text-muted-foreground text-sm">
|
||||
{$t('course.navItem.people.invite_modal.no_matching_existing_students')}
|
||||
</p>
|
||||
{:else}
|
||||
{#each filteredStudents as student (student.profileId)}
|
||||
<div
|
||||
class="ui:hover:bg-muted/40 rounded-md px-2 py-1"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => student.profileId && toggleStudent(student.profileId)}
|
||||
onkeydown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
if (student.profileId) toggleStudent(student.profileId);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CheckboxField
|
||||
name={`existing-student-${student.profileId}`}
|
||||
label={student.name || student.email}
|
||||
checked={student.profileId ? selectedProfileIds.has(student.profileId) : false}
|
||||
>
|
||||
<span class="ui:text-muted-foreground text-sm">{student.email}</span>
|
||||
</CheckboxField>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
<div class="">
|
||||
<div class="space-y-2">
|
||||
<div class="max-h-72 overflow-y-auto">
|
||||
<MultiSelectList
|
||||
class="border-0"
|
||||
{emptyMessage}
|
||||
items={multiSelectItems}
|
||||
isLoading={showLoadingState}
|
||||
isSelected={(id) => selectedProfileIds.has(id)}
|
||||
onSearchValueChange={handleSearchValueChange}
|
||||
onToggle={(id) => toggleStudent(id)}
|
||||
namePrefix="existing-student"
|
||||
listClass="max-h-60"
|
||||
searchPlaceholder={$t('course.navItem.people.invite_modal.existing_students_search')}
|
||||
bind:searchValue
|
||||
>
|
||||
{#snippet headingSnippet()}
|
||||
<p class="ui:text-sm ui:font-medium">
|
||||
{$t('course.navItem.people.invite_modal.existing_students_title')} ({multiSelectItems.length})
|
||||
{#if selectedCount > 0}
|
||||
<span class="ui:text-muted-foreground text-xs"
|
||||
>{$t('audience.selected_count', { count: selectedCount })}</span
|
||||
>
|
||||
{/if}
|
||||
</p>
|
||||
{/snippet}
|
||||
</MultiSelectList>
|
||||
</div>
|
||||
|
||||
<CheckboxField
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { peopleApi } from '$features/course/api';
|
||||
import { orgApi } from '$features/org/api/org.svelte';
|
||||
import { DEFAULT_ORG_AUDIENCE_QUERY } from '$features/org/utils/audience-query-utils';
|
||||
import type { OrgTeamMember } from '$lib/utils/types/org';
|
||||
import type { OrgStudent, Tutor } from './types';
|
||||
|
||||
@@ -75,10 +76,11 @@
|
||||
|
||||
function loadStudents(orgId: string | undefined) {
|
||||
if (!orgId) return;
|
||||
|
||||
untrack(async () => {
|
||||
isLoadingStudents = true;
|
||||
try {
|
||||
await orgApi.getOrgAudience(orgId);
|
||||
await orgApi.getOrgAudience(orgId, DEFAULT_ORG_AUDIENCE_QUERY, { abortPrevious: true });
|
||||
} finally {
|
||||
isLoadingStudents = false;
|
||||
}
|
||||
@@ -125,7 +127,7 @@
|
||||
if (!open) closeModal();
|
||||
}}
|
||||
>
|
||||
<Dialog.Content class="max-h-[80vh] w-[96vw] max-w-6xl overflow-y-auto">
|
||||
<Dialog.Content class="max-h-[80vh] w-[96vw] max-w-3xl! overflow-y-auto">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{$t('course.navItem.people.invite_modal.title')}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
points?: any;
|
||||
hasError?: boolean;
|
||||
errorMsg?: string | null;
|
||||
/** Extra hint under points row (e.g. auto-grade requires non-zero points) */
|
||||
pointsHint?: string | null;
|
||||
onPointsChange?: any;
|
||||
key?: string;
|
||||
children?: import('svelte').Snippet;
|
||||
@@ -23,6 +25,7 @@
|
||||
points = $bindable(undefined),
|
||||
hasError = false,
|
||||
errorMsg = null,
|
||||
pointsHint = null,
|
||||
onPointsChange = () => {},
|
||||
children
|
||||
}: Props = $props();
|
||||
@@ -67,6 +70,8 @@
|
||||
|
||||
{#if errorMsg}
|
||||
<p class="text-xs text-red-500">{errorMsg}</p>
|
||||
{:else if pointsHint}
|
||||
<p class="ui:text-muted-foreground max-w-[min(100%,12rem)] text-xs">{pointsHint}</p>
|
||||
{/if}
|
||||
|
||||
{#if onClose && !isTitle}
|
||||
|
||||
+13
-11
@@ -64,15 +64,17 @@
|
||||
<span>{$t('course.navItems.nav_content')}</span>
|
||||
|
||||
<div class="ml-auto flex items-center gap-1">
|
||||
<Plus
|
||||
size={20}
|
||||
class="rounded-full p-1"
|
||||
onclick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
openContentModal(id);
|
||||
}}
|
||||
/>
|
||||
{#if !isStudent}
|
||||
<Plus
|
||||
size={20}
|
||||
class="rounded-full p-1"
|
||||
onclick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
openContentModal(id);
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
{/snippet}
|
||||
@@ -85,8 +87,8 @@
|
||||
{id}
|
||||
{isStudent}
|
||||
className="mt-1"
|
||||
onOpenContentModal={(sectionId) => openContentModal(id, sectionId)}
|
||||
onEditSection={(sectionId) => openSectionEditor(id, sectionId)}
|
||||
onOpenContentModal={isStudent ? undefined : (sectionId) => openContentModal(id, sectionId)}
|
||||
onEditSection={isStudent ? undefined : (sectionId) => openSectionEditor(id, sectionId)}
|
||||
/>
|
||||
</Sidebar.Group>
|
||||
|
||||
|
||||
+2
-1
@@ -26,6 +26,7 @@
|
||||
let { path, id, isStudent = false, className = '', onOpenContentModal }: Props = $props();
|
||||
|
||||
const contentData = $derived(getCourseContent(courseApi.course));
|
||||
const canOpenContentModal = $derived(Boolean(onOpenContentModal) && !isStudent);
|
||||
</script>
|
||||
|
||||
<Sidebar.MenuSub class={className}>
|
||||
@@ -42,7 +43,7 @@
|
||||
<span class="flex-1 truncate">{section.title}</span>
|
||||
|
||||
<div class="ml-auto flex items-center gap-1">
|
||||
{#if onOpenContentModal}
|
||||
{#if canOpenContentModal}
|
||||
<IconButton
|
||||
variant="ghost-outline"
|
||||
size="icon-xs"
|
||||
|
||||
+2
-2
@@ -285,8 +285,8 @@
|
||||
{id}
|
||||
{isStudent}
|
||||
className="mt-1 ml-2"
|
||||
onOpenContentModal={(sectionId) => openContentModal(id, sectionId)}
|
||||
onEditSection={(sectionId) => openSectionEditor(id, sectionId)}
|
||||
onOpenContentModal={isStudent ? undefined : (sectionId) => openContentModal(id, sectionId)}
|
||||
onEditSection={isStudent ? undefined : (sectionId) => openSectionEditor(id, sectionId)}
|
||||
/>
|
||||
{/if}
|
||||
</Sidebar.MenuItem>
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
<script lang="ts">
|
||||
import IssueCertificateModal from '$features/course/components/ceritficate/issue-certificate-modal.svelte';
|
||||
import { RoleBasedSecurity } from '$features/ui';
|
||||
import Design from '$features/course/components/ceritficate/design.svelte';
|
||||
import CertificateEdit from '$features/course/components/ceritficate/certificate-edit.svelte';
|
||||
import StudentCertificate from '$features/course/components/ceritficate/student-certificate/index.svelte';
|
||||
|
||||
type Props = {
|
||||
errors: Record<string, string>;
|
||||
};
|
||||
|
||||
let { errors }: Props = $props();
|
||||
</script>
|
||||
|
||||
<IssueCertificateModal />
|
||||
|
||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||
<Design />
|
||||
<CertificateEdit {errors} />
|
||||
</RoleBasedSecurity>
|
||||
<RoleBasedSecurity onlyStudent allowedRoles={[3]}>
|
||||
<StudentCertificate />
|
||||
|
||||
@@ -110,20 +110,18 @@
|
||||
{/if}
|
||||
{@render filterControls?.()}
|
||||
|
||||
{#if !isLMS}
|
||||
{#if $courseMetaDeta.view === 'list'}
|
||||
<IconButton onclick={() => setViewPreference('grid')}>
|
||||
<GridIcon size={16} />
|
||||
</IconButton>
|
||||
{:else}
|
||||
<IconButton onclick={() => setViewPreference('list')}>
|
||||
<ListIcon size={16} />
|
||||
</IconButton>
|
||||
{/if}
|
||||
{#if $courseMetaDeta.view === 'list'}
|
||||
<IconButton onclick={() => setViewPreference('grid')}>
|
||||
<GridIcon size={16} />
|
||||
</IconButton>
|
||||
{:else}
|
||||
<IconButton onclick={() => setViewPreference('list')}>
|
||||
<ListIcon size={16} />
|
||||
</IconButton>
|
||||
{/if}
|
||||
</Page.BodyHeader>
|
||||
|
||||
<div class="mx-auto w-full flex-1">
|
||||
<div class="mx-auto mt-4 w-full flex-1">
|
||||
{#if isLoading}
|
||||
<section class={`${isLoading || courses ? 'cards-container' : ''} `}>
|
||||
<CourseCardLoader />
|
||||
@@ -146,15 +144,18 @@
|
||||
<Table.Head class="w-[10%]">{$t('courses.course_card.list_view.type')}</Table.Head>
|
||||
<Table.Head class="w-3/12 min-w-0">{$t('courses.course_card.list_view.tags')}</Table.Head>
|
||||
<Table.Head class="w-[8%]">{$t('courses.course_card.list_view.lessons')}</Table.Head>
|
||||
<Table.Head class="w-[8%]">{$t('courses.course_card.list_view.students')}</Table.Head>
|
||||
<Table.Head class="w-[10%]">{$t('courses.course_card.list_view.published')}</Table.Head>
|
||||
<Table.Head class="w-[6%]"></Table.Head>
|
||||
{#if !isLMS}
|
||||
<Table.Head class="w-[8%]">{$t('courses.course_card.list_view.students')}</Table.Head>
|
||||
<Table.Head class="w-[10%]">{$t('courses.course_card.list_view.published')}</Table.Head>
|
||||
<Table.Head class="w-[6%]"></Table.Head>
|
||||
{/if}
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each courses as courseData (courseData.id)}
|
||||
<CourseList
|
||||
id={courseData.id}
|
||||
slug={courseData.slug ?? ''}
|
||||
title={courseData.title}
|
||||
type={$t(`course.navItem.settings.${courseData.type?.toLowerCase()}`)}
|
||||
description={courseData.description}
|
||||
@@ -167,6 +168,8 @@
|
||||
slug: string;
|
||||
color?: string | null;
|
||||
}>}
|
||||
{isExplore}
|
||||
{isLMS}
|
||||
/>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
import { exerciseApi } from '$features/course/api';
|
||||
import { courseApi } from '$features/course/api';
|
||||
import EditMode from '$features/course/components/exercise/edit-mode.svelte';
|
||||
import ExerciseSettingsTab from '$features/course/components/exercise/exercise-settings-tab.svelte';
|
||||
import ViewMode from '$features/course/components/exercise/view-mode.svelte';
|
||||
import Submissions from '$features/course/components/exercise/submissions/submissions.svelte';
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
@@ -41,17 +42,19 @@
|
||||
import VideoIcon from '@lucide/svelte/icons/video';
|
||||
import {
|
||||
getQuestionTypeId,
|
||||
getQuestionTypeKeyFromId,
|
||||
getQuestionTypeOptionById
|
||||
} from '$features/course/components/exercise/question-type-utils';
|
||||
import type { SubmissionListItem } from '$features/course/utils/types';
|
||||
import { Badge } from '@cio/ui/base/badge';
|
||||
import { isAutoGradableQuestionType } from '@cio/question-types';
|
||||
|
||||
interface Props {
|
||||
exerciseId?: string;
|
||||
goBack?: () => void;
|
||||
isFetching?: boolean;
|
||||
submissions: SubmissionListItem[];
|
||||
mySubmission: SubmissionListItem | null;
|
||||
mySubmissions?: SubmissionListItem[];
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -59,15 +62,18 @@
|
||||
goBack = () => {},
|
||||
isFetching = false,
|
||||
submissions,
|
||||
mySubmission
|
||||
mySubmissions = []
|
||||
}: Props = $props();
|
||||
|
||||
type ExerciseTab = 'questions' | 'submissions';
|
||||
type ExerciseTab = 'questions' | 'settings' | 'submissions';
|
||||
|
||||
function normalizeExerciseTab(tabParam: string | null): ExerciseTab {
|
||||
if (tabParam === 'submissions' || tabParam === 'analytics') {
|
||||
return 'submissions';
|
||||
}
|
||||
if (tabParam === 'settings') {
|
||||
return 'settings';
|
||||
}
|
||||
|
||||
return 'questions';
|
||||
}
|
||||
@@ -109,6 +115,26 @@
|
||||
return;
|
||||
}
|
||||
|
||||
if (requiresPositivePointsForAutoGrade) {
|
||||
const zeroPointQuestions = ($questionnaire.questions ?? []).filter((q) => !q.deletedAt && Number(q.points) === 0);
|
||||
|
||||
if (zeroPointQuestions.length > 0) {
|
||||
const pointErrors: Record<string, { option?: string; title?: string; points?: string }> = {};
|
||||
|
||||
for (const q of zeroPointQuestions) {
|
||||
pointErrors[q.id] = {
|
||||
points: t.get('course.navItem.lessons.exercises.all_exercises.points_required_auto_grade')
|
||||
};
|
||||
}
|
||||
|
||||
questionnaireValidation.set(pointErrors);
|
||||
|
||||
snackbar.error('snackbar.exercise.points_required_auto_grade');
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
isSaving = true;
|
||||
|
||||
reset();
|
||||
@@ -204,11 +230,41 @@
|
||||
)
|
||||
);
|
||||
const isExerciseUnlocked = $derived(exerciseContentItem?.isUnlocked ?? false);
|
||||
|
||||
const isSelfPacedCourse = $derived(courseApi.course?.type === 'SELF_PACED');
|
||||
const activeQuestionTypeIds = $derived(
|
||||
($questionnaire.questions ?? []).filter((q) => !q.deletedAt).map((q) => getQuestionTypeId(q))
|
||||
);
|
||||
function isExerciseFullyAutoGradable(typeIds: number[]): boolean {
|
||||
return typeIds.length > 0 && typeIds.every((id) => isAutoGradableQuestionType(getQuestionTypeKeyFromId(id)));
|
||||
}
|
||||
|
||||
/** When true, every question must have points > 0 before save */
|
||||
const requiresPositivePointsForAutoGrade = $derived(
|
||||
isSelfPacedCourse && isExerciseFullyAutoGradable(activeQuestionTypeIds)
|
||||
);
|
||||
const teacherAutoGradeBadge = $derived.by(() => {
|
||||
if ($globalStore.isStudent || !isSelfPacedCourse || activeQuestionTypeIds.length === 0) return null;
|
||||
return isExerciseFullyAutoGradable(activeQuestionTypeIds) ? ('auto' as const) : ('manual' as const);
|
||||
});
|
||||
</script>
|
||||
|
||||
<Page.Header isSticky={true} class="z-250! min-h-[36px]">
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>{$questionnaire.title || 'Exercise'}</Page.Title>
|
||||
<Page.Title class="flex flex-wrap items-center gap-2">
|
||||
<span>{$questionnaire.title || 'Exercise'}</span>
|
||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||
{#if teacherAutoGradeBadge === 'auto'}
|
||||
<Badge variant="success" class="font-normal">
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.auto_graded_badge')}
|
||||
</Badge>
|
||||
{:else if teacherAutoGradeBadge === 'manual'}
|
||||
<Badge variant="warning" class="font-normal">
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.manual_grading_required_badge')}
|
||||
</Badge>
|
||||
{/if}
|
||||
</RoleBasedSecurity>
|
||||
</Page.Title>
|
||||
</Page.HeaderContent>
|
||||
<Page.Action>
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -278,7 +334,7 @@
|
||||
<div class="overflow-x-hidden">
|
||||
{#if $globalStore.isStudent}
|
||||
{#if isExerciseUnlocked}
|
||||
<ViewMode {preview} {exerciseId} isFetchingExercise={isFetching} {mySubmission} />
|
||||
<ViewMode {preview} {exerciseId} isFetchingExercise={isFetching} {mySubmissions} />
|
||||
{:else}
|
||||
<Empty
|
||||
title={$t('course.navItem.lessons.content_locked_title')}
|
||||
@@ -290,10 +346,13 @@
|
||||
{/if}
|
||||
{:else}
|
||||
<UnderlineTabs.Root bind:value={selectedTab} class="mb-4">
|
||||
<UnderlineTabs.List class="grid w-full max-w-xs grid-cols-2">
|
||||
<UnderlineTabs.List class="grid w-full max-w-lg grid-cols-3">
|
||||
<UnderlineTabs.Trigger value="questions">
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.questions')}
|
||||
</UnderlineTabs.Trigger>
|
||||
<UnderlineTabs.Trigger value="settings">
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.settings')}
|
||||
</UnderlineTabs.Trigger>
|
||||
<UnderlineTabs.Trigger value="submissions">
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.analytics.submissions')}
|
||||
|
||||
@@ -303,11 +362,20 @@
|
||||
<UnderlineTabs.Content value="questions">
|
||||
<UpdateDescription {preview} />
|
||||
{#if !preview}
|
||||
<EditMode bind:shouldDeleteExercise {exerciseId} {goBack} />
|
||||
<EditMode
|
||||
bind:shouldDeleteExercise
|
||||
{exerciseId}
|
||||
{goBack}
|
||||
{requiresPositivePointsForAutoGrade}
|
||||
selfPacedCourse={isSelfPacedCourse}
|
||||
/>
|
||||
{:else}
|
||||
<ViewMode {preview} {exerciseId} isFetchingExercise={isFetching} {mySubmission} />
|
||||
<ViewMode {preview} {exerciseId} isFetchingExercise={isFetching} {mySubmissions} />
|
||||
{/if}
|
||||
</UnderlineTabs.Content>
|
||||
<UnderlineTabs.Content value="settings">
|
||||
<ExerciseSettingsTab {exerciseId} />
|
||||
</UnderlineTabs.Content>
|
||||
<UnderlineTabs.Content value="submissions">
|
||||
<Submissions bind:exerciseId {submissions} />
|
||||
</UnderlineTabs.Content>
|
||||
|
||||
@@ -35,6 +35,12 @@
|
||||
import { currentOrgDomain, currentOrgPath, isFreePlan } from '$lib/utils/store/org';
|
||||
import { page } from '$app/stores';
|
||||
|
||||
interface Props {
|
||||
hasUnsavedChanges?: boolean;
|
||||
}
|
||||
|
||||
let { hasUnsavedChanges = $bindable(false) }: Props = $props();
|
||||
|
||||
let isLoading = $state(false);
|
||||
let isDeleting = $state(false);
|
||||
let errors: {
|
||||
@@ -45,7 +51,6 @@
|
||||
description: undefined
|
||||
});
|
||||
let avatar: string | undefined;
|
||||
let hasUnsavedChanges = $state(false);
|
||||
let openDeleteModal = $state(false);
|
||||
let selectedTagIds = $state<string[]>([]);
|
||||
let initialTagIds = $state<string[]>([]);
|
||||
|
||||
@@ -3,7 +3,14 @@ import type { Course } from '$features/course/types';
|
||||
import type { CourseMembers, CourseSectionWithLessons, ListLessons } from '$features/course/utils/types';
|
||||
import { writable, type Writable } from 'svelte/store';
|
||||
|
||||
export const defaultCourse = {
|
||||
const defaultLessonTabsOrder: NonNullable<NonNullable<Course['metadata']>['lessonTabsOrder']> = [
|
||||
{ id: 1, name: 'course.navItem.lessons.materials.tabs.note.title' },
|
||||
{ id: 2, name: 'course.navItem.lessons.materials.tabs.slide.title' },
|
||||
{ id: 3, name: 'course.navItem.lessons.materials.tabs.video.title' },
|
||||
{ id: 4, name: 'course.navItem.lessons.materials.tabs.document.title' }
|
||||
];
|
||||
|
||||
export const defaultCourse: Course = {
|
||||
id: '',
|
||||
title: '',
|
||||
description: '',
|
||||
@@ -12,8 +19,10 @@ export const defaultCourse = {
|
||||
cost: 0,
|
||||
currency: 'USD',
|
||||
status: 'ACTIVE',
|
||||
isCertificateDownloadable: false,
|
||||
certificateTheme: 'professional',
|
||||
certificate: {
|
||||
isDownloadable: false,
|
||||
theme: 'professional'
|
||||
},
|
||||
isPublished: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
@@ -24,10 +33,6 @@ export const defaultCourse = {
|
||||
bannerImage: null,
|
||||
attendance: [],
|
||||
group: null,
|
||||
course_section: [],
|
||||
sections: [],
|
||||
lessons: [],
|
||||
exercises: [],
|
||||
content: {
|
||||
grouped: false,
|
||||
sections: [],
|
||||
@@ -52,12 +57,7 @@ export const defaultCourse = {
|
||||
description: '',
|
||||
imgUrl: ''
|
||||
},
|
||||
lessonTabsOrder: [
|
||||
{ id: 1, name: 'course.navItem.lessons.materials.tabs.note.title' },
|
||||
{ id: 2, name: 'course.navItem.lessons.materials.tabs.slide.title' },
|
||||
{ id: 3, name: 'course.navItem.lessons.materials.tabs.video.title' },
|
||||
{ id: 4, name: 'course.navItem.lessons.materials.tabs.document.title' }
|
||||
],
|
||||
lessonTabsOrder: defaultLessonTabsOrder,
|
||||
grading: false,
|
||||
lessonDownload: true,
|
||||
allowNewStudent: false,
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export type CourseCompletionModalState = {
|
||||
open: boolean;
|
||||
courseId: string;
|
||||
};
|
||||
|
||||
export const courseCompletionModal = writable<CourseCompletionModalState | null>(null);
|
||||
|
||||
export function openCourseCompletionModal(courseId: string) {
|
||||
courseCompletionModal.set({ open: true, courseId });
|
||||
}
|
||||
|
||||
export function closeCourseCompletionModal() {
|
||||
courseCompletionModal.set(null);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { CERTIFICATE_THEME_IDS, DEFAULT_CERTIFICATE_THEME_ID, type CertificateThemeId } from './constants';
|
||||
|
||||
const CERTIFICATE_THEME_ID_SET = new Set<string>(CERTIFICATE_THEME_IDS);
|
||||
|
||||
export function parseCertificateThemeId(raw: string | null | undefined): CertificateThemeId {
|
||||
if (raw != null && raw !== '' && CERTIFICATE_THEME_ID_SET.has(raw)) {
|
||||
return raw as CertificateThemeId;
|
||||
}
|
||||
return DEFAULT_CERTIFICATE_THEME_ID;
|
||||
}
|
||||
@@ -37,6 +37,19 @@ export type CourseSortOrder = (typeof CourseSortOrder)[keyof typeof CourseSortOr
|
||||
|
||||
export const DEFAULT_SORT_ORDER: CourseSortOrder = CourseSortOrder.Desc;
|
||||
|
||||
export const CERTIFICATE_THEME_IDS = [
|
||||
'professional',
|
||||
'plain',
|
||||
'purpleProfessionalBadge',
|
||||
'blueProfessionalBadge',
|
||||
'purpleBadgePattern',
|
||||
'blueBadgePattern'
|
||||
] as const;
|
||||
|
||||
export type CertificateThemeId = (typeof CERTIFICATE_THEME_IDS)[number];
|
||||
|
||||
export const DEFAULT_CERTIFICATE_THEME_ID: CertificateThemeId = 'professional';
|
||||
|
||||
const COURSE_SORT_ORDER_VALUES = new Set<string>(Object.values(CourseSortOrder));
|
||||
|
||||
export function parseCourseSortOrder(value: string | null | undefined): CourseSortOrder {
|
||||
|
||||
@@ -71,16 +71,17 @@ export function formatAnswers(data: {
|
||||
answers: SubmissionAnswer[];
|
||||
}): Record<string, AnswerData> {
|
||||
const answers: Record<string, AnswerData> = {};
|
||||
const questionByIdAndName: Record<string, string> = {};
|
||||
const questionKeyById: Record<string, string> = {};
|
||||
|
||||
for (const question of data.questions) {
|
||||
questionByIdAndName[String(question.id)] = question.name ?? '';
|
||||
const key = question.name ? String(question.name) : String(question.id);
|
||||
questionKeyById[String(question.id)] = key;
|
||||
}
|
||||
|
||||
for (const answer of data.answers) {
|
||||
const questionName = questionByIdAndName[String(answer.questionId)];
|
||||
if (questionName && answer.answerData) {
|
||||
answers[questionName] = answer.answerData;
|
||||
const questionKey = questionKeyById[String(answer.questionId)];
|
||||
if (questionKey && answer.answerData) {
|
||||
answers[questionKey] = answer.answerData;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -266,6 +266,13 @@ export type DeleteCourseRequest = (typeof classroomio.course)[':courseId']['$del
|
||||
export type UpdateCourseContentRequest = (typeof classroomio.course)[':courseId']['content']['$put'];
|
||||
export type DeleteCourseContentRequest = (typeof classroomio.course)[':courseId']['content']['$delete'];
|
||||
export type GetCourseProgressRequest = (typeof classroomio.course)[':courseId']['progress']['$get'];
|
||||
export type GetCertificationEvaluationRequest =
|
||||
(typeof classroomio.course)[':courseId']['certification-evaluation']['$get'];
|
||||
export type GetCertificationEvaluationSuccess = Extract<
|
||||
InferResponseType<GetCertificationEvaluationRequest>,
|
||||
{ success: true }
|
||||
>;
|
||||
export type CertificationEvaluationData = GetCertificationEvaluationSuccess['data'];
|
||||
export type CloneCourseRequest = (typeof classroomio.course)[':courseId']['clone']['$post'];
|
||||
export type EnrollCourseRequest = (typeof classroomio.course)[':courseId']['enroll']['$post'];
|
||||
|
||||
@@ -280,12 +287,17 @@ export type CourseMetadata = (NonNullable<ApiCourse['metadata']> extends object
|
||||
: Record<string, unknown>) & {
|
||||
isContentGroupingEnabled?: boolean;
|
||||
};
|
||||
export type CourseCertificate =
|
||||
NonNullable<ApiCourse['certificate']> extends object
|
||||
? NonNullable<ApiCourse['certificate']>
|
||||
: Record<string, unknown>;
|
||||
export type CourseContent = NonNullable<ApiCourse['content']>;
|
||||
export type CourseContentSection = CourseContent['sections'][number];
|
||||
export type CourseContentItem = CourseContent['items'][number];
|
||||
|
||||
export type Course = Omit<ApiCourse, 'metadata' | 'lessons' | 'sections' | 'exercises'> & {
|
||||
metadata?: CourseMetadata | null;
|
||||
certificate?: CourseCertificate | null;
|
||||
content: CourseContent;
|
||||
};
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
|
||||
<UnderlineTabs.Root bind:value={currentTab}>
|
||||
<UnderlineTabs.List>
|
||||
{#each tabs as tab}
|
||||
{#each tabs as tab (tab.value)}
|
||||
<UnderlineTabs.Trigger value={tab.value}>
|
||||
{$t(tab.label)}
|
||||
</UnderlineTabs.Trigger>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
AssignAudienceCoursesRequest,
|
||||
DeleteAudienceMemberRequest,
|
||||
DeleteTeamRequest,
|
||||
DomainRequestRequest,
|
||||
GetAudienceRequest,
|
||||
@@ -8,6 +9,8 @@ import type {
|
||||
InviteTeamRequest,
|
||||
OrgPublicCourses,
|
||||
OrganizationAudience,
|
||||
OrganizationAudiencePagination,
|
||||
OrganizationAudienceQuery,
|
||||
OrganizationTeamMembers,
|
||||
ResendAudienceInviteRequest,
|
||||
RevokeAudienceInviteRequest
|
||||
@@ -36,6 +39,7 @@ import { resolve } from '$app/paths';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { uploadImage } from '$lib/utils/services/upload';
|
||||
import { DEFAULT_ORG_AUDIENCE_QUERY, toAudienceRequestQuery } from '../utils/audience-query-utils';
|
||||
|
||||
export interface TOrgUpdateForm {
|
||||
name?: string;
|
||||
@@ -59,9 +63,16 @@ export interface TOrgUpdateForm {
|
||||
class OrgApi extends BaseApiWithErrors {
|
||||
teamMembers = $state<OrganizationTeamMembers>([]);
|
||||
audience = $state<OrganizationAudience>([]);
|
||||
audiencePagination = $state<OrganizationAudiencePagination | null>(null);
|
||||
publicCourses: OrgPublicCourses = $state([]);
|
||||
|
||||
isFetchingOrgPublicCourses = $state(false);
|
||||
private activeAudienceRequestController: AbortController | null = null;
|
||||
|
||||
cancelAudienceRequest() {
|
||||
this.activeAudienceRequestController?.abort();
|
||||
this.activeAudienceRequestController = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets organization team members (non-students)
|
||||
@@ -91,16 +102,52 @@ class OrgApi extends BaseApiWithErrors {
|
||||
* @param orgId Organization ID
|
||||
* @returns Audience array
|
||||
*/
|
||||
async getOrgAudience(orgId?: string) {
|
||||
async getOrgAudience(
|
||||
orgId?: string,
|
||||
query?: Partial<OrganizationAudienceQuery>,
|
||||
options: { abortPrevious?: boolean; signal?: AbortSignal } = {}
|
||||
) {
|
||||
if (!orgId) return;
|
||||
|
||||
return this.execute<GetAudienceRequest>({
|
||||
requestFn: () => classroomio.organization.audience.$get(),
|
||||
const requestQuery = toAudienceRequestQuery({
|
||||
...DEFAULT_ORG_AUDIENCE_QUERY,
|
||||
...query
|
||||
});
|
||||
|
||||
let requestSignal = options.signal;
|
||||
let requestController: AbortController | null = null;
|
||||
|
||||
if (options.abortPrevious) {
|
||||
this.cancelAudienceRequest();
|
||||
requestController = new AbortController();
|
||||
this.activeAudienceRequestController = requestController;
|
||||
requestSignal = requestController.signal;
|
||||
}
|
||||
|
||||
const response = await this.execute<GetAudienceRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.organization.audience.$get(
|
||||
{
|
||||
query: requestQuery!
|
||||
},
|
||||
{
|
||||
init: {
|
||||
signal: requestSignal
|
||||
}
|
||||
}
|
||||
),
|
||||
logContext: 'fetching organization audience',
|
||||
onSuccess: (response) => {
|
||||
this.audience = response.data;
|
||||
this.audiencePagination = response.pagination;
|
||||
}
|
||||
});
|
||||
|
||||
if (requestController && this.activeAudienceRequestController === requestController) {
|
||||
this.activeAudienceRequestController = null;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -384,7 +431,6 @@ class OrgApi extends BaseApiWithErrors {
|
||||
* Imports users into the organization as students
|
||||
*/
|
||||
async importAudienceMembers(data: TImportAudienceMembers) {
|
||||
console.log('importAudienceMembers', data);
|
||||
return this.execute<ImportAudienceRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.organization.audience.import.$post({
|
||||
@@ -396,7 +442,8 @@ class OrgApi extends BaseApiWithErrors {
|
||||
snackbar.success(
|
||||
t.get('audience.import.snackbar_success', {
|
||||
imported: d.imported,
|
||||
skipped: d.skipped,
|
||||
assigned: d.assigned ?? 0,
|
||||
pendingInvitesRenewed: d.pendingInvitesRenewed ?? 0,
|
||||
emailsSent: d.emailsSent
|
||||
})
|
||||
);
|
||||
@@ -460,8 +507,6 @@ class OrgApi extends BaseApiWithErrors {
|
||||
} else {
|
||||
snackbar.error('audience.invite.resend_snackbar_email_failed');
|
||||
}
|
||||
const orgId = get(currentOrg).id;
|
||||
if (orgId) this.getOrgAudience(orgId);
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
@@ -480,8 +525,6 @@ class OrgApi extends BaseApiWithErrors {
|
||||
logContext: 'revoking audience invite',
|
||||
onSuccess: () => {
|
||||
snackbar.success('audience.invite.revoke_snackbar_success');
|
||||
const orgId = get(currentOrg).id;
|
||||
if (orgId) this.getOrgAudience(orgId);
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
@@ -490,6 +533,27 @@ class OrgApi extends BaseApiWithErrors {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async deleteAudienceMember(memberId: number) {
|
||||
return this.execute<DeleteAudienceMemberRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.organization.audience[':memberId'].$delete({
|
||||
param: { memberId: memberId.toString() }
|
||||
}),
|
||||
logContext: 'removing audience member',
|
||||
onSuccess: () => {
|
||||
snackbar.success('audience.delete.snackbar_success');
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error(result);
|
||||
return;
|
||||
}
|
||||
|
||||
snackbar.error('error' in result ? result.error : result.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const orgApi = new OrgApi();
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { OrganizationAudienceQuery, OrganizationAudienceRequestQuery } from './types';
|
||||
|
||||
export const DEFAULT_ORG_AUDIENCE_QUERY: OrganizationAudienceQuery = {
|
||||
page: 1,
|
||||
limit: 20,
|
||||
sortBy: 'createdAt',
|
||||
sortOrder: 'desc'
|
||||
};
|
||||
|
||||
export function getAudienceQueryFromSearchParams(
|
||||
searchParams: URLSearchParams,
|
||||
defaults: OrganizationAudienceQuery = DEFAULT_ORG_AUDIENCE_QUERY
|
||||
): OrganizationAudienceQuery {
|
||||
const page = Number(searchParams.get('page'));
|
||||
const limit = Number(searchParams.get('limit'));
|
||||
const sortBy = searchParams.get('sortBy');
|
||||
const sortOrder = searchParams.get('sortOrder');
|
||||
const search = searchParams.get('search')?.trim() || undefined;
|
||||
|
||||
return {
|
||||
page: Number.isFinite(page) && page > 0 ? Math.floor(page) : defaults.page,
|
||||
limit: Number.isFinite(limit) && limit > 0 ? Math.floor(limit) : defaults.limit,
|
||||
sortBy: sortBy === 'name' || sortBy === 'email' || sortBy === 'createdAt' ? sortBy : defaults.sortBy,
|
||||
sortOrder: sortOrder === 'asc' || sortOrder === 'desc' ? sortOrder : defaults.sortOrder,
|
||||
search
|
||||
};
|
||||
}
|
||||
|
||||
export function getAudienceSearchParams(query: OrganizationAudienceQuery): URLSearchParams {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
searchParams.set('page', String(query.page));
|
||||
searchParams.set('limit', String(query.limit));
|
||||
searchParams.set('sortBy', query.sortBy);
|
||||
searchParams.set('sortOrder', query.sortOrder);
|
||||
|
||||
if (query.search) {
|
||||
searchParams.set('search', query.search);
|
||||
}
|
||||
|
||||
return searchParams;
|
||||
}
|
||||
|
||||
export function toAudienceRequestQuery(
|
||||
query?: Partial<OrganizationAudienceQuery>
|
||||
): OrganizationAudienceRequestQuery | undefined {
|
||||
if (!query) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
page: query.page != null ? String(query.page) : undefined,
|
||||
limit: query.limit != null ? String(query.limit) : undefined,
|
||||
search: query.search,
|
||||
sortBy: query.sortBy,
|
||||
sortOrder: query.sortOrder
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { classroomio, type InferResponseType } from '$lib/utils/services/api';
|
||||
import { classroomio, type InferRequestType, type InferResponseType } from '$lib/utils/services/api';
|
||||
|
||||
// Team member types
|
||||
export type GetTeamRequest = (typeof classroomio.organization)['team']['$get'];
|
||||
@@ -20,9 +20,19 @@ export type OrganizationTeamMembers = Array<
|
||||
export type GetAudienceRequest = (typeof classroomio.organization)['audience']['$get'];
|
||||
export type OrganizationAudienceResponse = InferResponseType<GetAudienceRequest> | null;
|
||||
export type OrganizationAudienceSuccess = Extract<InferResponseType<GetAudienceRequest>, { success: true }>;
|
||||
export type OrganizationAudienceRequestQuery = NonNullable<InferRequestType<GetAudienceRequest>['query']>;
|
||||
export type OrganizationAudienceSortBy = 'createdAt' | 'name' | 'email';
|
||||
export type OrganizationAudienceSortOrder = 'asc' | 'desc';
|
||||
export type OrganizationAudienceQuery = {
|
||||
page: number;
|
||||
limit: number;
|
||||
search?: string;
|
||||
sortBy: OrganizationAudienceSortBy;
|
||||
sortOrder: OrganizationAudienceSortOrder;
|
||||
};
|
||||
|
||||
export type OrganizationAudience = OrganizationAudienceSuccess['data'];
|
||||
|
||||
export type OrganizationAudiencePagination = OrganizationAudienceSuccess['pagination'];
|
||||
export type OrganizationAudienceMember = OrganizationAudience[number];
|
||||
|
||||
// Org Public Courses types
|
||||
@@ -43,6 +53,8 @@ export type InviteTeamData = InviteTeamSuccess['data'];
|
||||
|
||||
export type DeleteTeamRequest = (typeof classroomio.organization)['team'][':memberId']['$delete'];
|
||||
export type DeleteTeamSuccess = Extract<InferResponseType<DeleteTeamRequest>, { success: true }>;
|
||||
export type DeleteAudienceMemberRequest = (typeof classroomio.organization)['audience'][':memberId']['$delete'];
|
||||
export type DeleteAudienceMemberSuccess = Extract<InferResponseType<DeleteAudienceMemberRequest>, { success: true }>;
|
||||
|
||||
// update organization types
|
||||
export type UpdateOrganizationRequest = (typeof classroomio.organization)['$put'];
|
||||
|
||||
@@ -370,7 +370,7 @@
|
||||
</Field.Field>
|
||||
{:else if currentDomainStatus !== 'verified'}
|
||||
<Field.Field>
|
||||
<div class="rounded-md border px-4 py-3 text-sm ui:text-muted-foreground">
|
||||
<div class="ui:text-muted-foreground rounded-md border px-4 py-3 text-sm">
|
||||
{$t('components.settings.domains.reconnect_hint')}
|
||||
</div>
|
||||
</Field.Field>
|
||||
|
||||
@@ -10,3 +10,10 @@ export function toggleConfetti() {
|
||||
show: !confetti.show
|
||||
}));
|
||||
}
|
||||
|
||||
export function setConfetti(show: boolean) {
|
||||
confettiStore.update((confetti) => ({
|
||||
...confetti,
|
||||
show
|
||||
}));
|
||||
}
|
||||
|
||||
+15
-7
@@ -18,6 +18,7 @@
|
||||
HoverableItem,
|
||||
PreviewIcon
|
||||
} from '@cio/ui/custom/moving-icons';
|
||||
import type { TCourseUpdate } from '@cio/utils/validation/course';
|
||||
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
import { CloseButton } from '$features/ui';
|
||||
@@ -151,15 +152,22 @@
|
||||
course.slug = course.slug || generateSlug(course.title, { appendTimestamp: true });
|
||||
|
||||
console.log('course', course);
|
||||
await courseApi.update(courseId, {
|
||||
const updatePayload = {
|
||||
...course,
|
||||
type: course.type!,
|
||||
slug: course.slug!,
|
||||
isPublished: course.isPublished ?? undefined,
|
||||
overview: course.overview ?? undefined,
|
||||
isCertificateDownloadable: course.isCertificateDownloadable ?? undefined,
|
||||
certificateTheme: course.certificateTheme ?? undefined
|
||||
});
|
||||
certificate: course.certificate
|
||||
? {
|
||||
...course.certificate,
|
||||
isDownloadable: course.certificate.isDownloadable ?? undefined,
|
||||
theme: course.certificate.theme ?? undefined
|
||||
}
|
||||
: undefined
|
||||
} as TCourseUpdate;
|
||||
|
||||
await courseApi.update(courseId, updatePayload);
|
||||
|
||||
loading = false;
|
||||
syncCourseStore(course);
|
||||
@@ -173,7 +181,7 @@
|
||||
});
|
||||
}
|
||||
|
||||
function setter(value: any, setterKey: string) {
|
||||
function setter(value: unknown, setterKey: string) {
|
||||
if (typeof value === 'undefined') return;
|
||||
|
||||
// Untrack the course change to avoid unnecessary re-renders
|
||||
@@ -231,7 +239,7 @@
|
||||
</Sidebar.GroupLabel>
|
||||
<Sidebar.GroupContent>
|
||||
<Sidebar.Menu>
|
||||
{#each sections as section}
|
||||
{#each sections as section (section.key)}
|
||||
{@const SectionIcon = section.icon}
|
||||
<HoverableItem>
|
||||
{#snippet children(isHovered)}
|
||||
@@ -253,7 +261,7 @@
|
||||
</Sidebar.Group>
|
||||
{:else}
|
||||
<Sidebar.Menu>
|
||||
{#each sections as section}
|
||||
{#each sections as section (section.key)}
|
||||
{@const SectionIcon = section.icon}
|
||||
<HoverableItem>
|
||||
{#snippet children(isHovered)}
|
||||
|
||||
@@ -32,7 +32,8 @@ export const QUESTION_TYPES = QUESTION_TYPE_REGISTRY.filter((qt) => !HIDDEN_QUES
|
||||
id: questionType.id,
|
||||
key: questionType.key,
|
||||
label: questionType.label,
|
||||
labelKey: QUESTION_TYPE_LABEL_KEYS[questionType.key]
|
||||
labelKey: QUESTION_TYPE_LABEL_KEYS[questionType.key],
|
||||
autoGradable: questionType.autoGradable
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user