diff --git a/ui-svelte/package-lock.json b/ui-svelte/package-lock.json index c540b58a..848d38fa 100644 --- a/ui-svelte/package-lock.json +++ b/ui-svelte/package-lock.json @@ -8,6 +8,7 @@ "name": "ui-svelte", "version": "0.0.0", "dependencies": { + "@tanstack/table-core": "^8.21.3", "chart.js": "4.5.1", "highlight.js": "^11.11.1", "katex": "^0.16.28", @@ -817,6 +818,19 @@ "vite": "^5.2.0 || ^6 || ^7" } }, + "node_modules/@tanstack/table-core": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", + "integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tsconfig/svelte": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/@tsconfig/svelte/-/svelte-5.0.8.tgz", diff --git a/ui-svelte/package.json b/ui-svelte/package.json index 787dcc9d..4bc7df04 100644 --- a/ui-svelte/package.json +++ b/ui-svelte/package.json @@ -34,6 +34,7 @@ "vitest": "^4.1.0" }, "dependencies": { + "@tanstack/table-core": "^8.21.3", "chart.js": "4.5.1", "highlight.js": "^11.11.1", "katex": "^0.16.28", diff --git a/ui-svelte/src/App.svelte b/ui-svelte/src/App.svelte index 88699541..7f4a15c5 100644 --- a/ui-svelte/src/App.svelte +++ b/ui-svelte/src/App.svelte @@ -10,6 +10,7 @@ import Playground from "./routes/Playground.svelte"; import PlaygroundStub from "./routes/PlaygroundStub.svelte"; import * as Sidebar from "$lib/components/ui/sidebar/index.js"; + import * as Tooltip from "$lib/components/ui/tooltip/index.js"; import { Separator } from "$lib/components/ui/separator/index.js"; import { enableAPIEvents, checkPerformanceEnabled } from "./stores/api"; import { initScreenWidth, initSystemThemeListener, isDarkMode, appTitle, connectionState } from "./stores/theme"; @@ -80,24 +81,26 @@ }); - - - -
- - -

{sectionTitle}

-
+ + + + +
+ + +

{sectionTitle}

