feat: certification rules, content sanitization, handle draft course updates and reset content, better redis logging
This commit is contained in:
+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>
|
||||
|
||||
Reference in New Issue
Block a user