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}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user