Admin UI: split table state from table component. This will make it easier to use more of TanStack/table's more advanced features.

Also split and clean-up some of the table explorer components.
This commit is contained in:
Sebastian Jeltsch
2026-01-15 15:51:47 +01:00
parent 381b855b95
commit 3ac69471ab
7 changed files with 520 additions and 500 deletions
+85 -122
View File
@@ -1,12 +1,4 @@
import {
For,
Match,
Switch,
createEffect,
createMemo,
createSignal,
splitProps,
} from "solid-js";
import { For, Match, Show, Switch } from "solid-js";
import type { Accessor } from "solid-js";
import {
flexRender,
@@ -19,7 +11,6 @@ import type {
ColumnPinningState,
PaginationState,
Row,
RowSelectionState,
Table as TableType,
} from "@tanstack/solid-table";
import { TbPin, TbPinFilled } from "solid-icons/tb";
@@ -33,7 +24,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import {
Table,
Table as ShadcnTable,
TableBody,
TableCell,
TableHead,
@@ -57,37 +48,27 @@ export function safeParseInt(v: string | undefined): number | undefined {
return undefined;
}
type Props<TData, TValue> = {
columns: Accessor<ColumnDef<TData, TValue>[]>;
data: Accessor<TData[] | undefined>;
type TableOptions<TData, TValue> = {
data: TData[] | undefined;
columns: ColumnDef<TData, TValue>[];
rowCount?: number;
pagination?: PaginationState;
onPaginationChange?: (state: PaginationState) => void;
onRowSelection?: (rows: Row<TData>[], value: boolean) => void;
onRowClick?: (idx: number, row: TData) => void;
columnPinning?: Accessor<ColumnPinningState>;
onColumnPinningChange?: (state: ColumnPinningState) => void;
};
// TODO: This entire implementation is incredibly messy. We should probably just
// receive a `createSolidTable` result, i.e. use TableImpl below. This would allow
// users direct access to the table state.
export function DataTable<TData, TValue>(props: Props<TData, TValue>) {
const [local] = splitProps(props, ["columns", "data"]);
const [rowSelection, setRowSelection] = createSignal<RowSelectionState>({});
createEffect(() => {
// NOTE: because we use our own state for row selection, reset it when data changes.
const _ = local.data();
setRowSelection({});
});
export function buildTable<TData, TValue>(opts: TableOptions<TData, TValue>) {
console.debug("buildTable: ", opts);
const buildColumns = () => {
const onRowSelection = props.onRowSelection;
function buildColumns() {
const onRowSelection = opts.onRowSelection;
if (!onRowSelection) {
return local.columns();
return opts.columns;
}
const helper = createColumnHelper<TData>();
@@ -131,122 +112,110 @@ export function DataTable<TData, TValue>(props: Props<TData, TValue>) {
),
}),
// Custom/Domain-provided columns
...local.columns(),
...opts.columns,
];
};
}
const table = createMemo(() => {
console.debug("table data rebuild");
const columns = buildColumns();
const enableColumnPinning =
props.columnPinning !== undefined && columns.length > 2;
const buildColumnPinningState = (): ColumnPinningState => {
const state = {
...props.columnPinning?.(),
};
if (state.left?.[0] !== "__select__") {
state.left = ["__select__", ...(state.left ?? [])];
}
return state;
function buildColumnPinningState(): ColumnPinningState {
const state = {
...opts.columnPinning?.(),
};
if (state.left?.[0] !== "__select__") {
state.left = ["__select__", ...(state.left ?? [])];
}
return state;
}
const t = createSolidTable({
data: local.data() || [],
state: {
pagination:
props.pagination !== undefined
? {
pageIndex: props.pagination.pageIndex ?? 0,
pageSize: props.pagination.pageSize ?? 20,
}
: undefined,
rowSelection: rowSelection(),
columnPinning: buildColumnPinningState(),
},
columns,
getCoreRowModel: getCoreRowModel(),
const columns = buildColumns();
const enableColumnPinning =
opts.columnPinning !== undefined && columns.length > 2;
// NOTE: requires setting up the header cells with resize handles.
// enableColumnResizing: true,
// columnResizeMode: 'onChange',
// pagination:
manualPagination: true,
onPaginationChange:
props.onPaginationChange !== undefined
? (updater) => {
const newState =
typeof updater === "function"
? updater(t.getState().pagination)
: updater;
props.onPaginationChange!(newState);
const t = createSolidTable({
data: opts.data ?? [],
state: {
pagination:
opts.pagination !== undefined
? {
pageIndex: opts.pagination.pageIndex ?? 0,
pageSize: opts.pagination.pageSize ?? 20,
}
: undefined,
// If set to true, pagination will be reset to the first page when page-altering state changes
// eg. data is updated, filters change, grouping changes, etc.
//
// NOTE: In our current setup this causes infinite reload cycles when paginating.
autoResetPageIndex: false,
rowCount: props.rowCount,
// rowSelection: rowSelection(),
columnPinning: buildColumnPinningState(),
},
columns,
getCoreRowModel: getCoreRowModel(),
// Just means, the input data is already filtered.
manualFiltering: true,
// NOTE: requires setting up the header cells with resize handles.
// enableColumnResizing: true,
// columnResizeMode: 'onChange',
enableRowSelection: true,
enableMultiRowSelection: props.onRowSelection ? true : false,
onRowSelectionChange: setRowSelection,
// pagination:
manualPagination: true,
onPaginationChange:
opts.onPaginationChange !== undefined
? (updater) => {
const newState =
typeof updater === "function"
? updater(t.getState().pagination)
: updater;
enableColumnPinning,
onColumnPinningChange:
props.onColumnPinningChange !== undefined
? (updater) => {
const newState =
typeof updater === "function"
? updater(t.getState().columnPinning)
: updater;
opts.onPaginationChange!(newState);
}
: undefined,
// If set to true, pagination will be reset to the first page when page-altering state changes
// eg. data is updated, filters change, grouping changes, etc.
//
// NOTE: In our current setup this causes infinite reload cycles when paginating.
autoResetPageIndex: false,
rowCount: opts.rowCount,
props.onColumnPinningChange!(newState);
}
: undefined,
});
// Just means, the input data is already filtered.
manualFiltering: true,
return t;
enableRowSelection: true,
enableMultiRowSelection: opts.onRowSelection ? true : false,
// onRowSelectionChange: setRowSelection,
enableColumnPinning,
onColumnPinningChange:
opts.onColumnPinningChange !== undefined
? (updater) => {
const newState =
typeof updater === "function"
? updater(t.getState().columnPinning)
: updater;
opts.onColumnPinningChange!(newState);
}
: undefined,
});
return (
<TableImpl
table={table()}
onRowClick={props.onRowClick}
paginationEnabled={props.pagination !== undefined}
/>
);
return t;
}
function TableImpl<TData>(props: {
export function Table<TData>(props: {
table: TableType<TData>;
onRowClick?: (idx: number, row: TData) => void;
paginationEnabled: boolean;
showPaginationControls: boolean;
}) {
const paginationEnabled = () => props.paginationEnabled;
const paginationEnabled = () => props.showPaginationControls;
const paginationState = () => props.table.getState().pagination;
const columns = () => props.table.options.columns;
const numRows = () => props.table.getRowModel().rows?.length ?? 0;
const showPin = () => props.table.options.enableColumnPinning;
return (
<>
{paginationEnabled() && (
<div>
<Show when={paginationEnabled()}>
<PaginationControl
table={props.table}
rowCount={props.table.options.rowCount}
/>
)}
</Show>
<div class="rounded-md border">
<Table>
<ShadcnTable>
<TableHeader>
<For each={props.table.getHeaderGroups()}>
{(headerGroup) => (
@@ -383,15 +352,9 @@ function TableImpl<TData>(props: {
</Match>
</Switch>
</TableBody>
</Table>
</ShadcnTable>
</div>
{/*
{paginationEnabled() && (
<PaginationControl table={table} rowCount={props.rowCount} />
)}
*/}
</>
</div>
);
}
@@ -1,4 +1,11 @@
import { createSignal, Match, Show, Switch, Suspense } from "solid-js";
import {
createMemo,
createSignal,
Match,
Show,
Switch,
Suspense,
} from "solid-js";
import type { Setter } from "solid-js";
import { useSearchParams } from "@solidjs/router";
import { TbRefresh, TbCrown, TbEdit, TbTrash } from "solid-icons/tb";
@@ -26,7 +33,7 @@ import {
} from "@/components/ui/sheet";
import { Header } from "@/components/Header";
import { DataTable, safeParseInt } from "@/components/Table";
import { Table, buildTable, safeParseInt } from "@/components/Table";
import { IconButton } from "@/components/IconButton";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
@@ -330,7 +337,21 @@ export function AccountsPage() {
const [editUser, setEditUser] = createSignal<UserJson | undefined>();
const columns = () => buildColumns(setEditUser, refetch);
const accountsTable = createMemo(() => {
return buildTable({
columns: buildColumns(setEditUser, refetch),
data: users.data?.users ?? [],
rowCount: Number(users.data?.total_row_count ?? -1),
pagination: pagination(),
onPaginationChange: (s: PaginationState) => {
setSearchParams({
...searchParams,
pageIndex: s.pageIndex,
pageSize: s.pageSize,
});
},
});
});
return (
<div class="h-full">
@@ -368,18 +389,10 @@ export function AccountsPage() {
<Match when={users.data}>
<div class="w-full space-y-2.5">
<DataTable
columns={columns}
data={() => users.data!.users}
rowCount={Number(users.data!.total_row_count ?? -1)}
pagination={pagination()}
onPaginationChange={(s: PaginationState) => {
setSearchParams({
...searchParams,
pageIndex: s.pageIndex,
pageSize: s.pageSize,
});
}}
<Table
table={accountsTable()}
showPaginationControls={true}
onRowClick={undefined}
/>
</div>
@@ -4,6 +4,7 @@ import {
Match,
Show,
Switch,
createMemo,
createEffect,
createSignal,
onCleanup,
@@ -63,7 +64,7 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { showToast } from "@/components/ui/toast";
import { DataTable } from "@/components/Table";
import { Table, buildTable } from "@/components/Table";
import { useNavbar, DirtyDialog } from "@/components/Navbar";
import type { QueryResponse } from "@bindings/QueryResponse";
@@ -110,6 +111,32 @@ function ResultView(props: {
const isCached = () => props.response === undefined;
const response = () => props.response ?? props.script.result;
return (
<Switch>
<Match when={response()?.error}>
<div class="p-4">Error: {response()?.error?.message}</div>
</Match>
<Match when={response()?.data === undefined}>
<div class="p-4">No Data</div>
</Match>
<Match when={response()?.data !== undefined}>
<ResultViewImpl
data={response()!.data!}
timestamp={response()?.timestamp}
isCached={isCached()}
/>
</Match>
</Switch>
);
}
function ResultViewImpl(props: {
data: QueryResponse;
isCached: boolean;
timestamp?: number;
}) {
const [columnPinningState, setColumnPinningState] = createSignal({});
function columnDefs(data: QueryResponse): ColumnDef<ArrayRecord, SqlValue>[] {
@@ -126,49 +153,41 @@ function ResultView(props: {
});
}
const dataTable = createMemo(() => {
// TODO: Enable pagination
return buildTable({
columns: columnDefs(props.data),
data: props.data.rows,
columnPinning: columnPinningState,
onColumnPinningChange: setColumnPinningState,
});
});
return (
<Switch>
<Match when={response()?.error}>
<div class="p-4">Error: {response()?.error?.message}</div>
</Match>
<ErrorBoundary
fallback={(err, _reset) => {
return (
<div class="m-4 flex flex-col gap-4">
<p>Failed to render query result: {`${err}`}</p>
<Match when={response()?.data === undefined}>
<div class="p-4">No Data</div>
</Match>
<Match when={response()?.data !== undefined}>
<ErrorBoundary
fallback={(err, _reset) => {
return (
<div class="m-4 flex flex-col gap-4">
<p>Failed to render query result: {`${err}`}</p>
{isCached() && (
<p>
The view is trying to show cached data. Maybe the schema has
changed. Try to re-execute the query.
</p>
)}
</div>
);
}}
>
<div class="flex flex-col gap-2 p-4">
<div class="flex justify-end text-sm">
<ExecutionTime timestamp={response()?.timestamp} />
</div>
{/* TODO: Enable pagination */}
<DataTable
columns={() => columnDefs(response()!.data!)}
data={() => response()!.data!.rows}
columnPinning={columnPinningState}
onColumnPinningChange={setColumnPinningState}
/>
<Show when={props.isCached}>
<p>
The view is trying to show cached data. Maybe the schema has
changed. Try to re-execute the query.
</p>
</Show>
</div>
</ErrorBoundary>
</Match>
</Switch>
);
}}
>
<div class="flex flex-col gap-2 p-4">
<div class="flex justify-end text-sm">
<ExecutionTime timestamp={props.timestamp} />
</div>
<Table table={dataTable()} showPaginationControls={false} />
</div>
</ErrorBoundary>
);
}
@@ -7,6 +7,7 @@ import {
createSignal,
onCleanup,
onMount,
createMemo,
} from "solid-js";
import { useSearchParams } from "@solidjs/router";
import { createWritableMemo } from "@solid-primitives/memo";
@@ -39,7 +40,7 @@ import {
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { DataTable, safeParseInt } from "@/components/Table";
import { Table, buildTable, safeParseInt } from "@/components/Table";
import { FilterBar } from "@/components/FilterBar";
import { getLogs } from "@/lib/api/logs";
@@ -203,9 +204,26 @@ export function LogsPage() {
const [showMap, setShowMap] = createSignal(true);
const [showGeoipDialog, setShowGeoipDialog] = createSignal(false);
const [columnPinningState, setColumnPinningState] = createSignal({});
const logsTable = createMemo(() => {
return buildTable({
columns,
data: logsFetch.data?.entries ?? [],
columnPinning: columnPinningState,
onColumnPinningChange: setColumnPinningState,
rowCount: Number(logsFetch.data?.total_row_count ?? -1),
pagination: pagination(),
onPaginationChange: (s: PaginationState) => {
setSearchParams({
...searchParams,
pageIndex: s.pageIndex,
pageSize: s.pageSize,
});
},
});
});
return (
<div class="h-full">
<Header
@@ -293,21 +311,7 @@ export function LogsPage() {
placeholder={`Filter Query, e.g. '(latency > 2 || status >= 400) && method = "GET"'`}
/>
<DataTable
columns={() => columns}
data={() => logsFetch.data!.entries}
columnPinning={columnPinningState}
onColumnPinningChange={setColumnPinningState}
rowCount={Number(logsFetch.data!.total_row_count ?? -1)}
pagination={pagination()}
onPaginationChange={(s: PaginationState) => {
setSearchParams({
...searchParams,
pageIndex: s.pageIndex,
pageSize: s.pageSize,
});
}}
/>
<Table table={logsTable()} showPaginationControls={true} />
</Match>
</Switch>
</div>
@@ -1,4 +1,4 @@
import { createSignal, Switch, Match } from "solid-js";
import { createSignal, Switch, Match, createMemo } from "solid-js";
import { useQueryClient } from "@tanstack/solid-query";
import type { Row, ColumnDef } from "@tanstack/solid-table";
@@ -11,7 +11,7 @@ import {
CardFooter,
CardHeader,
} from "@/components/ui/card";
import { DataTable } from "@/components/Table";
import { Table, buildTable } from "@/components/Table";
import {
Dialog,
DialogContent,
@@ -70,6 +70,31 @@ function DatabaseSettingsForm(props: {
await setConfig(queryClient, newConfig, { throw: false });
};
const dbTable = createMemo(() => {
return buildTable({
columns: buildColumns(),
data: props.config.databases,
rowCount: props.config.databases.length,
onRowSelection: (rows: Row<DatabaseConfig>[], value: boolean) => {
const newSelection = new Set<string>(selectedRows());
for (const row of rows) {
const key = row.original.name;
if (!key) {
continue;
}
if (value) {
newSelection.add(key);
} else {
newSelection.delete(key);
}
}
setSelectedRows(newSelection);
},
});
});
let ref: HTMLInputElement | undefined;
return (
@@ -159,31 +184,7 @@ function DatabaseSettingsForm(props: {
</p>
<div class="max-h-[500px] overflow-auto">
<DataTable
columns={buildColumns}
data={() => props.config.databases}
rowCount={props.config.databases.length}
onRowSelection={(
rows: Row<DatabaseConfig>[],
value: boolean,
) => {
const newSelection = new Set<string>(selectedRows());
for (const row of rows) {
const key = row.original.name;
if (!key) {
continue;
}
if (value) {
newSelection.add(key);
} else {
newSelection.delete(key);
}
}
setSelectedRows(newSelection);
}}
/>
<Table table={dbTable()} showPaginationControls={false} />
</div>
</CardContent>
@@ -1,15 +1,11 @@
import { createSignal, Switch, Match, Show } from "solid-js";
import { useQuery } from "@tanstack/solid-query";
import { TbDownload, TbColumnsOff } from "solid-icons/tb";
import { TbDownload, TbBug } from "solid-icons/tb";
import { adminFetch } from "@/lib/fetch";
import { showSaveFileDialog, stringToReadableStream } from "@/lib/utils";
import { RecordApiConfig } from "@proto/config";
import type { Table } from "@bindings/Table";
import type { TableIndex } from "@bindings/TableIndex";
import type { TableTrigger } from "@bindings/TableTrigger";
import type { View } from "@bindings/View";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardTitle, CardHeader } from "@/components/ui/card";
@@ -130,43 +126,26 @@ export function SchemaCard(props: { api: RecordApiConfig }) {
);
}
export function DebugSchemaDialogButton(props: {
table: Table | View;
indexes: TableIndex[];
triggers: TableTrigger[];
}) {
const indexes = () => props.indexes;
const triggers = () => props.triggers;
export function DebugDialogButton(props: { title: string; data: object }) {
return (
<Dialog id="schema">
<DialogTrigger>
<IconButton tooltip="[DEV only]">
<TbColumnsOff />
<TbBug />
</IconButton>
</DialogTrigger>
<DialogContent class="max-w-[80dvw]">
<DialogHeader>
<DialogTitle>[Debug] Schema</DialogTitle>
<DialogTitle>[Debug] {props.title}</DialogTitle>
</DialogHeader>
<div class="max-h-[80dvh] overflow-auto">
<div class="mx-2 flex flex-col gap-2">
<h3>Schema</h3>
<h3>{props.title}</h3>
<pre class="w-[70vw] overflow-x-hidden text-xs">
{JSON.stringify(props.table, null, 2)}
</pre>
<h3>Indexes</h3>
<pre class="w-[70vw] overflow-x-hidden text-xs">
{JSON.stringify(indexes(), null, 2)}
</pre>
<h3>Triggers</h3>
<pre class="w-[70vw] overflow-x-hidden text-xs">
{JSON.stringify(triggers(), null, 2)}
{JSON.stringify(props.data, null, 2)}
</pre>
</div>
</div>
@@ -37,10 +37,14 @@ import {
import { SidebarTrigger } from "@/components/ui/sidebar";
import { showToast } from "@/components/ui/toast";
import { DebugSchemaDialogButton } from "@/components/tables/SchemaDownload";
import { DebugDialogButton } from "@/components/tables/SchemaDownload";
import { CreateAlterTableForm } from "@/components/tables/CreateAlterTable";
import { CreateAlterIndexForm } from "@/components/tables/CreateAlterIndex";
import { DataTable, safeParseInt } from "@/components/Table";
import {
Table as TableComponent,
buildTable,
safeParseInt,
} from "@/components/Table";
import { FilterBar } from "@/components/FilterBar";
import { DestructiveActionButton } from "@/components/DestructiveActionButton";
import { IconButton } from "@/components/IconButton";
@@ -246,16 +250,17 @@ function TableHeaderRightHandButtons(props: {
allTables: Table[];
schemaRefetch: () => Promise<void>;
}) {
const table = () => props.table;
const hidden = () => hiddenTable(table());
const type = () => tableType(table());
const selectedSchema = () => props.table;
const hidden = () => hiddenTable(selectedSchema());
const type = () => tableType(selectedSchema());
const satisfiesRecordApi = createMemo(() =>
tableOrViewSatisfiesRecordApiRequirements(props.table, props.allTables),
);
const queryClient = useQueryClient();
const config = createConfigQuery();
const hasRecordApi = () => hasRecordApis(config?.data?.config, table().name);
const hasRecordApi = () =>
hasRecordApis(config?.data?.config, selectedSchema().name);
return (
<div class="flex items-center justify-end gap-2">
@@ -267,7 +272,7 @@ function TableHeaderRightHandButtons(props: {
return (async () => {
try {
await dropTable({
name: prettyFormatQualifiedName(table().name),
name: prettyFormatQualifiedName(selectedSchema().name),
dry_run: null,
});
} finally {
@@ -370,15 +375,13 @@ function TableHeaderRightHandButtons(props: {
function TableHeader(props: {
table: [Table, string] | [View, string];
indexes: [TableIndex, string][];
triggers: [TableTrigger, string][];
allTables: [Table, string][];
schemaRefetch: () => Promise<void>;
rowsRefetch: () => void;
}) {
const allTables = createMemo(() => props.allTables.map(([t, _]) => t));
const table = () => props.table[0];
const type = () => tableType(table());
const selectedSchema = () => props.table[0];
const type = () => tableType(selectedSchema());
const headerTitle = () => {
switch (type()) {
@@ -395,7 +398,7 @@ function TableHeader(props: {
<Header
leading={<SidebarTrigger />}
title={headerTitle()}
titleSelect={prettyFormatQualifiedName(table().name)}
titleSelect={prettyFormatQualifiedName(selectedSchema().name)}
left={
<div class="flex items-center">
<IconButton tooltip="Refresh Data" onClick={props.rowsRefetch}>
@@ -419,19 +422,11 @@ function TableHeader(props: {
</span>
</DialogContent>
</Dialog>
<Show when={import.meta.env.DEV}>
<DebugSchemaDialogButton
table={table()}
indexes={props.indexes.map(([index, _]) => index)}
triggers={props.triggers.map(([trig, _]) => trig)}
/>
</Show>
</div>
}
right={
<TableHeaderRightHandButtons
table={table()}
table={selectedSchema()}
allTables={allTables()}
schemaRefetch={props.schemaRefetch}
/>
@@ -534,8 +529,10 @@ function ArrayRecordTable(props: {
new Map<string, SqlValue>(),
);
const table = () => props.state.selected;
const mutable = () => tableType(table()) === "table" && !hiddenTable(table());
const selectedSchema = () => props.state.selected;
const mutable = () =>
tableType(selectedSchema()) === "table" && !hiddenTable(selectedSchema());
const data = () => props.state.response.rows;
const rowsRefetch = () => props.rowsRefetch();
const columns = (): Column[] => props.state.response.columns;
@@ -544,17 +541,49 @@ function ArrayRecordTable(props: {
const pkColumnIndex = createMemo(
() => findPrimaryKeyColumnIndex(columns()) ?? 0,
);
const columnDefs = createMemo(() =>
buildColumnDefs(
table().name,
const table = createMemo(() => {
const columns = buildColumnDefs(
selectedSchema().name,
pkColumnIndex(),
props.state.response.columns,
blobEncoding(),
),
);
);
return buildTable({
// NOTE: The cell rendering is constrolled via the columnsDefs.
columns,
data: data(),
columnPinning: props.columnPinningState[0],
onColumnPinningChange: props.columnPinningState[1],
rowCount: Number(totalRowCount()),
pagination: props.pagination[0](),
onPaginationChange: (s: PaginationState) => {
props.pagination[1](s);
},
onRowSelection: mutable()
? // eslint-disable-next-line solid/reactivity
(rows: Row<ArrayRecord>[], value: boolean) => {
const newSelection = new Map<string, SqlValue>(selectedRows());
for (const row of rows) {
const pkValue: SqlValue = row.original[pkColumnIndex()];
const key = hashSqlValue(pkValue);
if (value) {
newSelection.set(key, pkValue);
} else {
newSelection.delete(key);
}
}
setSelectedRows(newSelection);
}
: undefined,
});
});
return (
<div>
<div id="data">
<SafeSheet
open={[
() => editRow() !== undefined,
@@ -569,7 +598,7 @@ function ArrayRecordTable(props: {
<>
<SheetContent class={sheetMaxWidth}>
<InsertUpdateRowForm
schema={table() as Table}
schema={selectedSchema() as Table}
rowsRefetch={rowsRefetch}
row={editRow()}
{...sheet}
@@ -589,17 +618,9 @@ function ArrayRecordTable(props: {
/>
<div class="overflow-x-auto pt-4">
<DataTable
// NOTE: The formatting is done via the columnsDefs.
columns={columnDefs}
data={() => props.state.response.rows}
columnPinning={props.columnPinningState[0]}
onColumnPinningChange={props.columnPinningState[1]}
rowCount={Number(totalRowCount())}
pagination={props.pagination[0]()}
onPaginationChange={(s: PaginationState) => {
props.pagination[1](s);
}}
<TableComponent
table={table()}
showPaginationControls={true}
onRowClick={
mutable()
? (_idx: number, row: ArrayRecord) => {
@@ -607,28 +628,6 @@ function ArrayRecordTable(props: {
}
: undefined
}
onRowSelection={
mutable()
? (rows: Row<ArrayRecord>[], value: boolean) => {
const newSelection = new Map<string, SqlValue>(
selectedRows(),
);
for (const row of rows) {
const pkValue: SqlValue =
row.original[pkColumnIndex()];
const key = hashSqlValue(pkValue);
if (value) {
newSelection.set(key, pkValue);
} else {
newSelection.delete(key);
}
}
setSelectedRows(newSelection);
}
: undefined
}
/>
</div>
</>
@@ -646,7 +645,7 @@ function ArrayRecordTable(props: {
<>
<SheetContent class={sheetMaxWidth}>
<InsertUpdateRowForm
schema={table() as Table}
schema={selectedSchema() as Table}
rowsRefetch={rowsRefetch}
{...sheet}
/>
@@ -676,10 +675,13 @@ function ArrayRecordTable(props: {
(async () => {
try {
await deleteRows(prettyFormatQualifiedName(table().name), {
primary_key_column: columns()[pkColumnIndex()].name,
values: ids,
});
await deleteRows(
prettyFormatQualifiedName(selectedSchema().name),
{
primary_key_column: columns()[pkColumnIndex()].name,
values: ids,
},
);
setSelectedRows(new Map<string, SqlValue>());
} catch (err) {
@@ -723,47 +725,225 @@ function ArrayRecordTable(props: {
<SelectContent />
</Select>
<Show when={import.meta.env.DEV}>
<DebugDialogButton title="Schema" data={data()} />
</Show>
</div>
</div>
</div>
);
}
function IndexTable(props: {
table: Table;
schemas: ListSchemasResponse;
schemaRefetch: () => Promise<void>;
hidden: boolean;
}) {
const [editIndex, setEditIndex] = createSignal<TableIndex | undefined>();
const [selectedIndexes, setSelectedIndexes] = createSignal(new Set<string>());
const indexes = createMemo(() => {
return props.schemas.indexes.filter(([index, _]) =>
equalQualifiedNames(props.table.name, {
name: index.table_name,
database_schema: index.name.database_schema,
}),
);
});
const indexesTable = createMemo(() => {
return buildTable({
columns: indexColumns,
data: indexes().map(([index, _]) => index),
onRowSelection: props.hidden
? undefined
: // eslint-disable-next-line solid/reactivity
(rows: Row<TableIndex>[], value: boolean) => {
const newSelection = new Set(selectedIndexes());
for (const row of rows) {
const qualifiedName = prettyFormatQualifiedName(
row.original.name,
);
if (value) {
newSelection.add(qualifiedName);
} else {
newSelection.delete(qualifiedName);
}
}
setSelectedIndexes(newSelection);
},
});
});
return (
<div id="indexes">
<h2>
Indexes
<Show when={import.meta.env.DEV}>
<DebugDialogButton title="Indexes" data={indexes()} />
</Show>
</h2>
<SafeSheet
open={[
() => editIndex() !== undefined,
(isOpen: boolean | ((value: boolean) => boolean)) => {
if (!isOpen) {
setEditIndex(undefined);
}
},
]}
>
{(sheet) => {
return (
<>
<SheetContent class={sheetMaxWidth}>
<CreateAlterIndexForm
schema={editIndex()}
table={props.table}
schemaRefetch={props.schemaRefetch}
{...sheet}
/>
</SheetContent>
<div class="space-y-2.5 overflow-x-auto">
<TableComponent
table={indexesTable()}
showPaginationControls={false}
onRowClick={
props.hidden
? undefined
: (_idx: number, index: TableIndex) => {
setEditIndex(index);
}
}
/>
</div>
</>
);
}}
</SafeSheet>
<Show when={!props.hidden}>
<div class="mt-2 flex gap-2">
<SafeSheet>
{(sheet) => {
return (
<>
<SheetContent class={sheetMaxWidth}>
<CreateAlterIndexForm
schemaRefetch={props.schemaRefetch}
table={props.table}
{...sheet}
/>
</SheetContent>
<SheetTrigger
as={(props: DialogTriggerProps) => (
<Button variant="default" {...props}>
Add Index
</Button>
)}
/>
</>
);
}}
</SafeSheet>
<Button
variant="destructive"
disabled={selectedIndexes().size == 0}
onClick={() => {
const names = Array.from(selectedIndexes());
if (names.length == 0) {
return;
}
(async () => {
try {
for (const name of names) {
await dropIndex({ name, dry_run: null });
}
setSelectedIndexes(new Set<string>());
} catch (err) {
showToast({
title: "Deletion Error",
description: `${err}`,
variant: "error",
});
} finally {
props.schemaRefetch();
}
})();
}}
>
Delete indexes
</Button>
</div>
</Show>
</div>
);
}
function TriggerTable(props: { table: Table; schemas: ListSchemasResponse }) {
const triggers = createMemo(() => {
return props.schemas.triggers.filter(([trig, _]) =>
equalQualifiedNames(props.table.name, {
name: trig.table_name,
database_schema: trig.name.database_schema,
}),
);
});
const triggersTable = createMemo(() => {
return buildTable({
columns: triggerColumns,
data: triggers().map(([trig, sql]) => ({
...trig,
sql,
})),
});
});
return (
<div id="triggers">
<h2>
Triggers
<Show when={import.meta.env.DEV}>
<DebugDialogButton title="Triggers" data={triggers()} />
</Show>
</h2>
<p class="text-sm">
The admin dashboard currently does not support modifying triggers.
Please use the editor to{" "}
<a href="https://www.sqlite.org/lang_createtrigger.html">create</a> new
triggers or <a href="https://sqlite.org/lang_droptrigger.html">drop</a>{" "}
existing ones.
</p>
<div class="mt-4">
<TableComponent
table={triggersTable()}
showPaginationControls={false}
/>
</div>
</div>
);
}
export function TablePane(props: {
selectedTable: [Table, string] | [View, string];
schemas: ListSchemasResponse;
schemaRefetch: () => Promise<void>;
}) {
const [editIndex, setEditIndex] = createSignal<TableIndex | undefined>();
const [selectedIndexes, setSelectedIndexes] = createSignal(new Set<string>());
const table = () => props.selectedTable[0];
const indexes = createMemo(() => {
return props.schemas.indexes.filter(([index, _]) =>
equalQualifiedNames(
{
name: index.table_name,
database_schema: index.name.database_schema,
},
table().name,
),
);
});
const triggers = createMemo(() => {
return props.schemas.triggers.filter(([trig, _]) =>
equalQualifiedNames(
{
name: trig.table_name,
database_schema: trig.name.database_schema,
},
table().name,
),
);
});
// Derived table() props.
const type = () => tableType(table());
const hidden = () => hiddenTable(table());
const selectedSchema = () => props.selectedTable[0];
const type = () => tableType(selectedSchema());
const hidden = () => hiddenTable(selectedSchema());
const [searchParams, setSearchParams] = useSearchParams<{
filter?: string;
@@ -862,8 +1042,6 @@ export function TablePane(props: {
<>
<TableHeader
table={props.selectedTable}
indexes={indexes()}
triggers={triggers()}
allTables={props.schemas.tables}
schemaRefetch={schemaRefetch}
rowsRefetch={rowsRefetch}
@@ -893,158 +1071,21 @@ export function TablePane(props: {
</Match>
</Switch>
{type() === "table" && (
<div id="indexes">
<h2>Indexes</h2>
<Show when={type() === "table"}>
<IndexTable
table={selectedSchema() as Table}
schemas={props.schemas}
schemaRefetch={props.schemaRefetch}
hidden={hidden()}
/>
</Show>
<SafeSheet
open={[
() => editIndex() !== undefined,
(isOpen: boolean | ((value: boolean) => boolean)) => {
if (!isOpen) {
setEditIndex(undefined);
}
},
]}
children={(sheet) => {
return (
<>
<SheetContent class={sheetMaxWidth}>
<CreateAlterIndexForm
schema={editIndex()}
table={table() as Table}
schemaRefetch={props.schemaRefetch}
{...sheet}
/>
</SheetContent>
<div class="space-y-2.5 overflow-x-auto">
<DataTable
columns={() => indexColumns}
data={() => indexes().map(([index, _]) => index)}
onRowClick={
hidden()
? undefined
: (_idx: number, index: TableIndex) => {
setEditIndex(index);
}
}
onRowSelection={
hidden()
? undefined
: (rows: Row<TableIndex>[], value: boolean) => {
const newSelection = new Set(selectedIndexes());
for (const row of rows) {
const qualifiedName =
prettyFormatQualifiedName(
row.original.name,
);
if (value) {
newSelection.add(qualifiedName);
} else {
newSelection.delete(qualifiedName);
}
}
setSelectedIndexes(newSelection);
}
}
/>
</div>
</>
);
}}
/>
{!hidden() && (
<div class="mt-2 flex gap-2">
<SafeSheet
children={(sheet) => {
return (
<>
<SheetContent class={sheetMaxWidth}>
<CreateAlterIndexForm
schemaRefetch={props.schemaRefetch}
table={table() as Table}
{...sheet}
/>
</SheetContent>
<SheetTrigger
as={(props: DialogTriggerProps) => (
<Button variant="default" {...props}>
Add Index
</Button>
)}
/>
</>
);
}}
/>
<Button
variant="destructive"
disabled={selectedIndexes().size == 0}
onClick={() => {
const names = Array.from(selectedIndexes());
if (names.length == 0) {
return;
}
(async () => {
try {
for (const name of names) {
await dropIndex({ name, dry_run: null });
}
setSelectedIndexes(new Set<string>());
} catch (err) {
showToast({
title: "Deletion Error",
description: `${err}`,
variant: "error",
});
} finally {
props.schemaRefetch();
}
})();
}}
>
Delete indexes
</Button>
</div>
)}
</div>
)}
{type() === "table" && (
<div id="triggers">
<h2>Triggers</h2>
<p class="text-sm">
The admin dashboard currently does not support modifying triggers.
Please use the editor to{" "}
<a href="https://www.sqlite.org/lang_createtrigger.html">
create
</a>{" "}
new triggers or{" "}
<a href="https://sqlite.org/lang_droptrigger.html">drop</a>{" "}
existing ones.
</p>
<div class="mt-4">
<DataTable
columns={() => triggerColumns}
data={() =>
triggers().map(([trig, sql]) => ({
...trig,
sql,
}))
}
/>
</div>
</div>
)}
<Show when={type() === "table"}>
<TriggerTable
table={selectedSchema() as Table}
schemas={props.schemas}
/>
</Show>
</div>
</>
);