From 09b07c47ea56e8eedd46e531cddd50f6258459e8 Mon Sep 17 00:00:00 2001 From: Sebastian Jeltsch Date: Mon, 2 Feb 2026 14:44:47 +0100 Subject: [PATCH] Show skeleton rows in tables while loading data to reduce visual jank. --- .../assets/js/admin/src/components/Table.tsx | 44 +++- .../src/components/accounts/AccountsPage.tsx | 116 +++++---- .../src/components/editor/EditorPage.tsx | 2 +- .../js/admin/src/components/logs/LogsPage.tsx | 37 ++- .../components/settings/DatabaseSettings.tsx | 2 +- .../admin/src/components/tables/TablePane.tsx | 236 +++++++++--------- 6 files changed, 227 insertions(+), 210 deletions(-) diff --git a/crates/assets/js/admin/src/components/Table.tsx b/crates/assets/js/admin/src/components/Table.tsx index fc9183b2..bae605fc 100644 --- a/crates/assets/js/admin/src/components/Table.tsx +++ b/crates/assets/js/admin/src/components/Table.tsx @@ -1,4 +1,4 @@ -import { For, Match, Show, Switch } from "solid-js"; +import { Index, For, Match, Show, Switch } from "solid-js"; import type { Accessor } from "solid-js"; import { flexRender, @@ -36,6 +36,7 @@ import { TableRow, } from "@/components/ui/table"; import { Checkbox } from "@/components/ui/checkbox"; +import { Skeleton } from "@/components/ui/skeleton"; import { createIsMobile } from "@/lib/signals"; type TableOptions = { @@ -204,12 +205,13 @@ function omit(object: T, key: K): Omit { export function Table(props: { table: SolidTable; + loading: boolean; onRowClick?: (idx: number, row: TData) => void; }) { const paginationEnabled = () => props.table.options.manualPagination ?? false; const paginationState = () => props.table.getState().pagination; const columns = () => props.table.options.columns; - const numRows = () => props.table.getRowModel().rows?.length ?? 0; + const numRows = (): number => props.table.getRowModel().rows.length; const enableSorting = () => props.table.options.manualSorting || props.table.options.enableSorting; @@ -248,6 +250,34 @@ export function Table(props: { + + + {() => ( + + + {(cell) => ( + + + + + + + + + + + + )} + + + )} + + + 0}> {(row) => ( @@ -256,15 +286,7 @@ export function Table(props: { - 0}> - - - Loading... - - - - - + Empty diff --git a/crates/assets/js/admin/src/components/accounts/AccountsPage.tsx b/crates/assets/js/admin/src/components/accounts/AccountsPage.tsx index 465e3c68..09e574f9 100644 --- a/crates/assets/js/admin/src/components/accounts/AccountsPage.tsx +++ b/crates/assets/js/admin/src/components/accounts/AccountsPage.tsx @@ -404,65 +404,75 @@ export function AccountsPage() { - Loading +
+ + - +
-
+
- - { - return ( - <> - - - - - ( - - )} - /> - - ); - }} - /> - - {/* WARN: This might open multiple sheets or at least scrims for each row */} - editUser() !== undefined, - (isOpen: boolean | ((value: boolean) => boolean)) => { - if (!isOpen) { - setEditUser(undefined); - } - }, - ]} - children={(sheet) => { - return ( - - - - - - ); - }} - /> + + { + return ( + <> + + + + + ( + + )} + /> + + ); + }} + /> + + {/* WARN: This might open multiple sheets or at least scrims for each row */} + editUser() !== undefined, + (isOpen: boolean | ((value: boolean) => boolean)) => { + if (!isOpen) { + setEditUser(undefined); + } + }, + ]} + children={(sheet) => { + return ( + + + + + + ); + }} + /> diff --git a/crates/assets/js/admin/src/components/editor/EditorPage.tsx b/crates/assets/js/admin/src/components/editor/EditorPage.tsx index c3005116..144ea2e4 100644 --- a/crates/assets/js/admin/src/components/editor/EditorPage.tsx +++ b/crates/assets/js/admin/src/components/editor/EditorPage.tsx @@ -195,7 +195,7 @@ function ResultViewImpl(props: { -
+
); diff --git a/crates/assets/js/admin/src/components/logs/LogsPage.tsx b/crates/assets/js/admin/src/components/logs/LogsPage.tsx index a0d8c97a..79002a3e 100644 --- a/crates/assets/js/admin/src/components/logs/LogsPage.tsx +++ b/crates/assets/js/admin/src/components/logs/LogsPage.tsx @@ -315,29 +315,27 @@ export function LogsPage() { />
- Loading...

}> + Error {`${logsFetch.error}`} - - Loading - - - - {pagination().pageIndex === 0 && logsFetch.data!.stats && ( + +
- +
- +
- )} +
2 || status >= 400) && method = "GET"'`} /> -
+
@@ -445,8 +443,8 @@ const Legend = L.Control.extend({ }, }); -function WorldMap(props: { country_codes: { [key in string]?: number } }) { - const codes = () => props.country_codes; +function WorldMap(props: { country_codes: Stats["country_codes"] }) { + const codes = () => props.country_codes ?? {}; let ref: HTMLDivElement | undefined; let map: L.Map | undefined; @@ -547,15 +545,10 @@ function WorldMap(props: { country_codes: { [key in string]?: number } }) { ); } -function LogsChart(props: { stats: Stats }) { - const stats = props.stats; - +function LogsChart(props: { rates: Stats["rate"] }) { const data = (): ChartData | undefined => { - const s = stats; - if (!s) return; - - const labels = s.rate.map(([ts, _v]) => Number(ts) * 1000); - const data = s.rate.map(([_ts, v]) => v); + const labels = props.rates.map(([ts, _v]) => Number(ts) * 1000); + const data = props.rates.map(([_ts, v]) => v); return { labels, diff --git a/crates/assets/js/admin/src/components/settings/DatabaseSettings.tsx b/crates/assets/js/admin/src/components/settings/DatabaseSettings.tsx index b458a860..356bdf04 100644 --- a/crates/assets/js/admin/src/components/settings/DatabaseSettings.tsx +++ b/crates/assets/js/admin/src/components/settings/DatabaseSettings.tsx @@ -184,7 +184,7 @@ function DatabaseSettingsForm(props: {

-
+
diff --git a/crates/assets/js/admin/src/components/tables/TablePane.tsx b/crates/assets/js/admin/src/components/tables/TablePane.tsx index 80e3e597..91d8c8f1 100644 --- a/crates/assets/js/admin/src/components/tables/TablePane.tsx +++ b/crates/assets/js/admin/src/components/tables/TablePane.tsx @@ -3,7 +3,7 @@ import type { Signal } from "solid-js"; import { createWritableMemo } from "@solid-primitives/memo"; import { TbRefresh, TbTable, TbTrash, TbColumns } from "solid-icons/tb"; import { useSearchParams } from "@solidjs/router"; -import { useQuery, useQueryClient } from "@tanstack/solid-query"; +import { useQuery } from "@tanstack/solid-query"; import type { QueryObserverResult } from "@tanstack/solid-query"; import type { CellContext, @@ -64,7 +64,7 @@ import { UploadedFiles, } from "@/components/tables/Files"; -import { createConfigQuery, invalidateConfig } from "@/lib/api/config"; +import { createConfigQuery } from "@/lib/api/config"; import type { Record, ArrayRecord } from "@/lib/record"; import { hashSqlValue } from "@/lib/value"; import { urlSafeBase64ToUuid, toHex, safeParseInt } from "@/lib/utils"; @@ -255,12 +255,11 @@ function TableHeaderRightHandButtons(props: { const satisfiesRecordApi = createMemo(() => tableOrViewSatisfiesRecordApiRequirements(props.table, props.allTables), ); - - const queryClient = useQueryClient(); - const config = createConfigQuery(); const hasRecordApi = () => hasRecordApis(config?.data?.config, selectedSchema().name); + const config = createConfigQuery(); + return (
{/* Delete table button */} @@ -275,7 +274,7 @@ function TableHeaderRightHandButtons(props: { dry_run: null, }); } finally { - invalidateConfig(queryClient); + await config.refetch(); await props.schemaRefetch(); } })(); @@ -380,10 +379,9 @@ function TableHeader(props: { }) { const allTables = createMemo(() => props.allTables.map(([t, _]) => t)); const selectedSchema = () => props.table[0]; - const type = () => tableType(selectedSchema()); const headerTitle = () => { - switch (type()) { + switch (tableType(selectedSchema())) { case "view": return "View"; case "virtualTable": @@ -434,34 +432,6 @@ function TableHeader(props: { ); } -type TableState = { - selected: Table | View; - response: ListRowsResponse; -}; - -async function buildTableState( - selected: Table | View, - filter: string | null, - pageSize: number, - pageIndex: number, - cursor: string | null, - sorting: SortingState, -): Promise { - const response = await fetchRows( - selected.name, - filter, - pageSize, - pageIndex, - cursor, - formatSortingAsOrder(sorting), - ); - - return { - selected, - response, - }; -} - type CellType = "UUID" | "JSON" | "File" | "File[]" | ColumnDataType; function deriveCellType(column: Column): CellType { @@ -483,12 +453,29 @@ function deriveCellType(column: Column): CellType { } function buildColumnDefs( - tableName: QualifiedName, + selectedSchema: Table | View, + columns: Column[] | undefined, pkColumnIndex: number, - columns: Column[], blobEncoding: BlobEncoding, ): ColumnDef[] { - return columns.map((col, idx) => { + if (columns === undefined) { + // Fallback to schema (rather than response) column defintions. + if (tableType(selectedSchema) === "table") { + return (selectedSchema as Table).columns.map((c) => ({ + id: c.name, + header: c.name, + })); + } + + // We don't have any schema column defs. Fallback to single col. + return [ + { + header: "", + }, + ]; + } + + return columns.map((col, idx): ColumnDef => { const fk = getForeignKey(col.options); const notNull = isNotNull(col.options); const type = deriveCellType(col); @@ -505,7 +492,7 @@ function buildColumnDefs( cell: (context) => renderCell( context, - tableName, + selectedSchema.name, columns, pkColumnIndex, { @@ -515,12 +502,13 @@ function buildColumnDefs( blobEncoding, ), accessorFn: (row: ArrayRecord) => row[idx], - } as ColumnDef; + }; }); } -function ArrayRecordTable(props: { - state: TableState; +function RecordTable(props: { + selectedSchema: Table | View; + records: ListRowsResponse | undefined; pagination: SimpleSignal; filter: SimpleSignal; columnPinningState: Signal; @@ -533,31 +521,31 @@ function ArrayRecordTable(props: { new Map(), ); - const selectedSchema = () => props.state.selected; + const selectedSchema = () => props.selectedSchema; const mutable = () => tableType(selectedSchema()) === "table" && !hiddenTable(selectedSchema()); - const data = () => props.state.response.rows; - const rowsRefetch = () => props.rowsRefetch(); - const columns = (): Column[] => props.state.response.columns; - const totalRowCount = () => props.state.response.total_row_count; + + const data = () => props.records?.rows; + const columns = () => props.records?.columns; + const totalRowCount = () => props.records?.total_row_count ?? 0; const pkColumnIndex = createMemo( - () => findPrimaryKeyColumnIndex(columns()) ?? 0, + () => findPrimaryKeyColumnIndex(columns() ?? []) ?? 0, ); const table = createMemo(() => { - const columns = buildColumnDefs( - selectedSchema().name, + const columnDefs = buildColumnDefs( + selectedSchema(), + columns(), pkColumnIndex(), - props.state.response.columns, blobEncoding(), ); return buildTable( { // NOTE: The cell rendering is constrolled via the columnsDefs. - columns, + columns: columnDefs, data: data(), columnPinning: props.columnPinningState[0], onColumnPinningChange: props.columnPinningState[1], @@ -633,10 +621,11 @@ function ArrayRecordTable(props: {
{ - setEditRow(rowDataToRow(columns(), row)); + setEditRow(rowDataToRow(columns() ?? [], row)); } : undefined } @@ -690,7 +679,8 @@ function ArrayRecordTable(props: { await deleteRows( prettyFormatQualifiedName(selectedSchema().name), { - primary_key_column: columns()[pkColumnIndex()].name, + primary_key_column: + columns()?.[pkColumnIndex()].name ?? "??", values: ids, }, ); @@ -739,7 +729,7 @@ function ArrayRecordTable(props: { - +
@@ -751,8 +741,8 @@ function IndexTable(props: { table: Table; schemas: ListSchemasResponse; schemaRefetch: () => Promise; - hidden: boolean; }) { + const hidden = () => hiddenTable(props.table); const [editIndex, setEditIndex] = createSignal(); const [selectedIndexes, setSelectedIndexes] = createSignal(new Set()); @@ -769,7 +759,7 @@ function IndexTable(props: { return buildTable({ columns: indexColumns, data: indexes().map(([index, _]) => index), - onRowSelection: props.hidden + onRowSelection: hidden() ? undefined : // eslint-disable-next-line solid/reactivity (rows: Row[], value: boolean) => { @@ -824,8 +814,9 @@ function IndexTable(props: {
{ setEditIndex(index); @@ -838,7 +829,7 @@ function IndexTable(props: { }} - +
{(sheet) => { @@ -938,7 +929,7 @@ function TriggerTable(props: { table: Table; schemas: ListSchemasResponse }) {

- +
); @@ -950,8 +941,7 @@ export function TablePane(props: { schemaRefetch: () => Promise; }) { const selectedSchema = () => props.selectedTable[0]; - const type = () => tableType(selectedSchema()); - const hidden = () => hiddenTable(selectedSchema()); + const isTable = () => tableType(selectedSchema()) === "table"; const [searchParams, setSearchParams] = useSearchParams<{ filter?: string; @@ -966,64 +956,62 @@ export function TablePane(props: { return []; }); - const filter = () => searchParams.filter; - const setFilter = (filter: string | undefined) => { - setSearchParams({ - ...searchParams, - filter, - }); - }; + const [filter, setFilter] = [ + () => searchParams.filter, + (filter: string | undefined) => { + setSearchParams({ + ...searchParams, + filter, + }); + }, + ]; - const pagination = (): PaginationState => { - return { - pageSize: safeParseInt(searchParams.pageSize) ?? 20, - pageIndex: safeParseInt(searchParams.pageIndex) ?? 0, - }; - }; - const setPagination = (s: PaginationState) => { - setSearchParams({ - ...searchParams, - pageSize: s.pageSize, - pageIndex: s.pageIndex, - }); - }; + const [pagination, setPagination] = [ + (): PaginationState => { + return { + pageSize: safeParseInt(searchParams.pageSize) ?? 20, + pageIndex: safeParseInt(searchParams.pageIndex) ?? 0, + }; + }, + (s: PaginationState) => { + setSearchParams({ + ...searchParams, + pageSize: s.pageSize, + pageIndex: s.pageIndex, + }); + }, + ]; const [sorting, setSorting] = createSignal([]); - const state: QueryObserverResult = useQuery(() => ({ + const records: QueryObserverResult = useQuery(() => ({ queryKey: [ - "tableData", - prettyFormatQualifiedName(props.selectedTable[0].name), + selectedSchema().name, searchParams.filter, - pagination().pageIndex, - pagination().pageSize, + pagination(), sorting(), - ], + ] as ReadonlyArray, queryFn: async ({ queryKey }) => { - const p = pagination(); - const c = cursors(); - const s = sorting(); - - console.debug( - `Fetching data with key: ${queryKey}, index: ${p.pageIndex}, cursors: ${c}, sorting: ${s}`, - ); + console.debug(`Fetching data with key: ${queryKey}`); try { - const state = await buildTableState( - props.selectedTable[0], + const { pageSize, pageIndex } = pagination(); + + const response = await fetchRows( + selectedSchema().name, searchParams.filter ?? null, - p.pageSize, - p.pageIndex, - c[p.pageIndex - 1], - s, + pageSize, + pageIndex, + cursors()[pageIndex - 1], + formatSortingAsOrder(sorting()), ); - const cursor = state.response.cursor; - if (cursor && p.pageIndex >= c.length) { - setCursors([...c, cursor]); + const newCursor = response.cursor; + if (newCursor && pageIndex >= cursors().length) { + setCursors([...cursors(), newCursor]); } - return state; + return response; } catch (err) { // Reset. setSearchParams({ @@ -1037,13 +1025,7 @@ export function TablePane(props: { }, })); - const client = useQueryClient(); - const rowsRefetch = () => { - // Refetches the actual table contents above. - client.invalidateQueries({ - queryKey: ["tableData"], - }); - }; + const rowsRefetch = records.refetch; const schemaRefetch = async () => { // First re-fetch the schema then the data rows to trigger a re-render. await props.schemaRefetch(); @@ -1063,20 +1045,31 @@ export function TablePane(props: {
- +
- Failed to fetch rows: {`${state.error}`} + Failed to fetch rows: {`${records.error}`}
- Loading... + + + - - +
- + - +