mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-02-09 10:14:20 +00:00
feat: add various improvements to the table component (#961)
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
This commit is contained in:
@@ -1,217 +0,0 @@
|
||||
<script lang="ts" generics="T extends {id:string}">
|
||||
import Checkbox from '$lib/components/ui/checkbox/checkbox.svelte';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import * as Pagination from '$lib/components/ui/pagination';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import * as Table from '$lib/components/ui/table/index.js';
|
||||
import Empty from '$lib/icons/empty.svelte';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
|
||||
import { debounced } from '$lib/utils/debounce-util';
|
||||
import { cn } from '$lib/utils/style';
|
||||
import { ChevronDown } from '@lucide/svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
import Button from './ui/button/button.svelte';
|
||||
|
||||
let {
|
||||
items,
|
||||
requestOptions = $bindable(),
|
||||
selectedIds = $bindable(),
|
||||
withoutSearch = false,
|
||||
selectionDisabled = false,
|
||||
onRefresh,
|
||||
columns,
|
||||
rows
|
||||
}: {
|
||||
items: Paginated<T>;
|
||||
requestOptions: SearchPaginationSortRequest;
|
||||
selectedIds?: string[];
|
||||
withoutSearch?: boolean;
|
||||
selectionDisabled?: boolean;
|
||||
onRefresh: (requestOptions: SearchPaginationSortRequest) => Promise<Paginated<T>>;
|
||||
columns: { label: string; hidden?: boolean; sortColumn?: string }[];
|
||||
rows: Snippet<[{ item: T }]>;
|
||||
} = $props();
|
||||
|
||||
let searchValue = $state('');
|
||||
let availablePageSizes: number[] = [20, 50, 100];
|
||||
|
||||
let allChecked = $derived.by(() => {
|
||||
if (!selectedIds || items.data.length === 0) return false;
|
||||
for (const item of items.data) {
|
||||
if (!selectedIds.includes(item.id)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const onSearch = debounced(async (search: string) => {
|
||||
requestOptions.search = search;
|
||||
await onRefresh(requestOptions);
|
||||
searchValue = search;
|
||||
}, 300);
|
||||
|
||||
async function onAllCheck(checked: boolean) {
|
||||
const pageIds = items.data.map((item) => item.id);
|
||||
const current = selectedIds ?? [];
|
||||
|
||||
if (checked) {
|
||||
selectedIds = Array.from(new Set([...current, ...pageIds]));
|
||||
} else {
|
||||
selectedIds = current.filter((id) => !pageIds.includes(id));
|
||||
}
|
||||
}
|
||||
|
||||
async function onCheck(checked: boolean, id: string) {
|
||||
const current = selectedIds ?? [];
|
||||
if (checked) {
|
||||
selectedIds = Array.from(new Set([...current, id]));
|
||||
} else {
|
||||
selectedIds = current.filter((selectedId) => selectedId !== id);
|
||||
}
|
||||
}
|
||||
|
||||
async function onPageChange(page: number) {
|
||||
requestOptions.pagination = { limit: items.pagination.itemsPerPage, page };
|
||||
onRefresh(requestOptions);
|
||||
}
|
||||
|
||||
async function onPageSizeChange(size: number) {
|
||||
requestOptions.pagination = { limit: size, page: 1 };
|
||||
onRefresh(requestOptions);
|
||||
}
|
||||
|
||||
async function onSort(column?: string, direction: 'asc' | 'desc' = 'asc') {
|
||||
if (!column) return;
|
||||
|
||||
requestOptions.sort = { column, direction };
|
||||
onRefresh(requestOptions);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if !withoutSearch}
|
||||
<Input
|
||||
value={searchValue}
|
||||
class={cn(
|
||||
'relative z-50 mb-4 max-w-sm',
|
||||
items.data.length == 0 && searchValue == '' && 'hidden'
|
||||
)}
|
||||
placeholder={m.search()}
|
||||
type="text"
|
||||
oninput={(e: Event) => onSearch((e.currentTarget as HTMLInputElement).value)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if items.data.length === 0 && searchValue === ''}
|
||||
<div class="my-5 flex flex-col items-center">
|
||||
<Empty class="text-muted-foreground h-20" />
|
||||
<p class="text-muted-foreground mt-3 text-sm">{m.no_items_found()}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<Table.Root class="min-w-full table-auto overflow-x-auto">
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
{#if selectedIds}
|
||||
<Table.Head class="w-12">
|
||||
<Checkbox
|
||||
disabled={selectionDisabled}
|
||||
checked={allChecked}
|
||||
onCheckedChange={(c: boolean) => onAllCheck(c as boolean)}
|
||||
/>
|
||||
</Table.Head>
|
||||
{/if}
|
||||
{#each columns as column}
|
||||
<Table.Head class={cn(column.hidden && 'sr-only', column.sortColumn && 'px-0')}>
|
||||
{#if column.sortColumn}
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="flex items-center"
|
||||
onclick={() =>
|
||||
onSort(
|
||||
column.sortColumn,
|
||||
requestOptions.sort?.direction === 'desc' ? 'asc' : 'desc'
|
||||
)}
|
||||
>
|
||||
{column.label}
|
||||
{#if requestOptions.sort?.column === column.sortColumn}
|
||||
<ChevronDown
|
||||
class={cn(
|
||||
'ml-2 size-4',
|
||||
requestOptions.sort?.direction === 'asc' ? 'rotate-180' : ''
|
||||
)}
|
||||
/>
|
||||
{/if}
|
||||
</Button>
|
||||
{:else}
|
||||
{column.label}
|
||||
{/if}
|
||||
</Table.Head>
|
||||
{/each}
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each items.data as item}
|
||||
<Table.Row class={selectedIds?.includes(item.id) ? 'bg-muted/20' : ''}>
|
||||
{#if selectedIds}
|
||||
<Table.Cell class="w-12">
|
||||
<Checkbox
|
||||
disabled={selectionDisabled}
|
||||
checked={selectedIds.includes(item.id)}
|
||||
onCheckedChange={(c: boolean) => onCheck(c, item.id)}
|
||||
/>
|
||||
</Table.Cell>
|
||||
{/if}
|
||||
{@render rows({ item })}
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
|
||||
<div class="mt-5 flex flex-col-reverse items-center justify-between gap-3 sm:flex-row">
|
||||
<div class="flex items-center space-x-2">
|
||||
<p class="text-sm font-medium">{m.items_per_page()}</p>
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={items.pagination.itemsPerPage.toString()}
|
||||
onValueChange={(v) => onPageSizeChange(Number(v))}
|
||||
>
|
||||
<Select.Trigger class="h-9 w-[80px]">
|
||||
{items.pagination.itemsPerPage}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each availablePageSizes as size}
|
||||
<Select.Item value={size.toString()}>{size}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
<Pagination.Root
|
||||
class="mx-0 w-auto"
|
||||
count={items.pagination.totalItems}
|
||||
perPage={items.pagination.itemsPerPage}
|
||||
{onPageChange}
|
||||
page={items.pagination.currentPage}
|
||||
>
|
||||
{#snippet children({ pages })}
|
||||
<Pagination.Content class="flex justify-end">
|
||||
<Pagination.Item>
|
||||
<Pagination.PrevButton />
|
||||
</Pagination.Item>
|
||||
{#each pages as page (page.key)}
|
||||
{#if page.type !== 'ellipsis' && page.value != 0}
|
||||
<Pagination.Item>
|
||||
<Pagination.Link {page} isActive={items.pagination.currentPage === page.value}>
|
||||
{page.value}
|
||||
</Pagination.Link>
|
||||
</Pagination.Item>
|
||||
{/if}
|
||||
{/each}
|
||||
<Pagination.Item>
|
||||
<Pagination.NextButton />
|
||||
</Pagination.Item>
|
||||
</Pagination.Content>
|
||||
{/snippet}
|
||||
</Pagination.Root>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,69 +1,111 @@
|
||||
<script lang="ts">
|
||||
import AdvancedTable from '$lib/components/advanced-table.svelte';
|
||||
import AdvancedTable from '$lib/components/table/advanced-table.svelte';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import * as Table from '$lib/components/ui/table';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import {translateAuditLogEvent} from "$lib/utils/audit-log-translator";
|
||||
import AuditLogService from '$lib/services/audit-log-service';
|
||||
import type { AuditLog } from '$lib/types/audit-log.type';
|
||||
import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
|
||||
import type { AdvancedTableColumn } from '$lib/types/advanced-table.type';
|
||||
import type { AuditLog, AuditLogFilter } from '$lib/types/audit-log.type';
|
||||
import { translateAuditLogEvent } from '$lib/utils/audit-log-translator';
|
||||
|
||||
let {
|
||||
auditLogs,
|
||||
isAdmin = false,
|
||||
requestOptions
|
||||
filters
|
||||
}: {
|
||||
auditLogs: Paginated<AuditLog>;
|
||||
isAdmin?: boolean;
|
||||
requestOptions: SearchPaginationSortRequest;
|
||||
filters?: AuditLogFilter;
|
||||
} = $props();
|
||||
|
||||
const auditLogService = new AuditLogService();
|
||||
let tableRef: AdvancedTable<AuditLog>;
|
||||
|
||||
const columns: AdvancedTableColumn<AuditLog>[] = [
|
||||
{
|
||||
label: m.time(),
|
||||
column: 'createdAt',
|
||||
sortable: true,
|
||||
value: (item) => new Date(item.createdAt).toLocaleString()
|
||||
},
|
||||
{
|
||||
label: m.username(),
|
||||
column: 'username',
|
||||
hidden: !isAdmin,
|
||||
value: (item) => item.username ?? m.unknown()
|
||||
},
|
||||
{
|
||||
label: m.event(),
|
||||
column: 'event',
|
||||
sortable: true,
|
||||
cell: EventCell
|
||||
},
|
||||
{
|
||||
label: m.approximate_location(),
|
||||
key: 'location',
|
||||
value: (item) => formatLocation(item)
|
||||
},
|
||||
{
|
||||
label: m.ip_address(),
|
||||
column: 'ipAddress',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
label: m.device(),
|
||||
column: 'device',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
label: m.client(),
|
||||
key: 'client',
|
||||
value: (item) => item.data?.clientName
|
||||
}
|
||||
];
|
||||
|
||||
$effect(() => {
|
||||
if (filters) {
|
||||
tableRef?.refresh();
|
||||
}
|
||||
});
|
||||
|
||||
export async function refresh() {
|
||||
await tableRef.refresh();
|
||||
}
|
||||
|
||||
function formatLocation(log: AuditLog) {
|
||||
if (log.city && log.country) {
|
||||
return `${log.city}, ${log.country}`;
|
||||
} else if (log.country) {
|
||||
return log.country;
|
||||
} else {
|
||||
return m.unknown();
|
||||
}
|
||||
}
|
||||
|
||||
function wrapFilters(filters?: Record<string, string>) {
|
||||
if (!filters) return undefined;
|
||||
return Object.fromEntries(
|
||||
Object.entries(filters)
|
||||
.filter(([_, value]) => value !== undefined && value !== null && value !== '')
|
||||
.map(([key, value]) => [key, [value]])
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet EventCell({ item }: { item: AuditLog })}
|
||||
<Badge class="rounded-full" variant="outline">
|
||||
{translateAuditLogEvent(item.event)}
|
||||
</Badge>
|
||||
{/snippet}
|
||||
|
||||
<AdvancedTable
|
||||
items={auditLogs}
|
||||
{requestOptions}
|
||||
onRefresh={async (options) =>
|
||||
id="audit-log-list-{isAdmin ? 'admin' : 'user'}"
|
||||
bind:this={tableRef}
|
||||
fetchCallback={async (options) =>
|
||||
isAdmin
|
||||
? (auditLogs = await auditLogService.listAllLogs(options))
|
||||
: (auditLogs = await auditLogService.list(options))}
|
||||
columns={[
|
||||
{ label: m.time(), sortColumn: 'createdAt' },
|
||||
...(isAdmin ? [{ label: 'Username' }] : []),
|
||||
{ label: m.event(), sortColumn: 'event' },
|
||||
{ label: m.approximate_location(), sortColumn: 'city' },
|
||||
{ label: m.ip_address(), sortColumn: 'ipAddress' },
|
||||
{ label: m.device(), sortColumn: 'device' },
|
||||
{ label: m.client() }
|
||||
]}
|
||||
? await auditLogService.listAllLogs({
|
||||
...options,
|
||||
filters: wrapFilters(filters)
|
||||
})
|
||||
: await auditLogService.list(options)}
|
||||
defaultSort={{ column: 'createdAt', direction: 'desc' }}
|
||||
withoutSearch
|
||||
>
|
||||
{#snippet rows({ item })}
|
||||
<Table.Cell>{new Date(item.createdAt).toLocaleString()}</Table.Cell>
|
||||
{#if isAdmin}
|
||||
<Table.Cell>
|
||||
{#if item.username}
|
||||
{item.username}
|
||||
{:else}
|
||||
Unknown User
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
{/if}
|
||||
<Table.Cell>
|
||||
<Badge class="rounded-full" variant="outline">{translateAuditLogEvent(item.event)}</Badge>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{#if item.city && item.country}
|
||||
{item.city}, {item.country}
|
||||
{:else if item.country}
|
||||
{item.country}
|
||||
{:else}
|
||||
{m.unknown()}
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell>{item.ipAddress}</Table.Cell>
|
||||
<Table.Cell>{item.device}</Table.Cell>
|
||||
<Table.Cell>{item.data.clientName}</Table.Cell>
|
||||
{/snippet}
|
||||
</AdvancedTable>
|
||||
{columns}
|
||||
/>
|
||||
|
||||
@@ -12,14 +12,10 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class={'bg-muted flex items-center justify-center rounded-2xl p-3'}>
|
||||
<div class={cn('bg-muted flex items-center justify-center rounded-2xl p-3', props.class)}>
|
||||
{#if error}
|
||||
<LucideImageOff class={cn('text-muted-foreground p-5', props.class)} />
|
||||
<LucideImageOff class="text-muted-foreground p-5" />
|
||||
{:else}
|
||||
<img
|
||||
{...props}
|
||||
class={cn('object-contain aspect-square', props.class)}
|
||||
onerror={() => (error = true)}
|
||||
/>
|
||||
<img {...props} class="aspect-square object-contain" onerror={() => (error = true)} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,33 +1,29 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import AdvancedTable from '$lib/components/advanced-table.svelte';
|
||||
import { openConfirmDialog } from '$lib/components/confirm-dialog/';
|
||||
import AdvancedTable from '$lib/components/table/advanced-table.svelte';
|
||||
import { Badge, type BadgeVariant } from '$lib/components/ui/badge';
|
||||
import { Button, buttonVariants } from '$lib/components/ui/button';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import * as Table from '$lib/components/ui/table';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import UserService from '$lib/services/user-service';
|
||||
import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
|
||||
import type {
|
||||
AdvancedTableColumn,
|
||||
CreateAdvancedTableActions
|
||||
} from '$lib/types/advanced-table.type';
|
||||
import type { SignupTokenDto } from '$lib/types/signup-token.type';
|
||||
import { axiosErrorToast } from '$lib/utils/error-util';
|
||||
import { Copy, Ellipsis, Trash2 } from '@lucide/svelte';
|
||||
import { Copy, Trash2 } from '@lucide/svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
let {
|
||||
open = $bindable(),
|
||||
signupTokens = $bindable(),
|
||||
signupTokensRequestOptions,
|
||||
onTokenDeleted
|
||||
open = $bindable()
|
||||
}: {
|
||||
open: boolean;
|
||||
signupTokens: Paginated<SignupTokenDto>;
|
||||
signupTokensRequestOptions: SearchPaginationSortRequest;
|
||||
onTokenDeleted?: () => Promise<void>;
|
||||
} = $props();
|
||||
|
||||
const userService = new UserService();
|
||||
let tableRef: AdvancedTable<SignupTokenDto>;
|
||||
|
||||
function formatDate(dateStr: string | undefined) {
|
||||
if (!dateStr) return m.never();
|
||||
@@ -44,12 +40,8 @@
|
||||
action: async () => {
|
||||
try {
|
||||
await userService.deleteSignupToken(token.id);
|
||||
await tableRef.refresh();
|
||||
toast.success(m.signup_token_deleted_successfully());
|
||||
|
||||
// Refresh the tokens
|
||||
if (onTokenDeleted) {
|
||||
await onTokenDeleted();
|
||||
}
|
||||
} catch (e) {
|
||||
axiosErrorToast(e);
|
||||
}
|
||||
@@ -98,8 +90,69 @@
|
||||
axiosErrorToast(err);
|
||||
});
|
||||
}
|
||||
|
||||
const columns: AdvancedTableColumn<SignupTokenDto>[] = [
|
||||
{ label: m.token(), column: 'token', cell: TokenCell },
|
||||
{ label: m.status(), key: 'status', cell: StatusCell },
|
||||
{
|
||||
label: m.usage(),
|
||||
column: 'usageCount',
|
||||
sortable: true,
|
||||
cell: UsageCell
|
||||
},
|
||||
{
|
||||
label: m.expires(),
|
||||
column: 'expiresAt',
|
||||
sortable: true,
|
||||
value: (item) => formatDate(item.expiresAt)
|
||||
},
|
||||
{ label: 'Usage Limit', column: 'usageLimit' },
|
||||
{
|
||||
label: m.created(),
|
||||
column: 'createdAt',
|
||||
sortable: true,
|
||||
hidden: true,
|
||||
value: (item) => formatDate(item.createdAt)
|
||||
}
|
||||
];
|
||||
|
||||
const actions: CreateAdvancedTableActions<SignupTokenDto> = (_) => [
|
||||
{
|
||||
label: m.copy(),
|
||||
icon: Copy,
|
||||
onClick: (token) => copySignupLink(token)
|
||||
},
|
||||
{
|
||||
label: m.delete(),
|
||||
icon: Trash2,
|
||||
variant: 'danger',
|
||||
onClick: (token) => deleteToken(token)
|
||||
}
|
||||
];
|
||||
</script>
|
||||
|
||||
{#snippet TokenCell({ item }: { item: SignupTokenDto })}
|
||||
<span class="font-mono text-xs">
|
||||
{item.token.substring(0, 3)}...{item.token.substring(Math.max(item.token.length - 4, 0))}
|
||||
</span>
|
||||
{/snippet}
|
||||
|
||||
{#snippet StatusCell({ item }: { item: SignupTokenDto })}
|
||||
{@const status = getTokenStatus(item)}
|
||||
{@const statusBadge = getStatusBadge(status)}
|
||||
<Badge class="rounded-full" variant={statusBadge.variant}>
|
||||
{statusBadge.text}
|
||||
</Badge>
|
||||
{/snippet}
|
||||
|
||||
{#snippet UsageCell({ item }: { item: SignupTokenDto })}
|
||||
<div class="flex items-center gap-1">
|
||||
{item.usageCount}
|
||||
{m.of()}
|
||||
{item.usageLimit}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<Dialog.Root {open} {onOpenChange}>
|
||||
<Dialog.Content class="sm-min-w[500px] max-h-[90vh] min-w-[90vw] overflow-auto lg:min-w-[1000px]">
|
||||
<Dialog.Header>
|
||||
@@ -111,70 +164,13 @@
|
||||
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<AdvancedTable
|
||||
items={signupTokens}
|
||||
requestOptions={signupTokensRequestOptions}
|
||||
id="signup-token-list"
|
||||
withoutSearch={true}
|
||||
onRefresh={async (options) => {
|
||||
const result = await userService.listSignupTokens(options);
|
||||
signupTokens = result;
|
||||
return result;
|
||||
}}
|
||||
columns={[
|
||||
{ label: m.token() },
|
||||
{ label: m.status() },
|
||||
{ label: m.usage(), sortColumn: 'usageCount' },
|
||||
{ label: m.expires(), sortColumn: 'expiresAt' },
|
||||
{ label: m.created(), sortColumn: 'createdAt' },
|
||||
{ label: m.actions(), hidden: true }
|
||||
]}
|
||||
>
|
||||
{#snippet rows({ item })}
|
||||
<Table.Cell class="font-mono text-xs">
|
||||
{item.token.substring(0, 2)}...{item.token.substring(item.token.length - 4)}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{@const status = getTokenStatus(item)}
|
||||
{@const statusBadge = getStatusBadge(status)}
|
||||
<Badge class="rounded-full" variant={statusBadge.variant}>
|
||||
{statusBadge.text}
|
||||
</Badge>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<div class="flex items-center gap-1">
|
||||
{`${item.usageCount} ${m.of()} ${item.usageLimit}`}
|
||||
</div>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-sm">
|
||||
<div class="flex items-center gap-1">
|
||||
{formatDate(item.expiresAt)}
|
||||
</div>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-sm">
|
||||
{formatDate(item.createdAt)}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger class={buttonVariants({ variant: 'ghost', size: 'icon' })}>
|
||||
<Ellipsis class="size-4" />
|
||||
<span class="sr-only">{m.toggle_menu()}</span>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Item onclick={() => copySignupLink(item)}>
|
||||
<Copy class="mr-2 size-4" />
|
||||
{m.copy()}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
class="text-red-500 focus:!text-red-700"
|
||||
onclick={() => deleteToken(item)}
|
||||
>
|
||||
<Trash2 class="mr-2 size-4" />
|
||||
{m.delete()}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</Table.Cell>
|
||||
{/snippet}
|
||||
</AdvancedTable>
|
||||
fetchCallback={userService.listSignupTokens}
|
||||
bind:this={tableRef}
|
||||
{columns}
|
||||
{actions}
|
||||
/>
|
||||
</div>
|
||||
<Dialog.Footer class="mt-3">
|
||||
<Button onclick={() => (open = false)}>
|
||||
|
||||
@@ -13,11 +13,9 @@
|
||||
import { mode } from 'mode-watcher';
|
||||
|
||||
let {
|
||||
open = $bindable(),
|
||||
onTokenCreated
|
||||
open = $bindable()
|
||||
}: {
|
||||
open: boolean;
|
||||
onTokenCreated?: () => Promise<void>;
|
||||
} = $props();
|
||||
|
||||
const userService = new UserService();
|
||||
@@ -37,12 +35,11 @@
|
||||
|
||||
async function createSignupToken() {
|
||||
try {
|
||||
signupToken = await userService.createSignupToken(availableExpirations[selectedExpiration], usageLimit);
|
||||
signupToken = await userService.createSignupToken(
|
||||
availableExpirations[selectedExpiration],
|
||||
usageLimit
|
||||
);
|
||||
signupLink = `${page.url.origin}/st/${signupToken}`;
|
||||
|
||||
if (onTokenCreated) {
|
||||
await onTokenCreated();
|
||||
}
|
||||
} catch (e) {
|
||||
axiosErrorToast(e);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<script lang="ts" generics="TData extends Record<string, any>">
|
||||
import { buttonVariants } from '$lib/components/ui/button/index.js';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import type { AdvancedTableColumn } from '$lib/types/advanced-table.type';
|
||||
import Settings2Icon from '@lucide/svelte/icons/settings-2';
|
||||
|
||||
let {
|
||||
columns,
|
||||
selectedColumns = $bindable([])
|
||||
}: { columns: AdvancedTableColumn<TData>[]; selectedColumns: string[] } = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
class={buttonVariants({
|
||||
variant: 'outline',
|
||||
size: 'sm',
|
||||
class: 'ml-auto h-8'
|
||||
})}
|
||||
>
|
||||
<Settings2Icon />
|
||||
<span class="hidden md:flex">{m.view()}</span>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content>
|
||||
<DropdownMenu.Group>
|
||||
<DropdownMenu.Label>{m.toggle_columns()}</DropdownMenu.Label>
|
||||
<DropdownMenu.Separator />
|
||||
{#each columns as column (column)}
|
||||
<DropdownMenu.CheckboxItem
|
||||
closeOnSelect={false}
|
||||
checked={selectedColumns.includes(column.column ?? column.key!)}
|
||||
onCheckedChange={(v) => {
|
||||
const key = column.column ?? column.key!;
|
||||
if (v) {
|
||||
selectedColumns = [...selectedColumns, key];
|
||||
} else {
|
||||
selectedColumns = selectedColumns.filter((c) => c !== key);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{column.label}
|
||||
</DropdownMenu.CheckboxItem>
|
||||
{/each}
|
||||
</DropdownMenu.Group>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
128
frontend/src/lib/components/table/advanced-table-filter.svelte
Normal file
128
frontend/src/lib/components/table/advanced-table-filter.svelte
Normal file
@@ -0,0 +1,128 @@
|
||||
<script lang="ts" generics="TData, TValue">
|
||||
import { Badge } from '$lib/components/ui/badge/index.js';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import * as Command from '$lib/components/ui/command/index.js';
|
||||
import * as Popover from '$lib/components/ui/popover/index.js';
|
||||
import { Separator } from '$lib/components/ui/separator/index.js';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import { cn } from '$lib/utils/style';
|
||||
import CheckIcon from '@lucide/svelte/icons/check';
|
||||
import ListFilterIcon from '@lucide/svelte/icons/list-filter';
|
||||
import type { Component } from 'svelte';
|
||||
|
||||
let {
|
||||
title,
|
||||
options,
|
||||
selectedValues = new Set<string | boolean>(),
|
||||
showCheckboxes = true,
|
||||
onChanged = (selected: Set<string | boolean>) => {}
|
||||
}: {
|
||||
title: string;
|
||||
options: {
|
||||
label: string;
|
||||
value: string | boolean;
|
||||
icon?: Component;
|
||||
}[];
|
||||
selectedValues?: Set<string | boolean>;
|
||||
showCheckboxes?: boolean;
|
||||
onChanged?: (selected: Set<string | boolean>) => void;
|
||||
} = $props();
|
||||
|
||||
let open = $state(false);
|
||||
</script>
|
||||
|
||||
<Popover.Root bind:open>
|
||||
<Popover.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button
|
||||
{...props}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-8 border-dashed"
|
||||
data-testid={`facet-${title.toLowerCase()}-trigger`}
|
||||
>
|
||||
<ListFilterIcon />
|
||||
{title}
|
||||
{#if selectedValues.size > 0}
|
||||
<Separator orientation="vertical" class="mx-2 h-4" />
|
||||
<Badge variant="secondary" class="rounded-sm px-1 font-normal lg:hidden">
|
||||
{selectedValues.size}
|
||||
</Badge>
|
||||
<div class="hidden space-x-1 lg:flex">
|
||||
{#if selectedValues.size > 2}
|
||||
<Badge variant="secondary" class="rounded-sm px-1 font-normal">
|
||||
Count: {selectedValues.size}
|
||||
</Badge>
|
||||
{:else}
|
||||
{#each options.filter((opt) => selectedValues.has(opt.value)) as option (option)}
|
||||
<Badge variant="secondary" class="rounded-sm px-1 font-normal">
|
||||
{option.label}
|
||||
</Badge>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Popover.Trigger>
|
||||
<Popover.Content
|
||||
class="w-[200px] p-0"
|
||||
align="start"
|
||||
data-testid={`facet-${title.toLowerCase()}-content`}
|
||||
>
|
||||
<Command.Root>
|
||||
<Command.List>
|
||||
<Command.Empty>{m.no_items_found()}</Command.Empty>
|
||||
<Command.Group>
|
||||
{#each options as option (option)}
|
||||
{@const isSelected = selectedValues.has(option.value)}
|
||||
<Command.Item
|
||||
data-testid={`facet-${title.toLowerCase()}-option-${String(option.value)}`}
|
||||
onSelect={() => {
|
||||
if (isSelected) {
|
||||
selectedValues = new Set([...selectedValues].filter((v) => v !== option.value));
|
||||
} else {
|
||||
selectedValues = new Set([...selectedValues, option.value]);
|
||||
}
|
||||
onChanged(selectedValues);
|
||||
}}
|
||||
>
|
||||
{#if showCheckboxes}
|
||||
<div
|
||||
class={cn(
|
||||
'border-primary mr-2 flex size-4 items-center justify-center rounded-sm border',
|
||||
isSelected
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'opacity-50 [&_svg]:invisible'
|
||||
)}
|
||||
>
|
||||
<CheckIcon class="size-4" />
|
||||
</div>
|
||||
{/if}
|
||||
{#if option.icon}
|
||||
{@const Icon = option.icon}
|
||||
<Icon class="text-muted-foreground" />
|
||||
{/if}
|
||||
|
||||
<span>{option.label}</span>
|
||||
</Command.Item>
|
||||
{/each}
|
||||
</Command.Group>
|
||||
{#if selectedValues.size > 0}
|
||||
<Command.Separator />
|
||||
<Command.Group>
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
selectedValues = new Set();
|
||||
onChanged(selectedValues);
|
||||
open = false;
|
||||
}}
|
||||
>
|
||||
{m.clear_filters()}
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
{/if}
|
||||
</Command.List>
|
||||
</Command.Root>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
@@ -0,0 +1,67 @@
|
||||
<script lang="ts" generics="TData extends Record<string, any>">
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import type { AdvancedTableColumn } from '$lib/types/advanced-table.type';
|
||||
import type { ListRequestOptions } from '$lib/types/list-request.type';
|
||||
import { debounced } from '$lib/utils/debounce-util';
|
||||
import AdvancedTableColumnSelection from './advanced-table-column-selection.svelte';
|
||||
import AdvancedTableFilter from './advanced-table-filter.svelte';
|
||||
|
||||
let {
|
||||
columns,
|
||||
visibleColumns = $bindable(),
|
||||
requestOptions,
|
||||
searchValue = $bindable(),
|
||||
withoutSearch = false,
|
||||
onFilterChange,
|
||||
refresh
|
||||
}: {
|
||||
columns: AdvancedTableColumn<TData>[];
|
||||
visibleColumns: string[];
|
||||
requestOptions: ListRequestOptions;
|
||||
searchValue?: string;
|
||||
withoutSearch?: boolean;
|
||||
onFilterChange?: (selected: Set<string | boolean>, column: string) => void;
|
||||
refresh: () => Promise<void>;
|
||||
} = $props();
|
||||
|
||||
let filterableColumns = $derived(
|
||||
columns
|
||||
.filter((c) => c.filterableValues)
|
||||
.map((c) => ({
|
||||
name: c.label!,
|
||||
column: c.column!,
|
||||
options: c.filterableValues!
|
||||
}))
|
||||
);
|
||||
|
||||
const onSearch = debounced(async (search: string) => {
|
||||
requestOptions.search = search;
|
||||
await refresh();
|
||||
searchValue = search;
|
||||
}, 300);
|
||||
</script>
|
||||
|
||||
<div class="mb-4 flex flex-wrap items-end justify-between gap-2">
|
||||
<div class="flex flex-1 items-center gap-2 has-[>:nth-child(3)]:flex-wrap">
|
||||
{#if !withoutSearch}
|
||||
<Input
|
||||
value={searchValue}
|
||||
class="relative z-50 w-full sm:max-w-xs"
|
||||
placeholder={m.search()}
|
||||
type="text"
|
||||
oninput={(e: Event) => onSearch((e.currentTarget as HTMLInputElement).value)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#each filterableColumns as col}
|
||||
<AdvancedTableFilter
|
||||
title={col.name}
|
||||
options={col.options}
|
||||
selectedValues={new Set(requestOptions.filters?.[col.column] || [])}
|
||||
onChanged={(selected) => onFilterChange?.(selected, col.column)}
|
||||
/>
|
||||
{/each}
|
||||
<AdvancedTableColumnSelection {columns} bind:selectedColumns={visibleColumns} />
|
||||
</div>
|
||||
</div>
|
||||
356
frontend/src/lib/components/table/advanced-table.svelte
Normal file
356
frontend/src/lib/components/table/advanced-table.svelte
Normal file
@@ -0,0 +1,356 @@
|
||||
<script lang="ts" generics="T extends {id:string}">
|
||||
import Checkbox from '$lib/components/ui/checkbox/checkbox.svelte';
|
||||
import * as Pagination from '$lib/components/ui/pagination';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import * as Table from '$lib/components/ui/table/index.js';
|
||||
import Empty from '$lib/icons/empty.svelte';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import type {
|
||||
AdvancedTableColumn,
|
||||
CreateAdvancedTableActions
|
||||
} from '$lib/types/advanced-table.type';
|
||||
import type { ListRequestOptions, Paginated, SortRequest } from '$lib/types/list-request.type';
|
||||
import { cn } from '$lib/utils/style';
|
||||
import { ChevronDown, LucideEllipsis } from '@lucide/svelte';
|
||||
import { PersistedState } from 'runed';
|
||||
import { onMount } from 'svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
import Button, { buttonVariants } from '../ui/button/button.svelte';
|
||||
import * as DropdownMenu from '../ui/dropdown-menu/index.js';
|
||||
import { Skeleton } from '../ui/skeleton';
|
||||
import AdvancedTableToolbar from './advanced-table-toolbar.svelte';
|
||||
|
||||
let {
|
||||
id,
|
||||
selectedIds = $bindable(),
|
||||
withoutSearch = false,
|
||||
selectionDisabled = false,
|
||||
fetchCallback,
|
||||
defaultSort,
|
||||
columns,
|
||||
actions
|
||||
}: {
|
||||
id: string;
|
||||
selectedIds?: string[];
|
||||
withoutSearch?: boolean;
|
||||
selectionDisabled?: boolean;
|
||||
fetchCallback: (requestOptions: ListRequestOptions) => Promise<Paginated<T>>;
|
||||
defaultSort?: SortRequest;
|
||||
columns: AdvancedTableColumn<T>[];
|
||||
actions?: CreateAdvancedTableActions<T>;
|
||||
} = $props();
|
||||
|
||||
let items: Paginated<T> | undefined = $state();
|
||||
let searchValue = $state('');
|
||||
|
||||
const availablePageSizes: number[] = [20, 50, 100];
|
||||
|
||||
type TablePreferences = {
|
||||
visibleColumns: string[];
|
||||
paginationLimit: number;
|
||||
sort?: SortRequest;
|
||||
filters?: Record<string, (string | boolean)[]>;
|
||||
length?: number;
|
||||
};
|
||||
|
||||
const tablePreferences = new PersistedState<TablePreferences>(`table-${id}-preferences`, {
|
||||
visibleColumns: columns.filter((c) => !c.hidden).map((c) => c.column ?? c.key!),
|
||||
paginationLimit: 20,
|
||||
filters: initializeFilters()
|
||||
});
|
||||
|
||||
const requestOptions = $state<ListRequestOptions>({
|
||||
sort: tablePreferences.current.sort ?? defaultSort,
|
||||
pagination: { limit: tablePreferences.current.paginationLimit, page: 1 },
|
||||
filters: tablePreferences.current.filters
|
||||
});
|
||||
|
||||
let visibleColumns = $derived(
|
||||
columns.filter(
|
||||
(c) => tablePreferences.current.visibleColumns?.includes(c.column ?? c.key!) ?? []
|
||||
)
|
||||
);
|
||||
|
||||
onMount(async () => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const page = parseInt(urlParams.get(`${id}-page`) ?? '') || undefined;
|
||||
if (page) {
|
||||
requestOptions.pagination!.page = page;
|
||||
}
|
||||
await refresh();
|
||||
});
|
||||
|
||||
let allChecked = $derived.by(() => {
|
||||
if (!selectedIds || !items || items.data.length === 0) return false;
|
||||
for (const item of items!.data) {
|
||||
if (!selectedIds.includes(item.id)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
async function onAllCheck(checked: boolean) {
|
||||
const pageIds = items!.data.map((item) => item.id);
|
||||
const current = selectedIds ?? [];
|
||||
|
||||
if (checked) {
|
||||
selectedIds = Array.from(new Set([...current, ...pageIds]));
|
||||
} else {
|
||||
selectedIds = current.filter((id) => !pageIds.includes(id));
|
||||
}
|
||||
}
|
||||
|
||||
async function onCheck(checked: boolean, id: string) {
|
||||
const current = selectedIds ?? [];
|
||||
if (checked) {
|
||||
selectedIds = Array.from(new Set([...current, id]));
|
||||
} else {
|
||||
selectedIds = current.filter((selectedId) => selectedId !== id);
|
||||
}
|
||||
}
|
||||
|
||||
async function onPageChange(page: number) {
|
||||
changePageState(page);
|
||||
await refresh();
|
||||
}
|
||||
|
||||
async function onPageSizeChange(size: number) {
|
||||
requestOptions.pagination = { limit: size, page: 1 };
|
||||
tablePreferences.current.paginationLimit = size;
|
||||
await refresh();
|
||||
}
|
||||
|
||||
async function onFilterChange(selected: Set<string | boolean>, column: string) {
|
||||
requestOptions.filters = {
|
||||
...requestOptions.filters,
|
||||
[column]: Array.from(selected)
|
||||
};
|
||||
tablePreferences.current.filters = requestOptions.filters;
|
||||
await refresh();
|
||||
}
|
||||
|
||||
async function onSort(column?: string) {
|
||||
if (!column) return;
|
||||
|
||||
const isSameColumn = requestOptions.sort?.column === column;
|
||||
const nextDirection: 'asc' | 'desc' =
|
||||
isSameColumn && requestOptions.sort?.direction === 'asc' ? 'desc' : 'asc';
|
||||
|
||||
requestOptions.sort = { column, direction: nextDirection };
|
||||
tablePreferences.current.sort = requestOptions.sort;
|
||||
await refresh();
|
||||
}
|
||||
|
||||
function changePageState(page: number) {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set(`${id}-page`, page.toString());
|
||||
history.replaceState(history.state, '', url.toString());
|
||||
requestOptions.pagination!.page = page;
|
||||
}
|
||||
|
||||
function updateListLength(totalItems: number) {
|
||||
tablePreferences.current.length =
|
||||
totalItems > tablePreferences.current.paginationLimit
|
||||
? tablePreferences.current.paginationLimit
|
||||
: totalItems;
|
||||
}
|
||||
|
||||
function initializeFilters() {
|
||||
const filters: Record<string, (string | boolean)[]> = {};
|
||||
columns.forEach((c) => {
|
||||
if (c.filterableValues) {
|
||||
filters[c.column!] = [];
|
||||
}
|
||||
});
|
||||
return filters;
|
||||
}
|
||||
|
||||
export async function refresh() {
|
||||
items = await fetchCallback(requestOptions);
|
||||
changePageState(items.pagination.currentPage);
|
||||
updateListLength(items.pagination.totalItems);
|
||||
}
|
||||
</script>
|
||||
|
||||
<AdvancedTableToolbar
|
||||
{columns}
|
||||
bind:visibleColumns={tablePreferences.current.visibleColumns}
|
||||
{requestOptions}
|
||||
{searchValue}
|
||||
{withoutSearch}
|
||||
{refresh}
|
||||
{onFilterChange}
|
||||
/>
|
||||
|
||||
{#if (items?.pagination.totalItems === 0 && searchValue === '') || tablePreferences.current.length === 0}
|
||||
<div class="my-5 flex flex-col items-center">
|
||||
<Empty class="text-muted-foreground h-20" />
|
||||
<p class="text-muted-foreground mt-3 text-sm">{m.no_items_found()}</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#if !items}
|
||||
<div>
|
||||
{#each Array((tablePreferences.current.length || 10) + 1) as _}
|
||||
<div>
|
||||
<Skeleton class="mt-3 h-[45px] w-full rounded-lg" />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div in:fade>
|
||||
<Table.Root class="min-w-full table-auto overflow-x-auto">
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
{#if selectedIds}
|
||||
<Table.Head class="w-12">
|
||||
<Checkbox
|
||||
disabled={selectionDisabled}
|
||||
checked={allChecked}
|
||||
onCheckedChange={(c: boolean) => onAllCheck(c as boolean)}
|
||||
/>
|
||||
</Table.Head>
|
||||
{/if}
|
||||
|
||||
{#each visibleColumns as column}
|
||||
<Table.Head class={cn(column.sortable && 'p-0')}>
|
||||
{#if column.sortable}
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="h-12 w-full justify-start px-4 font-medium hover:bg-transparent"
|
||||
onclick={() => onSort(column.column)}
|
||||
>
|
||||
<span class="flex items-center">
|
||||
{column.label}
|
||||
<ChevronDown
|
||||
class={cn(
|
||||
'ml-2 size-4 transition-all',
|
||||
requestOptions.sort?.column === column.column
|
||||
? requestOptions.sort?.direction === 'asc'
|
||||
? 'rotate-180 opacity-100'
|
||||
: 'opacity-100'
|
||||
: 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
</Button>
|
||||
{:else}
|
||||
{column.label}
|
||||
{/if}
|
||||
</Table.Head>
|
||||
{/each}
|
||||
{#if actions}
|
||||
<Table.Head align="right" class="w-12">
|
||||
<span class="sr-only">{m.actions()}</span>
|
||||
</Table.Head>
|
||||
{/if}
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each items.data as item}
|
||||
<Table.Row class={selectedIds?.includes(item.id) ? 'bg-muted/20' : ''}>
|
||||
{#if selectedIds}
|
||||
<Table.Cell class="w-12">
|
||||
<Checkbox
|
||||
disabled={selectionDisabled}
|
||||
checked={selectedIds.includes(item.id)}
|
||||
onCheckedChange={(c: boolean) => onCheck(c, item.id)}
|
||||
/>
|
||||
</Table.Cell>
|
||||
{/if}
|
||||
{#each visibleColumns as column}
|
||||
<Table.Cell>
|
||||
{#if column.value}
|
||||
{column.value(item)}
|
||||
{:else if column.cell}
|
||||
{@render column.cell({ item })}
|
||||
{:else if column.column && typeof item[column.column] === 'boolean'}
|
||||
{item[column.column] ? m.enabled() : m.disabled()}
|
||||
{:else if column.column}
|
||||
{item[column.column]}
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
{/each}
|
||||
{#if actions}
|
||||
<Table.Cell align="right" class="w-12 py-0">
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
class={buttonVariants({ variant: 'ghost', size: 'icon' })}
|
||||
>
|
||||
<LucideEllipsis class="size-4" />
|
||||
<span class="sr-only">{m.toggle_menu()}</span>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
{#each actions(item).filter((a) => !a.hidden) as action}
|
||||
<DropdownMenu.Item
|
||||
onclick={() => action.onClick(item)}
|
||||
disabled={action.disabled}
|
||||
class={action.variant === 'danger'
|
||||
? 'text-red-500 focus:!text-red-700'
|
||||
: ''}
|
||||
>
|
||||
{#if action.icon}
|
||||
{@const Icon = action.icon}
|
||||
<Icon class="mr-2 size-4" />
|
||||
{/if}
|
||||
{action.label}
|
||||
</DropdownMenu.Item>
|
||||
{/each}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</Table.Cell>
|
||||
{/if}
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-5 flex flex-col-reverse items-center justify-between gap-3 sm:flex-row">
|
||||
<div class="flex items-center space-x-2">
|
||||
<p class="text-sm font-medium">{m.items_per_page()}</p>
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={items?.pagination.itemsPerPage.toString()}
|
||||
onValueChange={(v) => onPageSizeChange(Number(v))}
|
||||
>
|
||||
<Select.Trigger class="h-9 w-[80px]">
|
||||
{items?.pagination.itemsPerPage}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each availablePageSizes as size}
|
||||
<Select.Item value={size.toString()}>{size}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
<Pagination.Root
|
||||
class="mx-0 w-auto"
|
||||
count={items?.pagination.totalItems || 0}
|
||||
perPage={items?.pagination.itemsPerPage}
|
||||
{onPageChange}
|
||||
page={items?.pagination.currentPage}
|
||||
>
|
||||
{#snippet children({ pages })}
|
||||
<Pagination.Content class="flex justify-end">
|
||||
<Pagination.Item>
|
||||
<Pagination.PrevButton />
|
||||
</Pagination.Item>
|
||||
{#each pages as page (page.key)}
|
||||
{#if page.type !== 'ellipsis' && page.value != 0}
|
||||
<Pagination.Item>
|
||||
<Pagination.Link {page} isActive={items?.pagination.currentPage === page.value}>
|
||||
{page.value}
|
||||
</Pagination.Link>
|
||||
</Pagination.Item>
|
||||
{/if}
|
||||
{/each}
|
||||
<Pagination.Item>
|
||||
<Pagination.NextButton />
|
||||
</Pagination.Item>
|
||||
</Pagination.Content>
|
||||
{/snippet}
|
||||
</Pagination.Root>
|
||||
</div>
|
||||
{/if}
|
||||
7
frontend/src/lib/components/ui/skeleton/index.ts
Normal file
7
frontend/src/lib/components/ui/skeleton/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import Root from "./skeleton.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Skeleton,
|
||||
};
|
||||
17
frontend/src/lib/components/ui/skeleton/skeleton.svelte
Normal file
17
frontend/src/lib/components/ui/skeleton/skeleton.svelte
Normal file
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef, type WithoutChildren } from "$lib/utils/style.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: WithoutChildren<WithElementRef<HTMLAttributes<HTMLDivElement>>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="skeleton"
|
||||
class={cn("bg-accent animate-pulse rounded-md", className)}
|
||||
{...restProps}
|
||||
></div>
|
||||
@@ -14,7 +14,7 @@
|
||||
bind:this={ref}
|
||||
data-slot="table-cell"
|
||||
class={cn(
|
||||
'p-4 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
|
||||
'py-3 px-4 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
<script lang="ts">
|
||||
import AdvancedTable from '$lib/components/advanced-table.svelte';
|
||||
import * as Table from '$lib/components/ui/table';
|
||||
import AdvancedTable from '$lib/components/table/advanced-table.svelte';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import UserGroupService from '$lib/services/user-group-service';
|
||||
import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
|
||||
import type { UserGroup } from '$lib/types/user-group.type';
|
||||
import { onMount } from 'svelte';
|
||||
import type { AdvancedTableColumn } from '$lib/types/advanced-table.type';
|
||||
import type { UserGroupWithUserCount } from '$lib/types/user-group.type';
|
||||
|
||||
let {
|
||||
selectionDisabled = false,
|
||||
@@ -17,30 +15,27 @@
|
||||
|
||||
const userGroupService = new UserGroupService();
|
||||
|
||||
let groups: Paginated<UserGroup> | undefined = $state();
|
||||
let requestOptions: SearchPaginationSortRequest = $state({
|
||||
sort: {
|
||||
column: 'friendlyName',
|
||||
direction: 'asc'
|
||||
}
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
groups = await userGroupService.list(requestOptions);
|
||||
});
|
||||
const columns: AdvancedTableColumn<UserGroupWithUserCount>[] = [
|
||||
{ label: 'ID', column: 'id', hidden: true },
|
||||
{ label: m.friendly_name(), column: 'friendlyName', sortable: true },
|
||||
{ label: m.name(), column: 'name', sortable: true },
|
||||
{ label: m.user_count(), column: 'userCount', sortable: true },
|
||||
{
|
||||
label: m.created(),
|
||||
column: 'createdAt',
|
||||
sortable: true,
|
||||
hidden: true,
|
||||
value: (item) => new Date(item.createdAt).toLocaleString()
|
||||
},
|
||||
{ label: m.ldap_id(), column: 'ldapId', hidden: true }
|
||||
];
|
||||
</script>
|
||||
|
||||
{#if groups}
|
||||
<AdvancedTable
|
||||
items={groups}
|
||||
{requestOptions}
|
||||
onRefresh={async (o) => (groups = await userGroupService.list(o))}
|
||||
columns={[{ label: m.name(), sortColumn: 'friendlyName' }]}
|
||||
bind:selectedIds={selectedGroupIds}
|
||||
{selectionDisabled}
|
||||
>
|
||||
{#snippet rows({ item })}
|
||||
<Table.Cell>{item.friendlyName}</Table.Cell>
|
||||
{/snippet}
|
||||
</AdvancedTable>
|
||||
{/if}
|
||||
<AdvancedTable
|
||||
id="user-group-selection"
|
||||
fetchCallback={userGroupService.list}
|
||||
defaultSort={{ column: 'friendlyName', direction: 'asc' }}
|
||||
bind:selectedIds={selectedGroupIds}
|
||||
{selectionDisabled}
|
||||
{columns}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user