+
-
-
- -
-
- -
-
-
-
+
+
+ +
+
+ +
+
+
+
+ diff --git a/ui-svelte/src/components/ActivityTable.svelte b/ui-svelte/src/components/ActivityTable.svelte index 8e98a2e7..2ecd9863 100644 --- a/ui-svelte/src/components/ActivityTable.svelte +++ b/ui-svelte/src/components/ActivityTable.svelte @@ -2,22 +2,34 @@ import type { ActivityLogEntry, ReqRespCapture } from "../lib/types"; import { getCapture } from "../stores/api"; import { persistentStore } from "../stores/persistent"; - import { onMount } from "svelte"; - import Tooltip from "./Tooltip.svelte"; - import MetadataTooltip from "./MetadataTooltip.svelte"; import CaptureDialog from "./CaptureDialog.svelte"; - import { Columns3, GripVertical } from "@lucide/svelte"; + import { + type ColumnDef, + type PaginationState, + type VisibilityState, + getCoreRowModel, + getPaginationRowModel, + } from "@tanstack/table-core"; + import { + FlexRender, + createSvelteTable, + renderComponent, + } from "$lib/components/ui/data-table/index.js"; + import * as Table from "$lib/components/ui/table/index.js"; import * as Card from "$lib/components/ui/card/index.js"; import * as Select from "$lib/components/ui/select/index.js"; + import * as DropdownMenu from "$lib/components/ui/dropdown-menu/index.js"; import { Button } from "$lib/components/ui/button/index.js"; - - type ColumnKey = string; - - interface ColumnDef { - key: ColumnKey; - label: string; - defaultVisible: boolean; - } + import { + Columns3, + ChevronLeft, + ChevronRight, + ChevronsLeft, + ChevronsRight, + } from "@lucide/svelte"; + import HeaderLabel from "./activity-table/HeaderLabel.svelte"; + import ViewCaptureButton from "./activity-table/ViewCaptureButton.svelte"; + import MetaCell from "./activity-table/MetaCell.svelte"; interface Props { metrics: ActivityLogEntry[]; @@ -41,150 +53,6 @@ cardClass = "", }: Props = $props(); - function buildColumns(withModel: boolean): ColumnDef[] { - const cols: ColumnDef[] = [ - { key: "id", label: "ID", defaultVisible: true }, - { key: "time", label: "Time", defaultVisible: true }, - ]; - if (withModel) cols.push({ key: "model", label: "Model", defaultVisible: true }); - cols.push( - { key: "req_path", label: "Path", defaultVisible: false }, - { key: "resp_status_code", label: "Status", defaultVisible: true }, - { key: "resp_content_type", label: "Content-Type", defaultVisible: false }, - { key: "cached", label: "Cached", defaultVisible: true }, - { key: "prompt", label: "Prompt", defaultVisible: true }, - { key: "generated", label: "Generated", defaultVisible: true }, - { key: "drafted", label: "Drafted", defaultVisible: false }, - { key: "prompt_speed", label: "Prompt Speed", defaultVisible: true }, - { key: "gen_speed", label: "Gen Speed", defaultVisible: true }, - { key: "duration", label: "Duration", defaultVisible: true }, - { key: "capture", label: "Capture", defaultVisible: true }, - { key: "meta", label: "Meta", defaultVisible: false } - ); - return cols; - } - - // svelte-ignore state_referenced_locally - const columns: ColumnDef[] = buildColumns(showModelColumn); - const defaultVisibleKeys = columns.filter((c) => c.defaultVisible).map((c) => c.key); - - // svelte-ignore state_referenced_locally - const visibleColumns = persistentStore(`${storagePrefix}-columns`, defaultVisibleKeys); - // svelte-ignore state_referenced_locally - const columnOrder = persistentStore( - `${storagePrefix}-column-order`, - columns.map((c) => c.key) - ); - // svelte-ignore state_referenced_locally - const pageSizeStore = persistentStore(`${storagePrefix}-page-size`, 10); - - let page = $state(0); - let totalPages = $derived(Math.max(1, Math.ceil(metrics.length / $pageSizeStore))); - let pageMetrics = $derived(metrics.slice(page * $pageSizeStore, (page + 1) * $pageSizeStore)); - let displayMetrics = $derived(showPagination ? pageMetrics : metrics); - - // Reset page when data source or page size changes - $effect(() => { - metrics; - $pageSizeStore; - page = 0; - }); - - let columnsMenuOpen = $state(false); - let dropdownContainer: HTMLDivElement | null = $state(null); - let dragKey: ColumnKey | null = $state(null); - let dragOverKey: ColumnKey | null = $state(null); - - onMount(() => { - function handleKeydown(e: KeyboardEvent) { - if (e.key === "Escape" && columnsMenuOpen) columnsMenuOpen = false; - } - function handleClick(e: MouseEvent) { - if (columnsMenuOpen && dropdownContainer && !dropdownContainer.contains(e.target as Node)) { - columnsMenuOpen = false; - } - } - document.addEventListener("keydown", handleKeydown); - document.addEventListener("click", handleClick); - return () => { - document.removeEventListener("keydown", handleKeydown); - document.removeEventListener("click", handleClick); - }; - }); - - function toggleColumn(key: ColumnKey) { - const current = $visibleColumns; - if (current.includes(key)) { - if (current.length > 1) visibleColumns.set(current.filter((k) => k !== key)); - } else { - visibleColumns.set([...current, key]); - } - } - - function isColumnVisible(key: ColumnKey): boolean { - return $visibleColumns.includes(key); - } - - function handleDragStart(e: DragEvent, key: ColumnKey) { - dragKey = key; - e.dataTransfer?.setData("text/plain", key); - if (e.dataTransfer) e.dataTransfer.effectAllowed = "move"; - } - - function handleDragOver(e: DragEvent, key: ColumnKey) { - e.preventDefault(); - if (e.dataTransfer) e.dataTransfer.dropEffect = "move"; - dragOverKey = key; - } - - function handleDrop(e: DragEvent, targetKey: ColumnKey) { - e.preventDefault(); - if (!dragKey || dragKey === targetKey) return; - const order = [...$columnOrder]; - const fromIndex = order.indexOf(dragKey); - let toIndex = order.indexOf(targetKey); - if (fromIndex === -1 || toIndex === -1) return; - order.splice(fromIndex, 1); - if (fromIndex < toIndex) toIndex -= 1; - order.splice(toIndex, 0, dragKey); - columnOrder.set(order); - } - - function handleDragEnd() { - dragKey = null; - dragOverKey = null; - } - - function orderByColumnOrder(cols: T[]): T[] { - return cols.slice().sort((a, b) => { - const aIndex = $columnOrder.indexOf(a.key); - const bIndex = $columnOrder.indexOf(b.key); - if (aIndex === -1 && bIndex === -1) return 0; - if (aIndex === -1) return 1; - if (bIndex === -1) return -1; - return aIndex - bIndex; - }); - } - - let orderedColumns = $derived(orderByColumnOrder(columns)); - - let activeVisibleColumns = $derived( - orderByColumnOrder(columns.filter((c) => isColumnVisible(c.key))).map((c) => c.key) - ); - - let columnLabelMap = $derived(Object.fromEntries(columns.map((c) => [c.key, c.label]))); - - $effect(() => { - const staticKeys = new Set(columns.map((c) => c.key)); - const order = $columnOrder; - const hasStale = order.some((k) => !staticKeys.has(k)); - const missing = columns.filter((c) => !order.includes(c.key)).map((c) => c.key); - if (hasStale || missing.length > 0) { - const cleaned = order.filter((k) => staticKeys.has(k)); - columnOrder.set([...cleaned, ...missing]); - } - }); - function formatSpeed(speed: number): string { return speed < 0 ? "unknown" : speed.toFixed(2) + " t/s"; } @@ -212,6 +80,80 @@ : "-"; } + interface ColMeta { + id: string; + label: string; + defaultVisible: boolean; + } + + function buildColumnMeta(withModel: boolean): ColMeta[] { + const cols: ColMeta[] = [ + { id: "id", label: "ID", defaultVisible: true }, + { id: "time", label: "Time", defaultVisible: true }, + ]; + if (withModel) cols.push({ id: "model", label: "Model", defaultVisible: true }); + cols.push( + { id: "req_path", label: "Path", defaultVisible: false }, + { id: "resp_status_code", label: "Status", defaultVisible: true }, + { id: "resp_content_type", label: "Content-Type", defaultVisible: false }, + { id: "cached", label: "Cached", defaultVisible: true }, + { id: "prompt", label: "Prompt", defaultVisible: true }, + { id: "generated", label: "Generated", defaultVisible: true }, + { id: "drafted", label: "Drafted", defaultVisible: false }, + { id: "prompt_speed", label: "Prompt Speed", defaultVisible: true }, + { id: "gen_speed", label: "Gen Speed", defaultVisible: true }, + { id: "duration", label: "Duration", defaultVisible: true }, + { id: "capture", label: "Capture", defaultVisible: true }, + { id: "meta", label: "Meta", defaultVisible: false } + ); + return cols; + } + + let columnMeta = $derived(buildColumnMeta(showModelColumn)); + + let columnLabelMap = $derived( + Object.fromEntries(columnMeta.map((c) => [c.id, c.label])) as Record + ); + + let defaultVisibility = $derived.by(() => { + const v: VisibilityState = {}; + for (const c of columnMeta) v[c.id] = c.defaultVisible; + return v; + }); + + // svelte-ignore state_referenced_locally + const storedVisibility = persistentStore( + `${storagePrefix}-columns`, + {} + ); + + // svelte-ignore state_referenced_locally + let columnVisibility = $state( + Object.keys($storedVisibility).length > 0 ? $storedVisibility : defaultVisibility + ); + + // svelte-ignore state_referenced_locally + const storedPageSize = persistentStore(`${storagePrefix}-page-size`, 10); + + // svelte-ignore state_referenced_locally + let pagination = $state({ + pageIndex: 0, + pageSize: showPagination ? $storedPageSize : 1, + }); + + // When not paginating, show every row; reset page on data/pageSize change. + $effect(() => { + if (!showPagination) { + pagination.pageSize = metrics.length || 1; + } + }); + + $effect(() => { + metrics; + pagination.pageSize; + pagination.pageIndex = 0; + }); + let selectedCapture = $state(null); let dialogOpen = $state(false); let loadingCaptureId = $state(null); @@ -229,13 +171,136 @@ selectedCapture = null; } - let thClass = $derived(compact ? "px-4 py-2 font-medium" : "px-6 py-3 font-medium"); + function buildColumns(withModel: boolean): ColumnDef[] { + const cols: ColumnDef[] = [ + { + id: "id", + header: "ID", + cell: ({ row }) => String(row.original.id + 1), + }, + { + id: "time", + header: "Time", + cell: ({ row }) => formatRelativeTime(row.original.timestamp), + }, + ]; + + if (withModel) { + cols.push({ + id: "model", + header: "Model", + cell: ({ row }) => row.original.model ?? "-", + }); + } + + cols.push( + { + id: "req_path", + header: "Path", + cell: ({ row }) => row.original.req_path || "-", + }, + { + id: "resp_status_code", + header: "Status", + cell: ({ row }) => String(row.original.resp_status_code || "-"), + }, + { + id: "resp_content_type", + header: "Content-Type", + cell: ({ row }) => row.original.resp_content_type || "-", + }, + { + id: "cached", + header: () => renderComponent(HeaderLabel, { label: "Cached", tooltip: "prompt tokens from cache" }), + cell: ({ row }) => + row.original.tokens.cache_tokens > 0 + ? row.original.tokens.cache_tokens.toLocaleString() + : "-", + }, + { + id: "prompt", + header: () => renderComponent(HeaderLabel, { label: "Prompt", tooltip: "new prompt tokens processed" }), + cell: ({ row }) => row.original.tokens.input_tokens.toLocaleString(), + }, + { + id: "generated", + header: "Generated", + cell: ({ row }) => row.original.tokens.output_tokens.toLocaleString(), + }, + { + id: "drafted", + header: () => renderComponent(HeaderLabel, { label: "Drafted", tooltip: "acceptance rate (accepted/drafted)" }), + cell: ({ row }) => + formatDrafted(row.original.tokens.draft_tokens, row.original.tokens.draft_acc_tokens), + }, + { + id: "prompt_speed", + header: "Prompt Speed", + cell: ({ row }) => formatSpeed(row.original.tokens.prompt_per_second), + }, + { + id: "gen_speed", + header: "Gen Speed", + cell: ({ row }) => formatSpeed(row.original.tokens.tokens_per_second), + }, + { + id: "duration", + header: "Duration", + cell: ({ row }) => formatDuration(row.original.duration_ms), + }, + { + id: "capture", + header: "Capture", + cell: ({ row }) => + renderComponent(ViewCaptureButton, { + hasCapture: row.original.has_capture, + loading: loadingCaptureId === row.original.id, + onclick: () => viewCapture(row.original.id), + }), + }, + { + id: "meta", + header: "Meta", + cell: ({ row }) => + renderComponent(MetaCell, { metadata: row.original.metadata }), + } + ); + return cols; + } + + let columns = $derived(buildColumns(showModelColumn)); + + const table = createSvelteTable({ + get data() { + return metrics; + }, + get columns() { + return columns; + }, + state: { + get pagination() { + return pagination; + }, + get columnVisibility() { + return columnVisibility; + }, + }, + onPaginationChange: (updater) => { + pagination = + typeof updater === "function" ? updater(pagination) : updater; + if (showPagination) storedPageSize.set(pagination.pageSize); + }, + onColumnVisibilityChange: (updater) => { + columnVisibility = + typeof updater === "function" ? updater(columnVisibility) : updater; + storedVisibility.set(columnVisibility); + }, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + }); + + let thClass = $derived(compact ? "px-4 py-2 h-9" : "px-6 py-3 h-12"); let tdClass = $derived(compact ? "px-4 py-2" : "px-6 py-4"); - let rowClass = $derived( - compact - ? "hover:bg-muted/50 whitespace-nowrap border-b" - : "hover:bg-muted/50 whitespace-nowrap border-b text-sm transition-colors" - ); @@ -250,13 +315,15 @@
{#if showPagination} - Per page + Rows pageSizeStore.set(Number(v))} + value={String(pagination.pageSize)} + onValueChange={(v) => table.setPageSize(Number(v))} > - {$pageSizeStore} + + {pagination.pageSize} + {#each [5, 10, 25, 50] as size (size)} {size} @@ -264,193 +331,106 @@ {/if} -
-
- - {#if columnsMenuOpen} -
- - {#each orderedColumns as col (col.key)} - {@const key = col.key} -
handleDragOver(e, key)} - ondrop={(e) => handleDrop(e, key)} - > - handleDragStart(e, key)} - ondragend={handleDragEnd} - > - - - -
- {/each} -
- {/if} -
-
+ {columnLabelMap[column.id] ?? column.id} + + {/if} + {/each} + +
- - - - {#each activeVisibleColumns as key (key)} - - {/each} - - - - {#if displayMetrics.length === 0} - - - + + + {#each table.getHeaderGroups() as headerGroup (headerGroup.id)} + + {#each headerGroup.headers as header (header.id)} + + {#if !header.isPlaceholder} + + {/if} + + {/each} + + {/each} + + + {#each table.getRowModel().rows as row (row.id)} + + {#each row.getVisibleCells() as cell (cell.id)} + + + + {/each} + {:else} - {#each displayMetrics as metric (metric.id)} - - {#each activeVisibleColumns as key (key)} - - {/each} - - {/each} - {/if} - -
- {#if key === "cached"} - Cached - {:else if key === "prompt"} - Prompt - {:else if key === "drafted"} - Drafted - {:else} - {columnLabelMap[key] ?? key} - {/if} -
- {emptyMessage} -
- {#if key === "id"} - {metric.id + 1} - {:else if key === "time"} - {formatRelativeTime(metric.timestamp)} - {:else if key === "model"} - {metric.model} - {:else if key === "req_path"} - {metric.req_path || "-"} - {:else if key === "resp_status_code"} - {#if metric.error_msg} - - {metric.resp_status_code || "-"} - - {:else} - {metric.resp_status_code || "-"} - {/if} - {:else if key === "resp_content_type"} - {metric.resp_content_type || "-"} - {:else if key === "cached"} - {metric.tokens.cache_tokens > 0 ? metric.tokens.cache_tokens.toLocaleString() : "-"} - {:else if key === "prompt"} - {metric.tokens.input_tokens.toLocaleString()} - {:else if key === "generated"} - {metric.tokens.output_tokens.toLocaleString()} - {:else if key === "drafted"} - {formatDrafted(metric.tokens.draft_tokens, metric.tokens.draft_acc_tokens)} - {:else if key === "prompt_speed"} - {formatSpeed(metric.tokens.prompt_per_second)} - {:else if key === "gen_speed"} - {formatSpeed(metric.tokens.tokens_per_second)} - {:else if key === "duration"} - {formatDuration(metric.duration_ms)} - {:else if key === "capture"} - {#if metric.has_capture} - - {:else} - - - {/if} - {:else if key === "meta"} - {#if Object.keys(metric.metadata || {}).length > 0} - - ... - - {:else} - - - {/if} - {:else} - - - {/if} -
+ + + {emptyMessage} + + + {/each} + + {#if showPagination && metrics.length > 0}
- Page {page + 1} of {totalPages} · {metrics.length} total + Page {pagination.pageIndex + 1} of {table.getPageCount()} · {metrics.length} total -
-
diff --git a/ui-svelte/src/components/activity-table/HeaderLabel.svelte b/ui-svelte/src/components/activity-table/HeaderLabel.svelte new file mode 100644 index 00000000..6b66918f --- /dev/null +++ b/ui-svelte/src/components/activity-table/HeaderLabel.svelte @@ -0,0 +1,17 @@ + + +{label}{#if tooltip} + + + {tooltip} + +{/if} diff --git a/ui-svelte/src/components/activity-table/MetaCell.svelte b/ui-svelte/src/components/activity-table/MetaCell.svelte new file mode 100644 index 00000000..7ef414f7 --- /dev/null +++ b/ui-svelte/src/components/activity-table/MetaCell.svelte @@ -0,0 +1,33 @@ + + +{#if entries.length > 0} + + + ... + + + + + {#each entries as [key, value]} + + + + + {/each} + +
{key}{value}
+
+
+{:else} + - +{/if} diff --git a/ui-svelte/src/components/activity-table/ViewCaptureButton.svelte b/ui-svelte/src/components/activity-table/ViewCaptureButton.svelte new file mode 100644 index 00000000..9d9ba033 --- /dev/null +++ b/ui-svelte/src/components/activity-table/ViewCaptureButton.svelte @@ -0,0 +1,19 @@ + + +{#if hasCapture} + +{:else} + - +{/if} diff --git a/ui-svelte/src/lib/components/ui/data-table/data-table.svelte.ts b/ui-svelte/src/lib/components/ui/data-table/data-table.svelte.ts new file mode 100644 index 00000000..f3c30a7f --- /dev/null +++ b/ui-svelte/src/lib/components/ui/data-table/data-table.svelte.ts @@ -0,0 +1,142 @@ +import { + type RowData, + type TableOptions, + type TableOptionsResolved, + type TableState, + type Updater, + createTable, +} from "@tanstack/table-core"; + +/** + * Creates a reactive TanStack table object for Svelte. + * @param options Table options to create the table with. + * @returns A reactive table object. + * @example + * ```svelte + * + * + * + * + * {#each table.getHeaderGroups() as headerGroup} + * + * {#each headerGroup.headers as header} + * + * {/each} + * + * {/each} + * + * + *
+ * + *
+ * ``` + */ +export function createSvelteTable(options: TableOptions) { + const resolvedOptions: TableOptionsResolved = mergeObjects( + { + state: {}, + onStateChange() {}, + renderFallbackValue: null, + mergeOptions: ( + defaultOptions: TableOptions, + options: Partial> + ) => { + return mergeObjects(defaultOptions, options); + }, + }, + options + ); + + const table = createTable(resolvedOptions); + let state = $state(table.initialState); + + function updateOptions() { + table.setOptions(() => { + return mergeObjects(resolvedOptions, options, { + state: mergeObjects(state, options.state || {}), + + onStateChange: (updater: Updater) => { + if (updater instanceof Function) state = updater(state); + else state = mergeObjects(state, updater); + + options.onStateChange?.(updater); + }, + }); + }); + } + + updateOptions(); + + $effect.pre(() => { + updateOptions(); + }); + + return table; +} + +type MaybeThunk = T | (() => T | null | undefined); +type Intersection = (T extends [infer H, ...infer R] + ? H & Intersection + : unknown) & {}; + +/** + * Lazily merges several objects (or thunks) while preserving + * getter semantics from every source. + * + * Proxy-based to avoid known WebKit recursion issue. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function mergeObjects[]>( + ...sources: Sources +): Intersection<{ [K in keyof Sources]: Sources[K] }> { + const resolve = (src: MaybeThunk): T | undefined => + typeof src === "function" ? (src() ?? undefined) : src; + + const findSourceWithKey = (key: PropertyKey) => { + for (let i = sources.length - 1; i >= 0; i--) { + const obj = resolve(sources[i]); + if (obj && key in obj) return obj; + } + return undefined; + }; + + return new Proxy(Object.create(null), { + get(_, key) { + const src = findSourceWithKey(key); + + return src?.[key as never]; + }, + + has(_, key) { + return !!findSourceWithKey(key); + }, + + ownKeys(): (string | symbol)[] { + // eslint-disable-next-line svelte/prefer-svelte-reactivity + const all = new Set(); + for (const s of sources) { + const obj = resolve(s); + if (obj) { + for (const k of Reflect.ownKeys(obj) as (string | symbol)[]) { + all.add(k); + } + } + } + return [...all]; + }, + + getOwnPropertyDescriptor(_, key) { + const src = findSourceWithKey(key); + if (!src) return undefined; + return { + configurable: true, + enumerable: true, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + value: (src as any)[key], + writable: true, + }; + }, + }) as Intersection<{ [K in keyof Sources]: Sources[K] }>; +} diff --git a/ui-svelte/src/lib/components/ui/data-table/flex-render.svelte b/ui-svelte/src/lib/components/ui/data-table/flex-render.svelte new file mode 100644 index 00000000..ac82a581 --- /dev/null +++ b/ui-svelte/src/lib/components/ui/data-table/flex-render.svelte @@ -0,0 +1,40 @@ + + +{#if typeof content === "string"} + {content} +{:else if content instanceof Function} + + + {@const result = content(context as any)} + {#if result instanceof RenderComponentConfig} + {@const { component: Component, props } = result} + + {:else if result instanceof RenderSnippetConfig} + {@const { snippet, params } = result} + {@render snippet({ ...params, attach })} + {:else} + {result} + {/if} +{/if} diff --git a/ui-svelte/src/lib/components/ui/data-table/index.ts b/ui-svelte/src/lib/components/ui/data-table/index.ts new file mode 100644 index 00000000..5f4e77ea --- /dev/null +++ b/ui-svelte/src/lib/components/ui/data-table/index.ts @@ -0,0 +1,3 @@ +export { default as FlexRender } from "./flex-render.svelte"; +export { renderComponent, renderSnippet } from "./render-helpers.js"; +export { createSvelteTable } from "./data-table.svelte.js"; diff --git a/ui-svelte/src/lib/components/ui/data-table/render-helpers.ts b/ui-svelte/src/lib/components/ui/data-table/render-helpers.ts new file mode 100644 index 00000000..fa036d62 --- /dev/null +++ b/ui-svelte/src/lib/components/ui/data-table/render-helpers.ts @@ -0,0 +1,111 @@ +import type { Component, ComponentProps, Snippet } from "svelte"; + +/** + * A helper class to make it easy to identify Svelte components in + * `columnDef.cell` and `columnDef.header` properties. + * + * > NOTE: This class should only be used internally by the adapter. If you're + * reading this and you don't know what this is for, you probably don't need it. + * + * @example + * ```svelte + * {@const result = content(context as any)} + * {#if result instanceof RenderComponentConfig} + * {@const { component: Component, props } = result} + * + * {/if} + * ``` + */ +export class RenderComponentConfig { + component: TComponent; + props: ComponentProps | Record; + constructor( + component: TComponent, + props: ComponentProps | Record = {} + ) { + this.component = component; + this.props = props; + } +} + +/** + * A helper class to make it easy to identify Svelte Snippets in `columnDef.cell` and `columnDef.header` properties. + * + * > NOTE: This class should only be used internally by the adapter. If you're + * reading this and you don't know what this is for, you probably don't need it. + * + * @example + * ```svelte + * {@const result = content(context as any)} + * {#if result instanceof RenderSnippetConfig} + * {@const { snippet, params } = result} + * {@render snippet(params)} + * {/if} + * ``` + */ +export class RenderSnippetConfig { + snippet: Snippet<[TProps]>; + params: TProps; + constructor(snippet: Snippet<[TProps]>, params: TProps) { + this.snippet = snippet; + this.params = params; + } +} + +/** + * A helper function to help create cells from Svelte components through ColumnDef's `cell` and `header` properties. + * + * This is only to be used with Svelte Components - use `renderSnippet` for Svelte Snippets. + * + * @param component A Svelte component + * @param props The props to pass to `component` + * @returns A `RenderComponentConfig` object that helps svelte-table know how to render the header/cell component. + * @example + * ```ts + * // +page.svelte + * const defaultColumns = [ + * columnHelper.accessor('name', { + * header: header => renderComponent(SortHeader, { label: 'Name', header }), + * }), + * columnHelper.accessor('state', { + * header: header => renderComponent(SortHeader, { label: 'State', header }), + * }), + * ] + * ``` + * @see {@link https://tanstack.com/table/latest/docs/guide/column-defs} + */ +export function renderComponent< + // eslint-disable-next-line @typescript-eslint/no-explicit-any + T extends Component, + Props extends ComponentProps, +>(component: T, props: Props = {} as Props) { + return new RenderComponentConfig(component, props); +} + +/** + * A helper function to help create cells from Svelte Snippets through ColumnDef's `cell` and `header` properties. + * + * The snippet must only take one parameter. + * + * This is only to be used with Snippets - use `renderComponent` for Svelte Components. + * + * @param snippet + * @param params + * @returns - A `RenderSnippetConfig` object that helps svelte-table know how to render the header/cell snippet. + * @example + * ```ts + * // +page.svelte + * const defaultColumns = [ + * columnHelper.accessor('name', { + * cell: cell => renderSnippet(nameSnippet, { name: cell.row.name }), + * }), + * columnHelper.accessor('state', { + * cell: cell => renderSnippet(stateSnippet, { state: cell.row.state }), + * }), + * ] + * ``` + * @see {@link https://tanstack.com/table/latest/docs/guide/column-defs} + */ +export function renderSnippet(snippet: Snippet<[TProps]>, params: TProps = {} as TProps) { + return new RenderSnippetConfig(snippet, params); +}