diff --git a/package.json b/package.json index dbd89565..d09b135c 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "@sveltejs/kit": "^2.70.2", "@sveltejs/vite-plugin-svelte": "^7.2.0", "@tailwindcss/vite": "^4.3.3", - "@tanstack/table-core": "^8.21.3", + "@tanstack/svelte-table": "^9.0.0", "@types/node": "^26.1.2", "@types/semver": "^7.8.0", "@types/w3c-web-serial": "^1.0.8", diff --git a/packages/svelte-core b/packages/svelte-core index f2581fb6..5b2a9643 160000 --- a/packages/svelte-core +++ b/packages/svelte-core @@ -1 +1 @@ -Subproject commit f2581fb6d79a81a46a7de20ffb8c3d22f3d00221 +Subproject commit 5b2a964327d17a2fae20523d61d792772e1eaa19 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c35ca8fa..500314a7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -99,9 +99,9 @@ importers: '@tailwindcss/vite': specifier: ^4.3.3 version: 4.3.3(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)) - '@tanstack/table-core': - specifier: ^8.21.3 - version: 8.21.3 + '@tanstack/svelte-table': + specifier: ^9.0.0 + version: 9.0.0(svelte@5.56.8(@typescript-eslint/types@8.66.0)) '@types/node': specifier: ^26.1.2 version: 26.1.2 @@ -1515,9 +1515,23 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 || ^8 - '@tanstack/table-core@8.21.3': - resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} - engines: {node: '>=12'} + '@tanstack/store@0.11.0': + resolution: {integrity: sha512-WlzzCt3xi0G6pCAJu1U+2jiECwabETDpQDi3hfkFZvJii9AuZqEKbOiVarX1/bWhTNjU486yQtJCCasi/0q+Cw==} + + '@tanstack/svelte-store@0.12.0': + resolution: {integrity: sha512-XhXlU3jIO/WxikfeVczRdsAvRWzsLBh8Ic6sC7nzfzvMbPut7ZSdCbE7/usfm0bMjVGMmZmyzZy2xRu73QYWAA==} + peerDependencies: + svelte: ^5.0.0 + + '@tanstack/svelte-table@9.0.0': + resolution: {integrity: sha512-hQBEup5JppVhYJZ1436b43MC7L2oL5wuSa/Yy5e1NS82WosLqHG7Bq/qhyk1sxw4BQHxTF0/9RzAIKRyBE0w9g==} + engines: {node: '>=20'} + peerDependencies: + svelte: ^5.0.0 + + '@tanstack/table-core@9.0.0': + resolution: {integrity: sha512-IyKCc4D7d/+I9euQntlVDQ7lnilmFRKpIROBeI5/866adFy+65xWvCFPz76NZ5j2lXe/RFDvFVV7bfETmwHEMA==} + engines: {node: '>=20'} '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -4721,7 +4735,22 @@ snapshots: tailwindcss: 4.3.3 vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0) - '@tanstack/table-core@8.21.3': {} + '@tanstack/store@0.11.0': {} + + '@tanstack/svelte-store@0.12.0(svelte@5.56.8(@typescript-eslint/types@8.66.0))': + dependencies: + '@tanstack/store': 0.11.0 + svelte: 5.56.8(@typescript-eslint/types@8.66.0) + + '@tanstack/svelte-table@9.0.0(svelte@5.56.8(@typescript-eslint/types@8.66.0))': + dependencies: + '@tanstack/svelte-store': 0.12.0(svelte@5.56.8(@typescript-eslint/types@8.66.0)) + '@tanstack/table-core': 9.0.0 + svelte: 5.56.8(@typescript-eslint/types@8.66.0) + + '@tanstack/table-core@9.0.0': + dependencies: + '@tanstack/store': 0.11.0 '@types/chai@5.2.3': dependencies: diff --git a/src/lib/components/Table/ColumnUtils.ts b/src/lib/components/Table/ColumnUtils.ts index 65f22dbc..b8bd0f1c 100644 --- a/src/lib/components/Table/ColumnUtils.ts +++ b/src/lib/components/Table/ColumnUtils.ts @@ -1,56 +1,32 @@ import { getReadableUserAgentName } from '$lib/utils'; -import { renderComponent } from '@openshock/svelte-core/components/ui/data-table/index.js'; +import { durationBetween, formatDuration } from '@openshock/svelte-core/utils/index.js'; import { - durationBetween, - formatDuration, - formatDurationSeconds, - formatElapsed, -} from '@openshock/svelte-core/utils/index.js'; -import { - sortingFns, - type BuiltInSortingFn, + renderComponent, + sortFn_alphanumeric, type ColumnDef, + type ExtractSortFnKeys, type Row, - type SortingFn, - type SortingFnOption, + type RowData, + type SortFnOption, type StringOrTemplateHeader, -} from '@tanstack/table-core'; + type TableFeatures, +} from '@tanstack/svelte-table'; import type { SemVer } from 'semver'; import type { Component, ComponentProps } from 'svelte'; import CellContent from './CellContent.svelte'; import DataTableSortButton from './SortButton.svelte'; +import type { SortableTableFeatures } from './types'; type CellContentProps = ComponentProps; -function CreateSortHeader(name: string): StringOrTemplateHeader { - return ({ column }) => - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- generic Svelte components can't be parameterized in .ts files - renderComponent(DataTableSortButton as Component, { - name, - column, - }); -} - -export function CreateColumnDef>( - accessorKey: TKey, - headerName: string, - renderer: (content: TData[TKey]) => CellContentProps -): ColumnDef { - return { - accessorKey, - header: headerName, - cell: ({ row }) => renderComponent(CellContent, renderer(row.original[accessorKey])), - }; -} - // TanStack's 'auto' sorting can't compare Temporal.Instant values (they aren't // primitives — basic comparison invokes Temporal's valueOf, which throws), so it // breaks sorting on every date/time column. This default adds Temporal.Instant // support while otherwise mirroring how 'auto' resolves: numbers compare // numerically (basic), everything else uses the alphanumeric/natural comparator. -function temporalAwareSortingFn( - rowA: Row, - rowB: Row, +function temporalAwareSortFn( + rowA: Row, + rowB: Row, columnId: string ): number { const a: unknown = rowA.getValue(columnId); @@ -71,47 +47,92 @@ function temporalAwareSortingFn( return a === b ? 0 : a > b ? 1 : -1; } - return sortingFns.alphanumeric(rowA, rowB, columnId); + return sortFn_alphanumeric(rowA, rowB, columnId); } -export function CreateSortableColumnDef< - TData extends object, - TKey extends Extract, ->( - accessorKey: TKey, - headerName: string, - renderer: (content: TData[TKey]) => CellContentProps, - sortFunct?: 'auto' | ((a: TData[TKey], b: TData[TKey]) => number) | BuiltInSortingFn -): ColumnDef { - let sortingFn: SortingFnOption; - if (sortFunct) { - if (typeof sortFunct === 'string') { - sortingFn = sortFunct; +/** + * Binds the column-def builders to one table's feature set and row type. + * + * v9 threads `TFeatures` through every column type, and TypeScript can't infer + * it from these builders' arguments (it only appears in their return types), so + * it's pinned once per table here. The individual builders still infer their + * accessor key and actions-component types per call, so call sites are + * unchanged from v8: + * + * ```ts + * const { CreateSortableColumnDef, CreateActionsColumnDef } = + * CreateColumnDefs(); + * ``` + */ +export function CreateColumnDefs() { + // v9 derives the feature-dependent half of `ColumnDef` through a mapped type + // keyed on `keyof TFeatures`, which TypeScript can't reduce while `TFeatures` + // is still a type parameter. Authoring each literal against the fully + // populated `TableFeatures` shape keeps it properly checked, and the widening + // to the caller's feature set happens once, here. + const asColumnDef = (def: ColumnDef) => + def as unknown as ColumnDef; + + function CreateSortHeader(name: string): StringOrTemplateHeader { + return ({ column }) => + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- generic Svelte components can't be parameterized in .ts files + renderComponent(DataTableSortButton as Component, { + name, + column, + }); + } + + function CreateColumnDef>( + accessorKey: TKey, + headerName: string, + renderer: (content: TData[TKey]) => CellContentProps + ): ColumnDef { + return asColumnDef({ + accessorKey, + header: headerName, + cell: ({ row }) => renderComponent(CellContent, renderer(row.original[accessorKey])), + }); + } + + function CreateSortableColumnDef>( + accessorKey: TKey, + headerName: string, + renderer: (content: TData[TKey]) => CellContentProps, + sortFunct?: 'auto' | ((a: TData[TKey], b: TData[TKey]) => number) | ExtractSortFnKeys + ): ColumnDef { + let sortFn: SortFnOption; + if (sortFunct) { + if (typeof sortFunct === 'string') { + sortFn = sortFunct; + } else { + sortFn = (row_a, row_b) => + sortFunct(row_a.getValue(accessorKey), row_b.getValue(accessorKey)); + } } else { - sortingFn = (row_a, row_b) => - sortFunct(row_a.getValue(accessorKey), row_b.getValue(accessorKey)); + sortFn = (rowA, rowB, columnId) => temporalAwareSortFn(rowA, rowB, columnId); } - } else { - sortingFn = temporalAwareSortingFn as SortingFn; + + return asColumnDef({ + accessorKey, + header: CreateSortHeader(headerName), + cell: ({ row }) => renderComponent(CellContent, renderer(row.original[accessorKey])), + // Registered sort-fn names come from the caller's `sortFns`, which the + // generic `TableFeatures` shape used for checking doesn't know about. + sortFn: sortFn as SortFnOption, + }); } - return { - accessorKey, - header: CreateSortHeader(headerName), - cell: ({ row }) => renderComponent(CellContent, renderer(row.original[accessorKey])), - sortingFn, - }; -} + function CreateActionsColumnDef< + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- matches renderComponent's own constraint + T extends Component, + >(component: T, getProps: (row: TData) => ComponentProps): ColumnDef { + return asColumnDef({ + id: 'actions', + cell: ({ row }) => renderComponent(component, getProps(row.original)), + }); + } -export function CreateActionsColumnDef< - TData, - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- matches renderComponent's own constraint - T extends Component, ->(component: T, getProps: (row: TData) => ComponentProps): ColumnDef { - return { - id: 'actions', - cell: ({ row }) => renderComponent(component, getProps(row.original)), - }; + return { CreateColumnDef, CreateSortableColumnDef, CreateActionsColumnDef }; } // Pre-defined cell values @@ -120,7 +141,6 @@ export const CellNotApplicable: CellContentProps = { bold: true, title: 'Not applicable', }; -export const CellOrangeNever: CellContentProps = { text: 'Never', bold: true, color: 'orange' }; export const CellRedUnknown: CellContentProps = { text: 'Unknown', bold: true, color: 'red' }; export const CellRedInvalid: CellContentProps = { text: 'Invalid', bold: true, color: 'red' }; export const CellRedUnavailable: CellContentProps = { @@ -172,13 +192,6 @@ export const RenderCellWithTooltip = (content: string, tooltip: string): CellCon title: tooltip, }); -export function LocaleDateRenderer(instant: Temporal.Instant): CellContentProps { - return RenderCellWithTooltip( - instant.toLocaleString(undefined, { dateStyle: 'short' }), - instant.toString() - ); -} - export function LocaleDateTimeRenderer(instant: Temporal.Instant | null): CellContentProps { if (!instant) return RenderCell('Never'); return RenderCellWithTooltip(instant.toLocaleString(), instant.toString()); @@ -191,26 +204,9 @@ export function TimeSinceDurationRenderer(instant: Temporal.Instant): CellConten ); } -export function TimeSinceRelativeRenderer(instant: Temporal.Instant): CellContentProps { - if (instant.epochMilliseconds <= 0) return CellOrangeNever; - return RenderCellWithTooltip( - formatElapsed(durationBetween(Temporal.Now.instant(), instant)), - instant.toString() - ); -} - -export const TimeSinceRelativeOrNeverRenderer = ( - instant: Temporal.Instant | null | undefined -): CellContentProps => - instant instanceof Temporal.Instant ? TimeSinceRelativeRenderer(instant) : CellOrangeNever; - export const NumberRenderer = (number: number | null): CellContentProps => number ? RenderBoldCell(number.toString()) : CellNotApplicable; -// Durations are stored/transmitted in milliseconds; display them in seconds with a unit. -export const DurationRenderer = (durationMs: number | null): CellContentProps => - durationMs ? RenderBoldCell(formatDurationSeconds(durationMs / 1000)) : CellNotApplicable; - export const UserAgentRenderer = (userAgent: string | null): CellContentProps => { if (!userAgent) return CellRedUnknown; diff --git a/src/lib/components/Table/DataTableTemplate.svelte b/src/lib/components/Table/DataTableTemplate.svelte index d7988112..11ad11eb 100644 --- a/src/lib/components/Table/DataTableTemplate.svelte +++ b/src/lib/components/Table/DataTableTemplate.svelte @@ -1,24 +1,31 @@ -
@@ -87,12 +82,9 @@ {#each table.getHeaderGroups() as headerGroup (headerGroup.id)} {#each headerGroup.headers as header (header.id)} - + {#if !header.isPlaceholder} - + {/if} {/each} @@ -101,13 +93,10 @@ {#each table.getRowModel().rows as row (row.id)} - onRowClick?.(row.original)} - > - {#each row.getVisibleCells() as cell (cell.id)} + onRowClick?.(row.original)}> + {#each row.getAllCells() as cell (cell.id)} - + {/each} diff --git a/src/lib/components/Table/SortButton.svelte b/src/lib/components/Table/SortButton.svelte index 37d2d0a9..f31cd75c 100644 --- a/src/lib/components/Table/SortButton.svelte +++ b/src/lib/components/Table/SortButton.svelte @@ -1,10 +1,13 @@ - @@ -82,7 +70,38 @@ handleApiError(error)}> - + {#if tokens.length === 0} + + {:else} +
+ {#each tokens as token (token.id)} + {@const lastUsed = formatRelativeInstant(token.lastUsed, now)} + {@const expires = formatRelativeInstant(token.validUntil, now)} +
+
+ {token.name} + + {token.shockerControl.paused ? 'Paused' : 'Active'} + +
+
+ + Created {token.createdOn.toLocaleString(undefined, { dateStyle: 'short' })} + + {lastUsed ? `Last used ${lastUsed}` : 'Never used'} + {expires ? `Expires ${expires}` : 'Never expires'} +
+ +
+ {/each} +
+ {/if} {#snippet pending()}
diff --git a/src/routes/(app)/settings/api-tokens/data-table-actions.svelte b/src/routes/(app)/settings/api-tokens/token-actions.svelte similarity index 100% rename from src/routes/(app)/settings/api-tokens/data-table-actions.svelte rename to src/routes/(app)/settings/api-tokens/token-actions.svelte diff --git a/src/routes/(app)/settings/sessions/+page.svelte b/src/routes/(app)/settings/sessions/+page.svelte index 11dbee8f..b76a2e5b 100644 --- a/src/routes/(app)/settings/sessions/+page.svelte +++ b/src/routes/(app)/settings/sessions/+page.svelte @@ -1,26 +1,18 @@ @@ -88,6 +73,34 @@ - + {#if data.length === 0} + + {:else} +
+ {#each data as session (session.id)} + {@const lastSeen = formatRelativeInstant(session.lastUsed, now)} + {@const started = formatRelativeInstant(session.created, now)} + {@const expires = formatRelativeInstant(session.expires, now)} +
+
+ + {deviceName(session.userAgent)} + + {session.ip} +
+
+ {lastSeen ? `Last seen ${lastSeen}` : 'Never used'} + {started ? `Signed in ${started}` : 'Sign-in time unknown'} + {expires ? `Expires ${expires}` : 'Never expires'} +
+ +
+ {/each} +
+ {/if}
diff --git a/src/routes/(app)/settings/sessions/data-table-actions.svelte b/src/routes/(app)/settings/sessions/session-actions.svelte similarity index 100% rename from src/routes/(app)/settings/sessions/data-table-actions.svelte rename to src/routes/(app)/settings/sessions/session-actions.svelte diff --git a/src/routes/(app)/shockers/logs/+page.svelte b/src/routes/(app)/shockers/logs/+page.svelte index 4c8de6d9..ca6473f2 100644 --- a/src/routes/(app)/shockers/logs/+page.svelte +++ b/src/routes/(app)/shockers/logs/+page.svelte @@ -1,23 +1,19 @@ @@ -237,7 +237,7 @@
-
+
0} {/if} +
+ (sortField = value as SortField)} + > + Sort: {sortLabel} + + + Sort by + {#each SORT_FIELDS as field (field.value)} + {field.label} + {/each} + + + + +
-
- +
+ {#if logs.length === 0} + 0 + ? 'No entries for the selected shockers yet.' + : 'Control activity will show up here once a shocker is used.'} + /> + {:else} +
+ {#each logs as entry (entry.id)} +
+ {entry.type} +
+
{entry.shockerName}
+
+ {entry.hubName} · by {controlledByName(entry)} +
+
+
+
+ {entry.intensity ? entry.intensity : 'N/A'} · {entry.duration + ? formatDurationSeconds(entry.duration / 1000) + : 'N/A'} +
+
+ {entry.createdOn.toLocaleString()} +
+
+
+ {/each} +
+ {/if}