From c1cd9f398eaeeff467c957ebc893490b484c8cfc Mon Sep 17 00:00:00 2001 From: Aaron Sutula Date: Tue, 18 Jun 2024 16:27:27 -0600 Subject: [PATCH 01/25] editing in memory data working Signed-off-by: Aaron Sutula --- packages/web/components/data-table.tsx | 28 +------ packages/web/components/edit-cell.tsx | 35 ++++++++ packages/web/components/table-cell.tsx | 40 +++++++++ packages/web/components/table-data.tsx | 107 +++++++++++++++++++++++++ packages/web/components/table.tsx | 35 ++++---- packages/web/lib/utils.ts | 6 +- packages/web/tsconfig.json | 8 +- packages/web/types.d.ts | 19 +++++ 8 files changed, 233 insertions(+), 45 deletions(-) create mode 100644 packages/web/components/edit-cell.tsx create mode 100644 packages/web/components/table-cell.tsx create mode 100644 packages/web/components/table-data.tsx create mode 100644 packages/web/types.d.ts diff --git a/packages/web/components/data-table.tsx b/packages/web/components/data-table.tsx index d63b731a..89716fd0 100644 --- a/packages/web/components/data-table.tsx +++ b/packages/web/components/data-table.tsx @@ -1,12 +1,7 @@ -"use client"; - import { type ColumnDef, - type VisibilityState, flexRender, - getCoreRowModel, - getPaginationRowModel, - useReactTable, + type Table as TSTable, } from "@tanstack/react-table"; import { ChevronDown } from "lucide-react"; import React from "react"; @@ -29,31 +24,14 @@ import { interface DataTableProps { columns: Array>; data: TData[]; + table: TSTable; } export function DataTable({ columns, data, + table, }: DataTableProps) { - const [columnVisibility, setColumnVisibility] = - React.useState({}); - - const table = useReactTable({ - data, - columns, - getCoreRowModel: getCoreRowModel(), - getPaginationRowModel: getPaginationRowModel(), - onColumnVisibilityChange: setColumnVisibility, - initialState: { - pagination: { - pageSize: 15, - }, - }, - state: { - columnVisibility, - }, - }); - return (
diff --git a/packages/web/components/edit-cell.tsx b/packages/web/components/edit-cell.tsx new file mode 100644 index 00000000..7a9f799d --- /dev/null +++ b/packages/web/components/edit-cell.tsx @@ -0,0 +1,35 @@ +import { type Cell } from "@tanstack/react-table"; +import { type MouseEvent } from "react"; + +export function EditCell({ + row, + table, +}: ReturnType, unknown>["getContext"]>) { + const meta = table.options.meta; + + const setEditedRows = (e: MouseEvent) => { + const elName = e.currentTarget.name; + meta?.setEditedRows((old) => ({ + ...old, + [row.id]: !old[row.id], + })); + if (elName !== "edit") { + meta?.revertData(row.index, e.currentTarget.name === "cancel"); + } + }; + + return meta?.editedRows[row.id] ? ( + <> + {" "} + + + ) : ( + + ); +} diff --git a/packages/web/components/table-cell.tsx b/packages/web/components/table-cell.tsx new file mode 100644 index 00000000..ff0ab22e --- /dev/null +++ b/packages/web/components/table-cell.tsx @@ -0,0 +1,40 @@ +import { useEffect, useState } from "react"; +import { type Cell } from "@tanstack/react-table"; +import { Input } from "./ui/input"; + +export default function TableCell({ + getValue, + row, + column, + table, +}: ReturnType, unknown>["getContext"]>) { + const initialValue = getValue(); + const columnMeta = column.columnDef.meta; + const tableMeta = table.options.meta; + + const [value, setValue] = useState(isValue(initialValue) ? initialValue : ""); + + useEffect(() => { + setValue(isValue(initialValue) ? initialValue : ""); + }, [initialValue]); + + const onBlur = () => { + table.options.meta?.updateData(row.index, column.id, value); + }; + + if (tableMeta?.editedRows[row.id]) { + return ( + setValue(e.target.value)} + onBlur={onBlur} + /> + ); + } + return {value}; +} + +function isValue(value: unknown): value is string | number { + return typeof value === "string" || typeof value === "number"; +} diff --git a/packages/web/components/table-data.tsx b/packages/web/components/table-data.tsx new file mode 100644 index 00000000..e102f0c1 --- /dev/null +++ b/packages/web/components/table-data.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { + type ColumnDef, + type DisplayColumnDef, + type VisibilityState, + getCoreRowModel, + getPaginationRowModel, + useReactTable, +} from "@tanstack/react-table"; +import React, { useState } from "react"; +import { type Schema } from "@tableland/sdk"; +import { DataTable } from "./data-table"; +import TableCell from "./table-cell"; +import { EditCell } from "./edit-cell"; +import { objectToTableData } from "@/lib/utils"; + +interface TableDataProps { + // columns: Array>>; + columns: Schema["columns"]; + initialData: Array>; +} + +export function TableData({ + columns: sdkColumns, + initialData, +}: TableDataProps) { + const [data, setData] = useState(objectToTableData(initialData)); + const [originalData, setOriginalData] = useState(() => [...initialData]); + const [editedRows, setEditedRows] = useState>({}); + + const [columnVisibility, setColumnVisibility] = useState({}); + + const columns: + | Array< + | ColumnDef> + | DisplayColumnDef> + > + | undefined = sdkColumns.map((col) => ({ + accessorKey: col.name, + header: col.name, + cell: TableCell, + meta: { + type: col.type === "integer" || col.type === "int" ? "number" : "string", + }, + })); + columns.push({ id: "edit", cell: EditCell }); + + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + onColumnVisibilityChange: setColumnVisibility, + initialState: { + pagination: { + pageSize: 15, + }, + }, + state: { + columnVisibility, + }, + meta: { + editedRows, + setEditedRows, + revertData: (rowIndex: number, revert: boolean) => { + if (revert) { + setData((old) => + old.map((row, index) => + index === rowIndex ? originalData[rowIndex] : row, + ), + ); + } else { + setOriginalData((old) => + old.map((row, index) => + index === rowIndex ? data[rowIndex] : row, + ), + ); + } + }, + updateData: ( + rowIndex: number, + columnId: string, + value: string | number, + ) => { + setData((old) => + old.map((row, index) => { + if (index === rowIndex) { + return { + ...old[rowIndex], + [columnId]: value, + }; + } + return row; + }), + ); + }, + }, + }); + + return ( + <> + +
{JSON.stringify(data, null, "\t")}
+ + ); +} diff --git a/packages/web/components/table.tsx b/packages/web/components/table.tsx index cfed46a3..1f222c85 100644 --- a/packages/web/components/table.tsx +++ b/packages/web/components/table.tsx @@ -1,6 +1,12 @@ -import { Database, type Schema, helpers, type Result } from "@tableland/sdk"; +import { + Database, + type Schema, + helpers, + type Result, + Validator, + type Table as TblTable, +} from "@tableland/sdk"; import { type schema } from "@tableland/studio-store"; -import { type ColumnDef } from "@tanstack/react-table"; import { Blocks, Coins, @@ -12,7 +18,6 @@ import { } from "lucide-react"; import Link from "next/link"; import { type RouterOutputs } from "@tableland/studio-api"; -import { DataTable } from "./data-table"; import { MetricCard, MetricCardContent, @@ -26,10 +31,11 @@ import HashDisplay from "./hash-display"; import { CardContent } from "./ui/card"; import ProjectsReferencingTable from "./projects-referencing-table"; import { Alert, AlertDescription, AlertTitle } from "./ui/alert"; +import { TableData } from "./table-data"; import { blockExplorers } from "@/lib/block-explorers"; import { openSeaLinks } from "@/lib/open-sea"; import { chainsMap } from "@/lib/chains-map"; -import { cn, objectToTableData } from "@/lib/utils"; +import { cn } from "@/lib/utils"; import { TimeSince } from "@/components/time"; import { api } from "@/trpc/server"; import DefDetails from "@/components/def-details"; @@ -76,25 +82,19 @@ export default async function Table({ const blockExplorer = blockExplorers.get(chainId); const openSeaLink = openSeaLinks.get(chainId); + let table: TblTable | undefined; let data: Result> | undefined; let error: Error | undefined; try { const baseUrl = helpers.getBaseUrl(chainId); + const validator = new Validator({ baseUrl }); + table = await validator.getTableById({ chainId, tableId }); const tbl = new Database({ baseUrl }); data = await tbl.prepare(`SELECT * FROM ${tableName};`).all(); } catch (err) { error = ensureError(err); } - const formattedData = data ? objectToTableData(data.results) : undefined; - const columns: Array> | undefined = data - ? data.results.length - ? Object.keys(data.results[0] as object).map((col) => ({ - accessorKey: col, - header: col, - })) - : [] - : undefined; const deploymentReferences = ( await api.deployments.deploymentReferences({ chainId, tableId }) ).filter((p) => p.environment.id !== environment?.id); @@ -231,7 +231,7 @@ export default async function Table({ - {data && formattedData && columns ? ( + {data && table ? ( <> Table Data SQL Logs @@ -243,9 +243,12 @@ export default async function Table({ )} - {formattedData && columns && ( + {data && table && ( - + )} {data && ( diff --git a/packages/web/lib/utils.ts b/packages/web/lib/utils.ts index c822c22f..efb33a4e 100644 --- a/packages/web/lib/utils.ts +++ b/packages/web/lib/utils.ts @@ -14,8 +14,8 @@ const arrSchema = z.array(z.any()); // accepts an Array with any entries that are any kind of Object and goes // through each Object's keys to ensure that values containing an Object or // Array are `stringify`ed. This enables showing nested data in an html table -// without the dreded "[object Object]" -export function objectToTableData(data: any[]) { +// without the dreaded "[object Object]" +export function objectToTableData(data: Array>) { data = arrSchema.parse(data); return data.map(function (d) { @@ -32,7 +32,7 @@ export function objectToTableData(data: any[]) { return [key, val]; }), - ) as TData; + ); }); } diff --git a/packages/web/tsconfig.json b/packages/web/tsconfig.json index c03b4c1c..25c37e75 100644 --- a/packages/web/tsconfig.json +++ b/packages/web/tsconfig.json @@ -25,6 +25,12 @@ }, "noErrorTruncation": true }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "include": [ + "next-env.d.ts", + "types.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts" + ], "exclude": ["node_modules"] } diff --git a/packages/web/types.d.ts b/packages/web/types.d.ts new file mode 100644 index 00000000..6a516d8e --- /dev/null +++ b/packages/web/types.d.ts @@ -0,0 +1,19 @@ +import "@tanstack/table-core"; + +declare module "@tanstack/table-core" { + interface TableMeta { + revertData: (rowIndex: number, revert: boolean) => void; + updateData: ( + rowIndex: number, + columnId: string, + value: string | number, + ) => void; + editedRows: Record; + setEditedRows: React.Dispatch< + React.SetStateAction> + >; + } + interface ColumnMeta { + type: "number" | "string"; + } +} From 550cae1de222e35d629b8cebb93afe9775293969 Mon Sep 17 00:00:00 2001 From: Aaron Sutula Date: Thu, 20 Jun 2024 10:15:30 -0600 Subject: [PATCH 02/25] wip on add row Signed-off-by: Aaron Sutula --- packages/web/components/table-data.tsx | 11 +++++++++++ packages/web/types.d.ts | 1 + 2 files changed, 12 insertions(+) diff --git a/packages/web/components/table-data.tsx b/packages/web/components/table-data.tsx index e102f0c1..ee11155f 100644 --- a/packages/web/components/table-data.tsx +++ b/packages/web/components/table-data.tsx @@ -13,6 +13,7 @@ import { type Schema } from "@tableland/sdk"; import { DataTable } from "./data-table"; import TableCell from "./table-cell"; import { EditCell } from "./edit-cell"; +import { Button } from "./ui/button"; import { objectToTableData } from "@/lib/utils"; interface TableDataProps { @@ -95,12 +96,22 @@ export function TableData({ }), ); }, + addRow: () => { + const newRow: Record = {}; + const setFunc = (old: Array>) => [ + ...old, + newRow, + ]; + setData(setFunc); + setOriginalData(setFunc); + }, }, }); return ( <> +
{JSON.stringify(data, null, "\t")}
); diff --git a/packages/web/types.d.ts b/packages/web/types.d.ts index 6a516d8e..00fddacd 100644 --- a/packages/web/types.d.ts +++ b/packages/web/types.d.ts @@ -8,6 +8,7 @@ declare module "@tanstack/table-core" { columnId: string, value: string | number, ) => void; + addRow: () => void; editedRows: Record; setEditedRows: React.Dispatch< React.SetStateAction> From f735a17cc096be6309fff99cd7b86635f771b57d Mon Sep 17 00:00:00 2001 From: Aaron Sutula Date: Thu, 20 Jun 2024 15:04:05 -0600 Subject: [PATCH 03/25] add and remove row(s) Signed-off-by: Aaron Sutula --- packages/web/components/edit-cell.tsx | 44 ++++++++++++++++++-------- packages/web/components/table-data.tsx | 28 ++++++++++++++++ packages/web/components/table.tsx | 3 +- packages/web/types.d.ts | 2 ++ 4 files changed, 63 insertions(+), 14 deletions(-) diff --git a/packages/web/components/edit-cell.tsx b/packages/web/components/edit-cell.tsx index 7a9f799d..60f59d43 100644 --- a/packages/web/components/edit-cell.tsx +++ b/packages/web/components/edit-cell.tsx @@ -1,5 +1,6 @@ import { type Cell } from "@tanstack/react-table"; import { type MouseEvent } from "react"; +import { Checkbox } from "./ui/checkbox"; export function EditCell({ row, @@ -18,18 +19,35 @@ export function EditCell({ } }; - return meta?.editedRows[row.id] ? ( - <> - {" "} - - - ) : ( - + const removeRow = () => { + meta?.removeRow(row.index); + }; + + return ( +
+ {meta?.editedRows[row.id] ? ( + <> + {" "} + + + ) : ( + <> + + + + )} + +
); } diff --git a/packages/web/components/table-data.tsx b/packages/web/components/table-data.tsx index ee11155f..238b5ec4 100644 --- a/packages/web/components/table-data.tsx +++ b/packages/web/components/table-data.tsx @@ -53,6 +53,7 @@ export function TableData({ getCoreRowModel: getCoreRowModel(), getPaginationRowModel: getPaginationRowModel(), onColumnVisibilityChange: setColumnVisibility, + enableRowSelection: true, initialState: { pagination: { pageSize: 15, @@ -105,13 +106,40 @@ export function TableData({ setData(setFunc); setOriginalData(setFunc); }, + removeRow: (rowIndex: number) => { + const setFilterFunc = (old: Array>) => + old.filter( + (_row: Record, index: number) => + index !== rowIndex, + ); + setData(setFilterFunc); + setOriginalData(setFilterFunc); + }, + removeSelectedRows: (selectedRows: number[]) => { + const setFilterFunc = (old: Array>) => + old.filter((_row, index) => !selectedRows.includes(index)); + setData(setFilterFunc); + setOriginalData(setFilterFunc); + }, }, }); + const removeRows = () => { + table.options.meta?.removeSelectedRows( + table.getSelectedRowModel().rows.map((row) => row.index), + ); + table.resetRowSelection(); + }; + + const selectedRows = table.getSelectedRowModel().rows; + return ( <> + {selectedRows.length > 0 && ( + + )}
{JSON.stringify(data, null, "\t")}
); diff --git a/packages/web/components/table.tsx b/packages/web/components/table.tsx index 1f222c85..ad494b72 100644 --- a/packages/web/components/table.tsx +++ b/packages/web/components/table.tsx @@ -6,7 +6,7 @@ import { Validator, type Table as TblTable, } from "@tableland/sdk"; -import { type schema } from "@tableland/studio-store"; +import { unescapeSchema, type schema } from "@tableland/studio-store"; import { Blocks, Coins, @@ -89,6 +89,7 @@ export default async function Table({ const baseUrl = helpers.getBaseUrl(chainId); const validator = new Validator({ baseUrl }); table = await validator.getTableById({ chainId, tableId }); + table.schema = unescapeSchema(table.schema); const tbl = new Database({ baseUrl }); data = await tbl.prepare(`SELECT * FROM ${tableName};`).all(); } catch (err) { diff --git a/packages/web/types.d.ts b/packages/web/types.d.ts index 00fddacd..904aa2f8 100644 --- a/packages/web/types.d.ts +++ b/packages/web/types.d.ts @@ -9,6 +9,8 @@ declare module "@tanstack/table-core" { value: string | number, ) => void; addRow: () => void; + removeRow: (rowIndex: number) => void; + removeSelectedRows: (selectedRows: number[]) => void; editedRows: Record; setEditedRows: React.Dispatch< React.SetStateAction> From 383fb6b6e5208a988fef6e84a283ba1862048992 Mon Sep 17 00:00:00 2001 From: Aaron Sutula Date: Tue, 2 Jul 2024 14:17:04 -0600 Subject: [PATCH 04/25] working editing in react state Signed-off-by: Aaron Sutula --- package-lock.json | 6 + packages/web/components/data-table.tsx | 38 +-- packages/web/components/edit-cell.tsx | 79 +++--- packages/web/components/table-cell.tsx | 8 +- packages/web/components/table-data-types.ts | 19 ++ packages/web/components/table-data.tsx | 279 +++++++++++++++----- packages/web/package.json | 1 + packages/web/types.d.ts | 16 +- 8 files changed, 289 insertions(+), 157 deletions(-) create mode 100644 packages/web/components/table-data-types.ts diff --git a/package-lock.json b/package-lock.json index c2629c0c..442cac20 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9394,6 +9394,11 @@ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, + "node_modules/deep-object-diff": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/deep-object-diff/-/deep-object-diff-1.1.9.tgz", + "integrity": "sha512-Rn+RuwkmkDwCi2/oXOFS9Gsr5lJZu/yTGpK7wAaAIE75CC+LCGEZHpY6VQJa/RoJcrmaA/docWJZvYohlNkWPA==" + }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", @@ -24827,6 +24832,7 @@ "class-variance-authority": "^0.7.0", "clsx": "^2.0.0", "cmdk": "^0.2.0", + "deep-object-diff": "^1.1.9", "ethers": "^6.12.1", "javascript-time-ago": "^2.5.9", "jotai": "^2.1.1", diff --git a/packages/web/components/data-table.tsx b/packages/web/components/data-table.tsx index 89716fd0..834f4e60 100644 --- a/packages/web/components/data-table.tsx +++ b/packages/web/components/data-table.tsx @@ -3,15 +3,8 @@ import { flexRender, type Table as TSTable, } from "@tanstack/react-table"; -import { ChevronDown } from "lucide-react"; import React from "react"; import { Button } from "@/components/ui/button"; -import { - DropdownMenu, - DropdownMenuCheckboxItem, - DropdownMenuContent, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; import { Table, TableBody, @@ -34,36 +27,6 @@ export function DataTable({ }: DataTableProps) { return (
-
- {!!data.length && ( - - - - - - {table - .getAllColumns() - .filter((column) => column.getCanHide()) - .map((column) => { - return ( - - column.toggleVisibility(!!value) - } - > - {column.id} - - ); - })} - - - )} -
@@ -90,6 +53,7 @@ export function DataTable({ {row.getVisibleCells().map((cell) => ( diff --git a/packages/web/components/edit-cell.tsx b/packages/web/components/edit-cell.tsx index 60f59d43..a8124249 100644 --- a/packages/web/components/edit-cell.tsx +++ b/packages/web/components/edit-cell.tsx @@ -1,53 +1,58 @@ import { type Cell } from "@tanstack/react-table"; -import { type MouseEvent } from "react"; -import { Checkbox } from "./ui/checkbox"; +import { Undo2, Trash2, Pencil } from "lucide-react"; +import { type TableRow } from "./table-data-types"; +import { Button } from "./ui/button"; export function EditCell({ row, table, -}: ReturnType, unknown>["getContext"]>) { +}: ReturnType["getContext"]>) { const meta = table.options.meta; - const setEditedRows = (e: MouseEvent) => { - const elName = e.currentTarget.name; - meta?.setEditedRows((old) => ({ - ...old, - [row.id]: !old[row.id], - })); - if (elName !== "edit") { - meta?.revertData(row.index, e.currentTarget.name === "cancel"); - } + const editRow = () => { + meta?.editRow(row); }; - const removeRow = () => { - meta?.removeRow(row.index); + const revertRow = () => { + meta?.revertRow(row); }; + const deleteRow = () => { + meta?.deleteRow(row); + }; + + const type = row.original.type; + return ( -
- {meta?.editedRows[row.id] ? ( - <> - {" "} - - - ) : ( - <> - - - +
+ {/* Edit */} + {type === "existing" && ( + + )} + {/* Revert */} + {(type === "edited" || type === "deleted") && ( + + )} + {/* Delete */} + {(type === "existing" || type === "edited" || type === "new") && ( + )} -
); } diff --git a/packages/web/components/table-cell.tsx b/packages/web/components/table-cell.tsx index ff0ab22e..1c43f1b9 100644 --- a/packages/web/components/table-cell.tsx +++ b/packages/web/components/table-cell.tsx @@ -1,16 +1,16 @@ import { useEffect, useState } from "react"; import { type Cell } from "@tanstack/react-table"; import { Input } from "./ui/input"; +import { type TableRow } from "./table-data-types"; export default function TableCell({ getValue, row, column, table, -}: ReturnType, unknown>["getContext"]>) { +}: ReturnType["getContext"]>) { const initialValue = getValue(); const columnMeta = column.columnDef.meta; - const tableMeta = table.options.meta; const [value, setValue] = useState(isValue(initialValue) ? initialValue : ""); @@ -19,10 +19,10 @@ export default function TableCell({ }, [initialValue]); const onBlur = () => { - table.options.meta?.updateData(row.index, column.id, value); + table.options.meta?.updateData(row, column.id, value); }; - if (tableMeta?.editedRows[row.id]) { + if (row.original.type === "edited" || row.original.type === "new") { return ( ; + +export type ExistingRow = { + type: "existing"; +} & Record; + +export type EditedRow = { + type: "edited"; + originalData: ExistingRow; +} & Record; + +export type DeletedRow = { + type: "deleted"; + originalData: ExistingRow | EditedRow; +} & Record; + +export type TableRow = NewRow | ExistingRow | EditedRow | DeletedRow; diff --git a/packages/web/components/table-data.tsx b/packages/web/components/table-data.tsx index 238b5ec4..444e5eaa 100644 --- a/packages/web/components/table-data.tsx +++ b/packages/web/components/table-data.tsx @@ -7,17 +7,45 @@ import { getCoreRowModel, getPaginationRowModel, useReactTable, + type Row, } from "@tanstack/react-table"; -import React, { useState } from "react"; +import React, { useMemo, useState } from "react"; import { type Schema } from "@tableland/sdk"; +// import { +// diff, +// addedDiff, +// deletedDiff, +// updatedDiff, +// detailedDiff, +// } from "deep-object-diff"; +import { hasConstraint } from "@tableland/studio-store"; +import { ChevronDown } from "lucide-react"; import { DataTable } from "./data-table"; import TableCell from "./table-cell"; import { EditCell } from "./edit-cell"; import { Button } from "./ui/button"; +import { + type TableRow, + type ExistingRow, + type NewRow, + type EditedRow, + type DeletedRow, +} from "./table-data-types"; import { objectToTableData } from "@/lib/utils"; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; + +interface Updates { + new: NewRow[]; + edited: EditedRow[]; + deleted: DeletedRow[]; +} interface TableDataProps { - // columns: Array>>; columns: Schema["columns"]; initialData: Array>; } @@ -26,17 +54,49 @@ export function TableData({ columns: sdkColumns, initialData, }: TableDataProps) { - const [data, setData] = useState(objectToTableData(initialData)); - const [originalData, setOriginalData] = useState(() => [...initialData]); - const [editedRows, setEditedRows] = useState>({}); + initialData = objectToTableData(initialData); + + const initialRows: ExistingRow[] = initialData.map((row) => ({ + type: "existing", + ...row, + })); + + const [data, setData] = useState(() => [...initialRows]); + + const updates = useMemo(() => { + const res = data.reduce( + (acc, row) => { + if (row.type === "new") { + acc.new.push(row); + } else if (row.type === "edited") { + acc.edited.push(row); + } else if (row.type === "deleted") { + acc.deleted.push(row); + } + return acc; + }, + { + new: [], + edited: [], + deleted: [], + }, + ); + return res; + }, [data]); + + const pkName = sdkColumns.find( + (col) => + hasConstraint(col, "primary key") || + hasConstraint(col, "primary key autoincrement"), + )?.name; const [columnVisibility, setColumnVisibility] = useState({}); + const editing = + !!updates.new.length || !!updates.edited.length || !!updates.deleted.length; + const columns: - | Array< - | ColumnDef> - | DisplayColumnDef> - > + | Array | DisplayColumnDef> | undefined = sdkColumns.map((col) => ({ accessorKey: col.name, header: col.name, @@ -45,7 +105,9 @@ export function TableData({ type: col.type === "integer" || col.type === "int" ? "number" : "string", }, })); - columns.push({ id: "edit", cell: EditCell }); + if (pkName) { + columns.push({ id: "edit", cell: EditCell }); + } const table = useReactTable({ data, @@ -53,7 +115,6 @@ export function TableData({ getCoreRowModel: getCoreRowModel(), getPaginationRowModel: getPaginationRowModel(), onColumnVisibilityChange: setColumnVisibility, - enableRowSelection: true, initialState: { pagination: { pageSize: 15, @@ -63,84 +124,160 @@ export function TableData({ columnVisibility, }, meta: { - editedRows, - setEditedRows, - revertData: (rowIndex: number, revert: boolean) => { - if (revert) { - setData((old) => - old.map((row, index) => - index === rowIndex ? originalData[rowIndex] : row, - ), - ); - } else { - setOriginalData((old) => - old.map((row, index) => - index === rowIndex ? data[rowIndex] : row, - ), - ); + pkName, + editRow: (rowToEdit: Row) => { + const tableRow = rowToEdit.original; + switch (tableRow.type) { + case "existing": + setData((old) => + old.map((row, index) => + index === rowToEdit.index + ? { + ...row, + type: "edited", + originalData: tableRow, + } + : row, + ), + ); + break; + } + }, + revertAll: () => { + setData(() => [...initialRows]); + }, + revertRow: (rowToRevert: Row) => { + const tableRow = rowToRevert.original; + switch (tableRow.type) { + case "edited": + setData((old) => + old.map((row, index) => + index === rowToRevert.index ? tableRow.originalData : row, + ), + ); + break; + case "new": + setData((old) => + old.filter((_, index) => index !== rowToRevert.index), + ); + break; + case "deleted": + setData((old) => + old.map((row, index) => + index === rowToRevert.index ? tableRow.originalData : row, + ), + ); + break; } }, updateData: ( - rowIndex: number, + rowToUpdate: Row, columnId: string, value: string | number, ) => { - setData((old) => - old.map((row, index) => { - if (index === rowIndex) { - return { - ...old[rowIndex], - [columnId]: value, - }; - } - return row; - }), - ); + const tableRow = rowToUpdate.original; + switch (tableRow.type) { + case "edited": + case "new": + setData((old) => + old.map((row, index) => { + if (rowToUpdate.index === index) { + return { + ...old[index], + [columnId]: value, + }; + } + return row; + }), + ); + break; + } }, addRow: () => { - const newRow: Record = {}; - const setFunc = (old: Array>) => [ - ...old, - newRow, - ]; - setData(setFunc); - setOriginalData(setFunc); + setData((old) => [{ type: "new" }, ...old]); }, - removeRow: (rowIndex: number) => { - const setFilterFunc = (old: Array>) => - old.filter( - (_row: Record, index: number) => - index !== rowIndex, - ); - setData(setFilterFunc); - setOriginalData(setFilterFunc); + deleteRow: (rowToDelete: Row) => { + const tableRow = rowToDelete.original; + switch (tableRow.type) { + case "existing": + case "edited": + setData((old) => + old.map((row, index) => + index === rowToDelete.index + ? { ...row, type: "deleted", originalData: tableRow } + : row, + ), + ); + break; + case "new": + setData((old) => + old.filter((_, index) => index !== rowToDelete.index), + ); + break; + } }, - removeSelectedRows: (selectedRows: number[]) => { - const setFilterFunc = (old: Array>) => - old.filter((_row, index) => !selectedRows.includes(index)); - setData(setFilterFunc); - setOriginalData(setFilterFunc); + getRowClassName: (row) => { + return row.original.type === "deleted" + ? "bg-destructive text-destructive-foreground" + : ""; }, }, }); - const removeRows = () => { - table.options.meta?.removeSelectedRows( - table.getSelectedRowModel().rows.map((row) => row.index), - ); - table.resetRowSelection(); - }; - - const selectedRows = table.getSelectedRowModel().rows; - return ( <> +
+
+ {editing && ( + <> + + + + )} + +
+ {!!columns.length && ( + + + + + + {table + .getAllColumns() + .filter((column) => column.getCanHide()) + .map((column) => { + return ( + + column.toggleVisibility(!!value) + } + > + {column.id} + + ); + })} + + + )} +
- {selectedRows.length > 0 && ( - - )} -
{JSON.stringify(data, null, "\t")}
+
{JSON.stringify(updates, null, "\t")}
); } diff --git a/packages/web/package.json b/packages/web/package.json index bc32e261..5fd2d674 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -51,6 +51,7 @@ "class-variance-authority": "^0.7.0", "clsx": "^2.0.0", "cmdk": "^0.2.0", + "deep-object-diff": "^1.1.9", "ethers": "^6.12.1", "javascript-time-ago": "^2.5.9", "jotai": "^2.1.1", diff --git a/packages/web/types.d.ts b/packages/web/types.d.ts index 904aa2f8..3b31a357 100644 --- a/packages/web/types.d.ts +++ b/packages/web/types.d.ts @@ -1,20 +1,20 @@ import "@tanstack/table-core"; +import { Row, RowData } from "@tanstack/react-table"; declare module "@tanstack/table-core" { interface TableMeta { - revertData: (rowIndex: number, revert: boolean) => void; + getRowClassName: (row: Row) => string; + pkName: string | undefined; + editRow: (row: Row) => void; + revertAll: () => void; + revertRow: (row: Row) => void; updateData: ( - rowIndex: number, + row: Row, columnId: string, value: string | number, ) => void; addRow: () => void; - removeRow: (rowIndex: number) => void; - removeSelectedRows: (selectedRows: number[]) => void; - editedRows: Record; - setEditedRows: React.Dispatch< - React.SetStateAction> - >; + deleteRow: (row: Row) => void; } interface ColumnMeta { type: "number" | "string"; From 6797b3d04162a95ecae62fb9b765c2691432729e Mon Sep 17 00:00:00 2001 From: Aaron Sutula Date: Wed, 3 Jul 2024 09:36:25 -0600 Subject: [PATCH 05/25] handle deleting of edited row explicitly Signed-off-by: Aaron Sutula --- packages/web/components/table-data.tsx | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/web/components/table-data.tsx b/packages/web/components/table-data.tsx index 444e5eaa..1a892201 100644 --- a/packages/web/components/table-data.tsx +++ b/packages/web/components/table-data.tsx @@ -200,7 +200,6 @@ export function TableData({ const tableRow = rowToDelete.original; switch (tableRow.type) { case "existing": - case "edited": setData((old) => old.map((row, index) => index === rowToDelete.index @@ -209,6 +208,19 @@ export function TableData({ ), ); break; + case "edited": + setData((old) => + old.map((row, index) => + index === rowToDelete.index + ? { + ...row, + type: "deleted", + originalData: tableRow.originalData, + } + : row, + ), + ); + break; case "new": setData((old) => old.filter((_, index) => index !== rowToDelete.index), From 6648adc521b01ffafb1e2476c2fe90bcc911433f Mon Sep 17 00:00:00 2001 From: Aaron Sutula Date: Wed, 3 Jul 2024 09:45:41 -0600 Subject: [PATCH 06/25] better color for deleted row Signed-off-by: Aaron Sutula --- packages/web/components/table-data.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web/components/table-data.tsx b/packages/web/components/table-data.tsx index 1a892201..ec88cc03 100644 --- a/packages/web/components/table-data.tsx +++ b/packages/web/components/table-data.tsx @@ -230,7 +230,7 @@ export function TableData({ }, getRowClassName: (row) => { return row.original.type === "deleted" - ? "bg-destructive text-destructive-foreground" + ? "bg-destructive/50 text-destructive-foreground hover:bg-destructive/70" : ""; }, }, From 4f76ffd15ba95b94d699ab16acaaabc098132fb7 Mon Sep 17 00:00:00 2001 From: Aaron Sutula Date: Thu, 4 Jul 2024 10:44:37 -0600 Subject: [PATCH 07/25] construct and execute sql statements working Signed-off-by: Aaron Sutula --- packages/web/components/edit-cell.tsx | 4 +- packages/web/components/table-cell.tsx | 15 +- packages/web/components/table-data-types.ts | 31 ++- packages/web/components/table-data.tsx | 262 ++++++++++++++------ packages/web/components/table.tsx | 5 +- packages/web/types.d.ts | 9 +- 6 files changed, 221 insertions(+), 105 deletions(-) diff --git a/packages/web/components/edit-cell.tsx b/packages/web/components/edit-cell.tsx index a8124249..44ecb556 100644 --- a/packages/web/components/edit-cell.tsx +++ b/packages/web/components/edit-cell.tsx @@ -1,12 +1,12 @@ import { type Cell } from "@tanstack/react-table"; import { Undo2, Trash2, Pencil } from "lucide-react"; -import { type TableRow } from "./table-data-types"; +import { type TableRowData } from "./table-data-types"; import { Button } from "./ui/button"; export function EditCell({ row, table, -}: ReturnType["getContext"]>) { +}: ReturnType["getContext"]>) { const meta = table.options.meta; const editRow = () => { diff --git a/packages/web/components/table-cell.tsx b/packages/web/components/table-cell.tsx index 1c43f1b9..cc6296ad 100644 --- a/packages/web/components/table-cell.tsx +++ b/packages/web/components/table-cell.tsx @@ -1,14 +1,14 @@ import { useEffect, useState } from "react"; import { type Cell } from "@tanstack/react-table"; import { Input } from "./ui/input"; -import { type TableRow } from "./table-data-types"; +import { type TableRowData } from "./table-data-types"; export default function TableCell({ getValue, row, column, table, -}: ReturnType["getContext"]>) { +}: ReturnType["getContext"]>) { const initialValue = getValue(); const columnMeta = column.columnDef.meta; @@ -19,7 +19,8 @@ export default function TableCell({ }, [initialValue]); const onBlur = () => { - table.options.meta?.updateData(row, column.id, value); + if (!columnMeta) return; + table.options.meta?.updateRowColumn(row, columnMeta.columnName, value); }; if (row.original.type === "edited" || row.original.type === "new") { @@ -27,7 +28,13 @@ export default function TableCell({ setValue(e.target.value)} + onChange={(e) => + setValue( + columnMeta?.type === "number" + ? e.target.valueAsNumber + : e.target.value, + ) + } onBlur={onBlur} /> ); diff --git a/packages/web/components/table-data-types.ts b/packages/web/components/table-data-types.ts index e4dbff0f..d3a61c82 100644 --- a/packages/web/components/table-data-types.ts +++ b/packages/web/components/table-data-types.ts @@ -1,19 +1,28 @@ -export type NewRow = { +export interface NewRowData { type: "new"; -} & Record; + data: Record; +} -export type ExistingRow = { +export interface ExistingRowData { type: "existing"; -} & Record; + data: Record; +} -export type EditedRow = { +export interface EditedRowData { type: "edited"; - originalData: ExistingRow; -} & Record; + data: Record; + originalData: ExistingRowData; + diff?: object; +} -export type DeletedRow = { +export interface DeletedRowData { type: "deleted"; - originalData: ExistingRow | EditedRow; -} & Record; + data: Record; + originalData: ExistingRowData | EditedRowData; +} -export type TableRow = NewRow | ExistingRow | EditedRow | DeletedRow; +export type TableRowData = + | NewRowData + | ExistingRowData + | EditedRowData + | DeletedRowData; diff --git a/packages/web/components/table-data.tsx b/packages/web/components/table-data.tsx index ec88cc03..eb4b4488 100644 --- a/packages/web/components/table-data.tsx +++ b/packages/web/components/table-data.tsx @@ -1,5 +1,9 @@ "use client"; +import { Database, type Table } from "@tableland/sdk"; +import { drizzle } from "drizzle-orm/d1"; +import { integer, int, sqliteTable, text, blob } from "drizzle-orm/sqlite-core"; +import { eq } from "drizzle-orm/expressions"; import { type ColumnDef, type DisplayColumnDef, @@ -10,26 +14,20 @@ import { type Row, } from "@tanstack/react-table"; import React, { useMemo, useState } from "react"; -import { type Schema } from "@tableland/sdk"; -// import { -// diff, -// addedDiff, -// deletedDiff, -// updatedDiff, -// detailedDiff, -// } from "deep-object-diff"; +import { updatedDiff } from "deep-object-diff"; import { hasConstraint } from "@tableland/studio-store"; import { ChevronDown } from "lucide-react"; +import { useRouter } from "next/navigation"; import { DataTable } from "./data-table"; import TableCell from "./table-cell"; import { EditCell } from "./edit-cell"; import { Button } from "./ui/button"; import { - type TableRow, - type ExistingRow, - type NewRow, - type EditedRow, - type DeletedRow, + type TableRowData, + type ExistingRowData, + type NewRowData, + type EditedRowData, + type DeletedRowData, } from "./table-data-types"; import { objectToTableData } from "@/lib/utils"; import { @@ -39,29 +37,75 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +type NonEmptyArray = [T, ...T[]]; + interface Updates { - new: NewRow[]; - edited: EditedRow[]; - deleted: DeletedRow[]; + new: NewRowData[]; + edited: EditedRowData[]; + deleted: DeletedRowData[]; } interface TableDataProps { - columns: Schema["columns"]; + table: Table; initialData: Array>; } -export function TableData({ - columns: sdkColumns, - initialData, -}: TableDataProps) { +const tbl = new Database({ + autoWait: true, +}); + +export function TableData({ table: tblTable, initialData }: TableDataProps) { + const router = useRouter(); + + const pkName = tblTable.schema.columns.find( + (col) => + hasConstraint(col, "primary key") || + hasConstraint(col, "primary key autoincrement"), + )?.name; + + const tableSchema = useMemo( + () => + tblTable.schema.columns.reduce>((acc, col) => { + if (col.type === "text") { + acc[col.name] = text(col.name); + } else if (col.type === "integer") { + acc[col.name] = integer(col.name); + } else if (col.type === "int") { + acc[col.name] = int(col.name); + } else if (col.type === "blob") { + acc[col.name] = blob(col.name); + } + return acc; + }, {}), + [tblTable], + ); + const drizzleTable = useMemo( + () => sqliteTable(tblTable.name, tableSchema), + [tblTable, tableSchema], + ); + const db = useMemo( + () => drizzle(tbl, { schema: drizzleTable, logger: false }), + [drizzleTable], + ); + + // const { params: ps, sql } = db + // .update(drizzleTable) + // .set({ table_name: "foo", chain_id: "1" }) + // .where(eq(drizzleTable.table_id, "abcdef")) + // .toSQL(); + + // console.log("SQL HERE:", sql); + + // console.log("bound:", tbl.prepare(sql).bind(ps).toString()); + initialData = objectToTableData(initialData); - const initialRows: ExistingRow[] = initialData.map((row) => ({ + const initialRows: ExistingRowData[] = initialData.map((row) => ({ type: "existing", - ...row, + data: { ...row }, })); - const [data, setData] = useState(() => [...initialRows]); + const [data, setData] = useState(() => [...initialRows]); const updates = useMemo(() => { const res = data.reduce( @@ -84,24 +128,19 @@ export function TableData({ return res; }, [data]); - const pkName = sdkColumns.find( - (col) => - hasConstraint(col, "primary key") || - hasConstraint(col, "primary key autoincrement"), - )?.name; - const [columnVisibility, setColumnVisibility] = useState({}); const editing = !!updates.new.length || !!updates.edited.length || !!updates.deleted.length; const columns: - | Array | DisplayColumnDef> - | undefined = sdkColumns.map((col) => ({ - accessorKey: col.name, + | Array | DisplayColumnDef> + | undefined = tblTable.schema.columns.map((col) => ({ + accessorKey: `data.${col.name}`, header: col.name, cell: TableCell, meta: { + columnName: col.name, type: col.type === "integer" || col.type === "int" ? "number" : "string", }, })); @@ -125,17 +164,17 @@ export function TableData({ }, meta: { pkName, - editRow: (rowToEdit: Row) => { - const tableRow = rowToEdit.original; - switch (tableRow.type) { + editRow: (rowToEdit: Row) => { + const tableRowData = rowToEdit.original; + switch (tableRowData.type) { case "existing": setData((old) => old.map((row, index) => index === rowToEdit.index ? { - ...row, type: "edited", - originalData: tableRow, + data: { ...row.data }, + originalData: tableRowData, } : row, ), @@ -143,48 +182,46 @@ export function TableData({ break; } }, - revertAll: () => { - setData(() => [...initialRows]); - }, - revertRow: (rowToRevert: Row) => { - const tableRow = rowToRevert.original; - switch (tableRow.type) { + updateRowColumn: ( + rowToUpdate: Row, + columnName: string, + value: string | number, + ) => { + const tableRowData = rowToUpdate.original; + switch (tableRowData.type) { case "edited": setData((old) => - old.map((row, index) => - index === rowToRevert.index ? tableRow.originalData : row, - ), - ); - break; - case "new": - setData((old) => - old.filter((_, index) => index !== rowToRevert.index), - ); - break; - case "deleted": - setData((old) => - old.map((row, index) => - index === rowToRevert.index ? tableRow.originalData : row, - ), + old.map((row, index) => { + if (rowToUpdate.index === index) { + const data = { + ...tableRowData.data, + [columnName]: value, + }; + const diff = updatedDiff( + tableRowData.originalData.data, + data, + ); + console.log("DIFF", diff); + return { + ...tableRowData, + data, + diff: Object.keys(diff).length ? diff : undefined, + }; + } + return row; + }), ); break; - } - }, - updateData: ( - rowToUpdate: Row, - columnId: string, - value: string | number, - ) => { - const tableRow = rowToUpdate.original; - switch (tableRow.type) { - case "edited": case "new": setData((old) => old.map((row, index) => { if (rowToUpdate.index === index) { return { - ...old[index], - [columnId]: value, + ...tableRowData, + data: { + ...tableRowData.data, + [columnName]: value, + }, }; } return row; @@ -194,16 +231,20 @@ export function TableData({ } }, addRow: () => { - setData((old) => [{ type: "new" }, ...old]); + setData((old) => [{ type: "new", data: {} }, ...old]); }, - deleteRow: (rowToDelete: Row) => { - const tableRow = rowToDelete.original; - switch (tableRow.type) { + deleteRow: (rowToDelete: Row) => { + const tableRowData = rowToDelete.original; + switch (tableRowData.type) { case "existing": setData((old) => old.map((row, index) => index === rowToDelete.index - ? { ...row, type: "deleted", originalData: tableRow } + ? { + data: { ...row.data }, + type: "deleted", + originalData: tableRowData, + } : row, ), ); @@ -213,9 +254,9 @@ export function TableData({ old.map((row, index) => index === rowToDelete.index ? { - ...row, + data: { ...row.data }, type: "deleted", - originalData: tableRow.originalData, + originalData: tableRowData.originalData, } : row, ), @@ -228,6 +269,33 @@ export function TableData({ break; } }, + revertRow: (rowToRevert: Row) => { + const tableRowData = rowToRevert.original; + switch (tableRowData.type) { + case "edited": + setData((old) => + old.map((row, index) => + index === rowToRevert.index ? tableRowData.originalData : row, + ), + ); + break; + case "new": + setData((old) => + old.filter((_, index) => index !== rowToRevert.index), + ); + break; + case "deleted": + setData((old) => + old.map((row, index) => + index === rowToRevert.index ? tableRowData.originalData : row, + ), + ); + break; + } + }, + revertAll: () => { + setData(() => [...initialRows]); + }, getRowClassName: (row) => { return row.original.type === "deleted" ? "bg-destructive/50 text-destructive-foreground hover:bg-destructive/70" @@ -236,13 +304,48 @@ export function TableData({ }, }); + const executeStatements = () => { + const sqlInsertItems = updates.new.map((row) => { + return db.insert(drizzleTable).values(row.data); + }); + const sqlUpdateItems = updates.edited + .filter((update) => update.diff) + .map((row) => { + return db + .update(drizzleTable) + .set(row.diff!) + .where(eq(drizzleTable[pkName!], row.originalData.data[pkName!])); + }); + const sqlDeleteItems = updates.deleted.map((row) => { + return db + .delete(drizzleTable) + .where(eq(drizzleTable[pkName!], row.originalData.data[pkName!])); + }); + type Hmm = + | (typeof sqlInsertItems)[number] + | (typeof sqlUpdateItems)[number] + | (typeof sqlDeleteItems)[number]; + const ugh = [ + ...sqlInsertItems, + ...sqlUpdateItems, + ...sqlDeleteItems, + ] as NonEmptyArray; + db.batch(ugh) + .then((res) => { + router.refresh(); + }) + .catch((err) => { + console.log("ERR", err); + }); + }; + return ( <>
{editing && ( <> - +
-
{JSON.stringify(updates, null, "\t")}
); diff --git a/packages/web/components/table.tsx b/packages/web/components/table.tsx index ad494b72..c455d391 100644 --- a/packages/web/components/table.tsx +++ b/packages/web/components/table.tsx @@ -246,10 +246,7 @@ export default async function Table({ {data && table && ( - + )} {data && ( diff --git a/packages/web/types.d.ts b/packages/web/types.d.ts index 3b31a357..87e1bef4 100644 --- a/packages/web/types.d.ts +++ b/packages/web/types.d.ts @@ -6,17 +6,18 @@ declare module "@tanstack/table-core" { getRowClassName: (row: Row) => string; pkName: string | undefined; editRow: (row: Row) => void; - revertAll: () => void; - revertRow: (row: Row) => void; - updateData: ( + updateRowColumn: ( row: Row, - columnId: string, + columnName: string, value: string | number, ) => void; addRow: () => void; deleteRow: (row: Row) => void; + revertRow: (row: Row) => void; + revertAll: () => void; } interface ColumnMeta { + columnName: string; type: "number" | "string"; } } From 4cd70e1b29977d2fccdc2fb5db324c8ac0059b8c Mon Sep 17 00:00:00 2001 From: Aaron Sutula Date: Fri, 5 Jul 2024 12:22:40 -0600 Subject: [PATCH 08/25] display table owner card Signed-off-by: Aaron Sutula --- .../[team]/[project]/[env]/[table]/page.tsx | 13 ++++++ .../web/app/_components/latest-tables.tsx | 11 +++-- packages/web/app/table/[name]/page.tsx | 5 +++ packages/web/components/table.tsx | 41 +++++++++++++------ packages/web/lib/validator-queries.ts | 17 ++++++-- 5 files changed, 68 insertions(+), 19 deletions(-) diff --git a/packages/web/app/[team]/[project]/[env]/[table]/page.tsx b/packages/web/app/[team]/[project]/[env]/[table]/page.tsx index 7605fa84..380ad018 100644 --- a/packages/web/app/[team]/[project]/[env]/[table]/page.tsx +++ b/packages/web/app/[team]/[project]/[env]/[table]/page.tsx @@ -14,6 +14,10 @@ import { } from "@/lib/api-helpers"; import DefDetails from "@/components/def-details"; import TableWrapper from "@/components/table-wrapper"; +import { + getRegistryRecord, + type RegistryRecord, +} from "@/lib/validator-queries"; export default async function Deployments({ params, @@ -37,6 +41,14 @@ export default async function Deployments({ } } + let registryRecord: RegistryRecord | undefined; + if (deployment) { + registryRecord = await cache(getRegistryRecord)( + deployment.chainId, + deployment.tableId, + ); + } + const isAuthorized = await cache(api.teams.isAuthorized)({ teamId: team.id }); return ( @@ -59,6 +71,7 @@ export default async function Deployments({ tableName={deployment.tableName} chainId={deployment.chainId} tableId={deployment.tableId} + owner={registryRecord?.controller} createdAt={new Date(deployment.createdAt)} schema={def.schema} environment={env} diff --git a/packages/web/app/_components/latest-tables.tsx b/packages/web/app/_components/latest-tables.tsx index d88a03eb..0f767554 100644 --- a/packages/web/app/_components/latest-tables.tsx +++ b/packages/web/app/_components/latest-tables.tsx @@ -9,10 +9,15 @@ import ChainSelector from "@/components/chain-selector"; import { TimeSince } from "@/components/time"; import { Label } from "@/components/ui/label"; import { cn } from "@/lib/utils"; -import { type Table, getLatestTables } from "@/lib/validator-queries"; +import { type RegistryRecord, getLatestTables } from "@/lib/validator-queries"; -export function LatestTables({ initialData }: { initialData: Table[] }) { - const [latestTables, setLatestTables] = useState(initialData); +export function LatestTables({ + initialData, +}: { + initialData: RegistryRecord[]; +}) { + const [latestTables, setLatestTables] = + useState(initialData); const [selectedChain, setSelectedChain] = useState< number | "mainnets" | "testnets" >("testnets"); diff --git a/packages/web/app/table/[name]/page.tsx b/packages/web/app/table/[name]/page.tsx index 42145f00..855263d4 100644 --- a/packages/web/app/table/[name]/page.tsx +++ b/packages/web/app/table/[name]/page.tsx @@ -2,9 +2,11 @@ import { type Table as TblTable, Validator, helpers } from "@tableland/sdk"; import { getSession } from "@tableland/studio-api"; import { cookies, headers } from "next/headers"; import { unescapeSchema } from "@tableland/studio-store"; +import { cache } from "react"; import Table from "@/components/table"; import TableWrapper from "@/components/table-wrapper"; import { ensureError } from "@/lib/ensure-error"; +import { getRegistryRecord } from "@/lib/validator-queries"; export default async function TablePage({ params, @@ -51,6 +53,8 @@ export default async function TablePage({ ); } + const registryRecord = await cache(getRegistryRecord)(chainId, tableId); + const schema = unescapeSchema(tablelandTable.schema); const createdAttr = tablelandTable.attributes?.find( @@ -80,6 +84,7 @@ export default async function TablePage({ schema={schema} tableName={params.name} tableId={tableId} + owner={registryRecord.controller} /> diff --git a/packages/web/components/table.tsx b/packages/web/components/table.tsx index c455d391..12488090 100644 --- a/packages/web/components/table.tsx +++ b/packages/web/components/table.tsx @@ -41,18 +41,6 @@ import { api } from "@/trpc/server"; import DefDetails from "@/components/def-details"; import { ensureError } from "@/lib/ensure-error"; -interface Props { - tableName: string; - chainId: number; - tableId: string; - createdAt: Date; - schema: Schema; - environment?: schema.Environment; - defData?: DefData; - deploymentData?: DeploymentData; - isAuthorized?: RouterOutputs["teams"]["isAuthorized"]; -} - interface DefData { id: string; name: string; @@ -67,10 +55,24 @@ interface DeploymentData { txnHash: string | null; } +interface Props { + tableName: string; + chainId: number; + tableId: string; + owner?: string; + createdAt: Date; + schema: Schema; + environment?: schema.Environment; + defData?: DefData; + deploymentData?: DeploymentData; + isAuthorized?: RouterOutputs["teams"]["isAuthorized"]; +} + export default async function Table({ tableName, chainId, tableId, + owner, createdAt, schema, environment, @@ -166,6 +168,21 @@ export default async function Table({ )} + {owner && ( + + + + Owner + + + + + + )} {deploymentData?.txnHash && ( ({ + statement: `select * from registry where chain_id = ${chainId} and id = ${id} limit 1`, + }); + return res; +} + export async function getLatestTables(chain: number | "mainnets" | "testnets") { let query = "select * from registry "; if (typeof chain === "number") { @@ -25,7 +34,7 @@ export async function getLatestTables(chain: number | "mainnets" | "testnets") { throw new Error("Failed to fetch data"); } - return res.json() as unknown as Table[]; + return res.json() as unknown as RegistryRecord[]; } export interface PopularTable { @@ -79,7 +88,7 @@ export interface SqlLog { } // Tables should all be on mainnets or all be on testnets, -// othwerwise the results will not be as expected. +// otherwise the results will not be as expected. export async function getSqlLogs( tables: Array<{ chainId: number; tableId: string }>, limit: number, From 3c7163c00cd643b4f3870e2c325763c3505f6c5a Mon Sep 17 00:00:00 2001 From: Aaron Sutula Date: Fri, 5 Jul 2024 12:35:53 -0600 Subject: [PATCH 09/25] user logo for owner card Signed-off-by: Aaron Sutula --- packages/web/components/table.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/web/components/table.tsx b/packages/web/components/table.tsx index 12488090..f0cc3fd6 100644 --- a/packages/web/components/table.tsx +++ b/packages/web/components/table.tsx @@ -15,6 +15,7 @@ import { Rocket, Table2, Workflow, + UserCircle, } from "lucide-react"; import Link from "next/link"; import { type RouterOutputs } from "@tableland/studio-api"; @@ -171,7 +172,7 @@ export default async function Table({ {owner && ( - + Owner From 7f14a791d407d26850ed954c5fe8ce797682266e Mon Sep 17 00:00:00 2001 From: Aaron Sutula Date: Mon, 8 Jul 2024 10:21:43 -0600 Subject: [PATCH 10/25] wip on table permissions Signed-off-by: Aaron Sutula --- packages/web/components/table-data.tsx | 41 +++++++++---- packages/web/components/table.tsx | 22 ++++++- packages/web/lib/validator-queries.ts | 79 ++++++++++++++++++++++++++ 3 files changed, 127 insertions(+), 15 deletions(-) diff --git a/packages/web/components/table-data.tsx b/packages/web/components/table-data.tsx index eb4b4488..fdfa9153 100644 --- a/packages/web/components/table-data.tsx +++ b/packages/web/components/table-data.tsx @@ -13,11 +13,12 @@ import { useReactTable, type Row, } from "@tanstack/react-table"; -import React, { useMemo, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import { updatedDiff } from "deep-object-diff"; import { hasConstraint } from "@tableland/studio-store"; import { ChevronDown } from "lucide-react"; import { useRouter } from "next/navigation"; +import { useAccount } from "wagmi"; import { DataTable } from "./data-table"; import TableCell from "./table-cell"; import { EditCell } from "./edit-cell"; @@ -36,6 +37,7 @@ import { DropdownMenuContent, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { type ACLItem, type TablePermissions } from "@/lib/validator-queries"; type NonEmptyArray = [T, ...T[]]; @@ -46,17 +48,40 @@ interface Updates { } interface TableDataProps { + chainId: number; + tableId: string; table: Table; initialData: Array>; + tablePermissions?: TablePermissions; } const tbl = new Database({ autoWait: true, }); -export function TableData({ table: tblTable, initialData }: TableDataProps) { +export function TableData({ + chainId, + tableId, + table: tblTable, + initialData, + tablePermissions, +}: TableDataProps) { const router = useRouter(); + const { address } = useAccount(); + + const [accountPermissions, setAccountPermissions] = useState< + ACLItem | undefined + >(); + + useEffect( + () => + setAccountPermissions( + address && tablePermissions ? tablePermissions[address] : undefined, + ), + [address, tablePermissions], + ); + const pkName = tblTable.schema.columns.find( (col) => hasConstraint(col, "primary key") || @@ -88,16 +113,6 @@ export function TableData({ table: tblTable, initialData }: TableDataProps) { [drizzleTable], ); - // const { params: ps, sql } = db - // .update(drizzleTable) - // .set({ table_name: "foo", chain_id: "1" }) - // .where(eq(drizzleTable.table_id, "abcdef")) - // .toSQL(); - - // console.log("SQL HERE:", sql); - - // console.log("bound:", tbl.prepare(sql).bind(ps).toString()); - initialData = objectToTableData(initialData); const initialRows: ExistingRowData[] = initialData.map((row) => ({ @@ -342,6 +357,7 @@ export function TableData({ table: tblTable, initialData }: TableDataProps) { return ( <>
+ {address &&

Connected as: {address}

}
{editing && ( <> @@ -391,6 +407,7 @@ export function TableData({ table: tblTable, initialData }: TableDataProps) { )}
+
{JSON.stringify(accountPermissions, null, "\t")}
{JSON.stringify(updates, null, "\t")}
); diff --git a/packages/web/components/table.tsx b/packages/web/components/table.tsx index f0cc3fd6..ff06897a 100644 --- a/packages/web/components/table.tsx +++ b/packages/web/components/table.tsx @@ -15,7 +15,7 @@ import { Rocket, Table2, Workflow, - UserCircle, + Crown, } from "lucide-react"; import Link from "next/link"; import { type RouterOutputs } from "@tableland/studio-api"; @@ -41,6 +41,10 @@ import { TimeSince } from "@/components/time"; import { api } from "@/trpc/server"; import DefDetails from "@/components/def-details"; import { ensureError } from "@/lib/ensure-error"; +import { + type TablePermissions, + getTablePermissions, +} from "@/lib/validator-queries"; interface DefData { id: string; @@ -86,6 +90,7 @@ export default async function Table({ const openSeaLink = openSeaLinks.get(chainId); let table: TblTable | undefined; + let tablePermissions: TablePermissions | undefined; let data: Result> | undefined; let error: Error | undefined; try { @@ -93,6 +98,7 @@ export default async function Table({ const validator = new Validator({ baseUrl }); table = await validator.getTableById({ chainId, tableId }); table.schema = unescapeSchema(table.schema); + tablePermissions = await getTablePermissions(chainId, tableId); const tbl = new Database({ baseUrl }); data = await tbl.prepare(`SELECT * FROM ${tableName};`).all(); } catch (err) { @@ -172,7 +178,7 @@ export default async function Table({ {owner && ( - + Owner @@ -255,6 +261,7 @@ export default async function Table({ Table Data SQL Logs Schema + Permissions ) : (

@@ -264,7 +271,13 @@ export default async function Table({ {data && table && ( - + )} {data && ( @@ -275,6 +288,9 @@ export default async function Table({ + +
{JSON.stringify(tablePermissions, null, 2)}
+

); diff --git a/packages/web/lib/validator-queries.ts b/packages/web/lib/validator-queries.ts index 3fb7316b..d2caff2a 100644 --- a/packages/web/lib/validator-queries.ts +++ b/packages/web/lib/validator-queries.ts @@ -10,6 +10,21 @@ export interface RegistryRecord { structure: string; } +export interface TablePermission { + insert: boolean; + update: boolean; + delete: boolean; +} + +export interface ACLItem { + chainId: number; + tableId: number; + controller: string; + createdAt: Date; + updatedAt?: Date; + privileges: TablePermission; +} + export async function getRegistryRecord(chainId: number, id: string) { const baseUrl = baseUrlForChain(chainId); const validator = new Validator({ baseUrl }); @@ -183,6 +198,70 @@ export async function getSqlLog( return array[0]; } +export type TablePermissions = Record; + +export async function getTablePermissions(chainId: number, tableId: string) { + const baseUrl = baseUrlForChain(chainId); + const validator = new Validator({ baseUrl }); + const res = await validator.queryByStatement<{ + chain_id: number; + controller: string; + created_at: number; + privileges: number; + table_id: number; + updated_at: number | null; + }>({ + statement: `select * from system_acl + where chain_id = ${chainId} + and table_id = ${tableId}`, + }); + const permissions = res.reduce((acc, row) => { + acc[row.controller] = { + chainId: row.chain_id, + tableId: row.table_id, + controller: row.controller, + createdAt: new Date(row.created_at * 1000), + updatedAt: row.updated_at ? new Date(row.updated_at * 1000) : undefined, + privileges: { + insert: (row.privileges & 1) > 0, + update: (row.privileges & 2) > 0, + delete: (row.privileges & 4) > 0, + }, + }; + return acc; + }, {}); + return permissions; +} + +export async function getTablePermissionsForAddress( + chainId: number, + tableId: string, + address: string, +) { + const baseUrl = baseUrlForChain(chainId); + const validator = new Validator({ baseUrl }); + const [res] = await validator.queryByStatement<{ + chain_id: number; + controller: string; + created_at: number; + privileges: number; + table_id: number; + updated_at: number | null; + }>({ + statement: `select * from system_acl + where chain_id = ${chainId} + and table_id = ${tableId} + and controller = '${address}' + limit 1`, + }); + const permission: TablePermission = { + insert: (res.privileges & 1) > 0, + update: (res.privileges & 2) > 0, + delete: (res.privileges & 4) > 0, + }; + return permission; +} + function baseUrlForChain(chainId: number | "mainnets" | "testnets") { if (chainId === "mainnets") { return helpers.getBaseUrl(1); From 795551f861dccf86432062ccb76bdab2bc2497a5 Mon Sep 17 00:00:00 2001 From: Aaron Sutula Date: Tue, 9 Jul 2024 09:51:50 -0600 Subject: [PATCH 11/25] updates to console table data display Signed-off-by: Aaron Sutula --- packages/web/components/console.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/web/components/console.tsx b/packages/web/components/console.tsx index 54ed76a3..93149884 100644 --- a/packages/web/components/console.tsx +++ b/packages/web/components/console.tsx @@ -4,6 +4,7 @@ import * as React from "react"; import { Database, helpers } from "@tableland/sdk"; import { studioAliases, getBaseUrl } from "@tableland/studio-client"; import { init } from "@tableland/sqlparser"; +import { getCoreRowModel, useReactTable } from "@tanstack/react-table"; import { CodeEditor } from "./code-editor"; import { DataTable } from "./data-table"; import { cn, objectToTableData } from "@/lib/utils"; @@ -362,7 +363,13 @@ function TabLabel(props: { function ResultSetPane(props: any): React.JSX.Element { const { tab } = props; - const formattedData = objectToTableData(tab.results); + const data = objectToTableData(tab.results); + + const table = useReactTable({ + data, + columns: tab.columns, + getCoreRowModel: getCoreRowModel(), + }); return (
@@ -380,7 +387,7 @@ function ResultSetPane(props: any): React.JSX.Element {
)} {!tab.error && !tab.messages?.length && ( - + )}
); From 014e7da89516a8f9bee32456ab23d1d7e5d4f9df Mon Sep 17 00:00:00 2001 From: Aaron Sutula Date: Wed, 10 Jul 2024 10:26:18 -0600 Subject: [PATCH 12/25] account and permissions display, acl usage Signed-off-by: Aaron Sutula --- packages/api/src/root.ts | 2 + packages/api/src/routers/users.ts | 14 ++ packages/store/src/api/users.ts | 16 +- packages/web/app/page.tsx | 5 +- packages/web/components/def-details.tsx | 2 +- packages/web/components/edit-cell.tsx | 23 +-- packages/web/components/hash-display.tsx | 2 +- packages/web/components/table-data.tsx | 68 ++++----- packages/web/components/table-details.tsx | 175 ++++++++++++++++++++++ packages/web/components/table.tsx | 122 +++++++-------- packages/web/types.d.ts | 4 +- 11 files changed, 312 insertions(+), 121 deletions(-) create mode 100644 packages/api/src/routers/users.ts create mode 100644 packages/web/components/table-details.tsx diff --git a/packages/api/src/root.ts b/packages/api/src/root.ts index 5f4cd144..0d810e54 100644 --- a/packages/api/src/root.ts +++ b/packages/api/src/root.ts @@ -11,6 +11,7 @@ import { providersRouter } from "./routers/providers"; import { defsRouter } from "./routers/defs"; import { teamsRouter } from "./routers/teams"; import { tablesRouter } from "./routers/tables"; +import { usersRouter } from "./routers/users"; export function appRouter( store: Store, @@ -43,6 +44,7 @@ export function appRouter( infura: infuraKey, quickNode: quickNodeKey, }), + users: usersRouter(store), }); } diff --git a/packages/api/src/routers/users.ts b/packages/api/src/routers/users.ts new file mode 100644 index 00000000..f0146706 --- /dev/null +++ b/packages/api/src/routers/users.ts @@ -0,0 +1,14 @@ +import { type Store } from "@tableland/studio-store"; +import { z } from "zod"; +import { createTRPCRouter, publicProcedure } from "../trpc"; + +export function usersRouter(store: Store) { + return createTRPCRouter({ + usersForAddresses: publicProcedure + .input(z.object({ addresses: z.array(z.string().trim()).min(1) })) + .query(async ({ input }) => { + const users = await store.users.usersForAddresses(input.addresses); + return users; + }), + }); +} diff --git a/packages/store/src/api/users.ts b/packages/store/src/api/users.ts index a97e0651..ecfba244 100644 --- a/packages/store/src/api/users.ts +++ b/packages/store/src/api/users.ts @@ -1,8 +1,9 @@ -import { eq } from "drizzle-orm"; +import { eq, inArray } from "drizzle-orm"; import { type DrizzleD1Database } from "drizzle-orm/d1"; import * as schema from "../schema/index.js"; const users = schema.users; +const teams = schema.teams; export function initUsers(db: DrizzleD1Database) { return { @@ -16,5 +17,18 @@ export function initUsers(db: DrizzleD1Database) { return user?.teamId; }, + + usersForAddresses: async function (addresses: string[]) { + const res = await db + .select({ + user: users, + team: teams, + }) + .from(users) + .innerJoin(teams, eq(users.teamId, teams.id)) + .where(inArray(users.address, addresses)) + .all(); + return res; + }, }; } diff --git a/packages/web/app/page.tsx b/packages/web/app/page.tsx index cc903fd7..2d91e307 100644 --- a/packages/web/app/page.tsx +++ b/packages/web/app/page.tsx @@ -24,7 +24,10 @@ import { getLatestTables, getPopularTables } from "@/lib/validator-queries"; import { api } from "@/trpc/server"; export default async function Page() { - const session = await getSession({ headers: headers(), cookies: cookies() }); + const session = await cache(getSession)({ + headers: headers(), + cookies: cookies(), + }); let teams: RouterOutputs["teams"]["userTeams"] = []; if (session.auth) { diff --git a/packages/web/components/def-details.tsx b/packages/web/components/def-details.tsx index 2d3ffda1..30585558 100644 --- a/packages/web/components/def-details.tsx +++ b/packages/web/components/def-details.tsx @@ -9,7 +9,7 @@ import { import DefColumns from "@/components/def-columns"; import DefConstraints from "@/components/def-constraints"; -export default async function DefDetails({ +export default function DefDetails({ name, schema, }: { diff --git a/packages/web/components/edit-cell.tsx b/packages/web/components/edit-cell.tsx index 44ecb556..03f10d48 100644 --- a/packages/web/components/edit-cell.tsx +++ b/packages/web/components/edit-cell.tsx @@ -26,7 +26,7 @@ export function EditCell({ return (
{/* Edit */} - {type === "existing" && ( + {meta?.accountPermissions?.privileges.update && type === "existing" && ( @@ -43,16 +43,17 @@ export function EditCell({ )} {/* Delete */} - {(type === "existing" || type === "edited" || type === "new") && ( - - )} + {meta?.accountPermissions?.privileges.delete && + (type === "existing" || type === "edited" || type === "new") && ( + + )}
); } diff --git a/packages/web/components/hash-display.tsx b/packages/web/components/hash-display.tsx index a927bcfa..367a6c82 100644 --- a/packages/web/components/hash-display.tsx +++ b/packages/web/components/hash-display.tsx @@ -54,7 +54,7 @@ export default function HashDisplay({ className="ml-1 h-auto p-1" onClick={() => handleCopy(hash, hashDesc, toast)} > - + Copy {hashDesc} diff --git a/packages/web/components/table-data.tsx b/packages/web/components/table-data.tsx index fdfa9153..65c05e7d 100644 --- a/packages/web/components/table-data.tsx +++ b/packages/web/components/table-data.tsx @@ -1,6 +1,6 @@ "use client"; -import { Database, type Table } from "@tableland/sdk"; +import { Database } from "@tableland/sdk"; import { drizzle } from "drizzle-orm/d1"; import { integer, int, sqliteTable, text, blob } from "drizzle-orm/sqlite-core"; import { eq } from "drizzle-orm/expressions"; @@ -13,12 +13,11 @@ import { useReactTable, type Row, } from "@tanstack/react-table"; -import React, { useEffect, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; import { updatedDiff } from "deep-object-diff"; -import { hasConstraint } from "@tableland/studio-store"; +import { type Schema, hasConstraint } from "@tableland/studio-store"; import { ChevronDown } from "lucide-react"; import { useRouter } from "next/navigation"; -import { useAccount } from "wagmi"; import { DataTable } from "./data-table"; import TableCell from "./table-cell"; import { EditCell } from "./edit-cell"; @@ -37,7 +36,7 @@ import { DropdownMenuContent, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { type ACLItem, type TablePermissions } from "@/lib/validator-queries"; +import { type ACLItem } from "@/lib/validator-queries"; type NonEmptyArray = [T, ...T[]]; @@ -48,11 +47,10 @@ interface Updates { } interface TableDataProps { - chainId: number; - tableId: string; - table: Table; + tableName: string; + schema: Schema; initialData: Array>; - tablePermissions?: TablePermissions; + accountPermissions?: ACLItem; } const tbl = new Database({ @@ -60,29 +58,14 @@ const tbl = new Database({ }); export function TableData({ - chainId, - tableId, - table: tblTable, + tableName, + schema, initialData, - tablePermissions, + accountPermissions, }: TableDataProps) { const router = useRouter(); - const { address } = useAccount(); - - const [accountPermissions, setAccountPermissions] = useState< - ACLItem | undefined - >(); - - useEffect( - () => - setAccountPermissions( - address && tablePermissions ? tablePermissions[address] : undefined, - ), - [address, tablePermissions], - ); - - const pkName = tblTable.schema.columns.find( + const pkName = schema.columns.find( (col) => hasConstraint(col, "primary key") || hasConstraint(col, "primary key autoincrement"), @@ -90,7 +73,7 @@ export function TableData({ const tableSchema = useMemo( () => - tblTable.schema.columns.reduce>((acc, col) => { + schema.columns.reduce>((acc, col) => { if (col.type === "text") { acc[col.name] = text(col.name); } else if (col.type === "integer") { @@ -102,12 +85,14 @@ export function TableData({ } return acc; }, {}), - [tblTable], + [schema], ); + const drizzleTable = useMemo( - () => sqliteTable(tblTable.name, tableSchema), - [tblTable, tableSchema], + () => sqliteTable(tableName, tableSchema), + [tableName, tableSchema], ); + const db = useMemo( () => drizzle(tbl, { schema: drizzleTable, logger: false }), [drizzleTable], @@ -150,7 +135,7 @@ export function TableData({ const columns: | Array | DisplayColumnDef> - | undefined = tblTable.schema.columns.map((col) => ({ + | undefined = schema.columns.map((col) => ({ accessorKey: `data.${col.name}`, header: col.name, cell: TableCell, @@ -179,6 +164,7 @@ export function TableData({ }, meta: { pkName, + accountPermissions, editRow: (rowToEdit: Row) => { const tableRowData = rowToEdit.original; switch (tableRowData.type) { @@ -357,7 +343,6 @@ export function TableData({ return ( <>
- {address &&

Connected as: {address}

}
{editing && ( <> @@ -370,12 +355,14 @@ export function TableData({ )} - + {accountPermissions?.privileges.insert && ( + + )}
{!!columns.length && ( @@ -407,7 +394,6 @@ export function TableData({ )}
-
{JSON.stringify(accountPermissions, null, "\t")}
{JSON.stringify(updates, null, "\t")}
); diff --git a/packages/web/components/table-details.tsx b/packages/web/components/table-details.tsx new file mode 100644 index 00000000..adb54749 --- /dev/null +++ b/packages/web/components/table-details.tsx @@ -0,0 +1,175 @@ +"use client"; + +import { type Auth, type RouterOutputs } from "@tableland/studio-api"; +import { type Schema } from "@tableland/studio-store"; +import { useEffect, useMemo, useState } from "react"; +import { useAccount } from "wagmi"; +import { AlertTriangle, Crown, KeyRound, User } from "lucide-react"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs"; +import SQLLogs from "./sql-logs"; +import { TableData } from "./table-data"; +import HashDisplay from "./hash-display"; +import { cn } from "@/lib/utils"; +import DefDetails from "@/components/def-details"; +import { type TablePermissions } from "@/lib/validator-queries"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; + +export interface TableDetailsProps { + displayName: string; + tableName: string; + schema: Schema; + chainId: number; + tableId: string; + data: Array>; + tablePermissions: TablePermissions; + owner: string; + authorizedStudioUsers: RouterOutputs["users"]["usersForAddresses"]; + auth?: Auth; +} + +export default function TableDetails({ + displayName, + tableName, + schema, + chainId, + tableId, + data, + tablePermissions, + owner, + authorizedStudioUsers, + auth, +}: TableDetailsProps) { + const [addressPostMount, setAddressPostMount] = useState< + `0x${string}` | undefined + >(); + const { address } = useAccount(); + + useEffect(() => { + setAddressPostMount(address); + }, [address]); + + const authorizedStudioUser = useMemo( + () => + authorizedStudioUsers?.find( + (item) => item.user.address === addressPostMount, + ), + [addressPostMount, authorizedStudioUsers], + ); + + const accountPermissions = useMemo( + () => (addressPostMount ? tablePermissions[addressPostMount] : undefined), + [addressPostMount, tablePermissions], + ); + + return ( + +
+ + Table Data + SQL Logs + Schema + Permissions + + {addressPostMount && ( +
+ + Connected as{" "} +
+ +
+
+
+ {authorizedStudioUser && ( + + + + + + + Studio user {authorizedStudioUser.team.name} + {authorizedStudioUser.user.teamId === auth?.user.teamId + ? " (you)" + : ""} + + + + )} + {auth && auth.user.address !== addressPostMount && ( + + + + + + + This address is different than the one associated with + your Studio account. + + + + )} + {owner === addressPostMount && ( + + + + + + Table owner + + + )} + {accountPermissions && ( + + + + + + + Permissions: +
    + {accountPermissions.privileges.insert && ( +
  • Insert
  • + )} + {accountPermissions.privileges.update && ( +
  • Update
  • + )} + {accountPermissions.privileges.delete && ( +
  • Delete
  • + )} +
+
+
+
+ )} +
+
+ )} +
+ + + + + + + + + + +
{JSON.stringify(tablePermissions, null, 2)}
+
+
+ ); +} diff --git a/packages/web/components/table.tsx b/packages/web/components/table.tsx index ff06897a..1488357f 100644 --- a/packages/web/components/table.tsx +++ b/packages/web/components/table.tsx @@ -1,12 +1,5 @@ -import { - Database, - type Schema, - helpers, - type Result, - Validator, - type Table as TblTable, -} from "@tableland/sdk"; -import { unescapeSchema, type schema } from "@tableland/studio-store"; +import { Database, type Schema, helpers, type Result } from "@tableland/sdk"; +import { type schema } from "@tableland/studio-store"; import { Blocks, Coins, @@ -18,7 +11,9 @@ import { Crown, } from "lucide-react"; import Link from "next/link"; -import { type RouterOutputs } from "@tableland/studio-api"; +import { getSession, type RouterOutputs } from "@tableland/studio-api"; +import { cookies, headers } from "next/headers"; +import { cache } from "react"; import { MetricCard, MetricCardContent, @@ -26,20 +21,17 @@ import { MetricCardHeader, MetricCardTitle, } from "./metric-card"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs"; -import SQLLogs from "./sql-logs"; import HashDisplay from "./hash-display"; import { CardContent } from "./ui/card"; import ProjectsReferencingTable from "./projects-referencing-table"; import { Alert, AlertDescription, AlertTitle } from "./ui/alert"; -import { TableData } from "./table-data"; +import TableDetails from "./table-details"; +import DefDetails from "./def-details"; import { blockExplorers } from "@/lib/block-explorers"; import { openSeaLinks } from "@/lib/open-sea"; import { chainsMap } from "@/lib/chains-map"; -import { cn } from "@/lib/utils"; import { TimeSince } from "@/components/time"; import { api } from "@/trpc/server"; -import DefDetails from "@/components/def-details"; import { ensureError } from "@/lib/ensure-error"; import { type TablePermissions, @@ -85,20 +77,30 @@ export default async function Table({ deploymentData, isAuthorized, }: Props) { + const session = await cache(getSession)({ + headers: headers(), + cookies: cookies(), + }); + const chain = chainsMap.get(chainId); const blockExplorer = blockExplorers.get(chainId); const openSeaLink = openSeaLinks.get(chainId); - let table: TblTable | undefined; let tablePermissions: TablePermissions | undefined; + let authorizedStudioUsers: + | RouterOutputs["users"]["usersForAddresses"] + | undefined; let data: Result> | undefined; let error: Error | undefined; try { const baseUrl = helpers.getBaseUrl(chainId); - const validator = new Validator({ baseUrl }); - table = await validator.getTableById({ chainId, tableId }); - table.schema = unescapeSchema(table.schema); tablePermissions = await getTablePermissions(chainId, tableId); + const authorizedAddresses = Object.keys(tablePermissions); + authorizedStudioUsers = authorizedAddresses.length + ? await api.users.usersForAddresses({ + addresses: authorizedAddresses, + }) + : []; const tbl = new Database({ baseUrl }); data = await tbl.prepare(`SELECT * FROM ${tableName};`).all(); } catch (err) { @@ -109,6 +111,12 @@ export default async function Table({ await api.deployments.deploymentReferences({ chainId, tableId }) ).filter((p) => p.environment.id !== environment?.id); + const authorizedStudioUser = authorizedStudioUsers?.find( + (item) => item.user.address === owner, + ); + + const displayName = defData?.name ?? tableName; + return (
@@ -177,17 +185,26 @@ export default async function Table({ {owner && ( - + Owner - + + {authorizedStudioUser && ( + + Studio user {authorizedStudioUser.team.name} + {authorizedStudioUser.user.teamId === session.auth?.user.teamId + ? " (you)" + : ""} + + )} )} {deploymentData?.txnHash && ( @@ -253,45 +270,22 @@ export default async function Table({ )}
- - - - {data && table ? ( - <> - Table Data - SQL Logs - Schema - Permissions - - ) : ( -

- Definition -

- )} -
- {data && table && ( - - - - )} - {data && ( - - - - )} - - - - -
{JSON.stringify(tablePermissions, null, 2)}
-
-
+ {owner && authorizedStudioUsers && tablePermissions && data ? ( + + ) : ( + + )}
); } diff --git a/packages/web/types.d.ts b/packages/web/types.d.ts index 87e1bef4..999fa25d 100644 --- a/packages/web/types.d.ts +++ b/packages/web/types.d.ts @@ -1,10 +1,12 @@ import "@tanstack/table-core"; import { Row, RowData } from "@tanstack/react-table"; +import { ACLItem } from "./lib/validator-queries"; declare module "@tanstack/table-core" { interface TableMeta { getRowClassName: (row: Row) => string; - pkName: string | undefined; + pkName?: string; + accountPermissions?: ACLItem; editRow: (row: Row) => void; updateRowColumn: ( row: Row, From c49cae30dac9889a63e52f7600b94f68c7d06a3b Mon Sep 17 00:00:00 2001 From: Aaron Sutula Date: Wed, 10 Jul 2024 11:18:47 -0600 Subject: [PATCH 13/25] handle generating queries where there is no primary key Signed-off-by: Aaron Sutula --- packages/web/components/table-data.tsx | 31 ++++++++++++++++---------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/packages/web/components/table-data.tsx b/packages/web/components/table-data.tsx index 65c05e7d..792bdefe 100644 --- a/packages/web/components/table-data.tsx +++ b/packages/web/components/table-data.tsx @@ -3,7 +3,7 @@ import { Database } from "@tableland/sdk"; import { drizzle } from "drizzle-orm/d1"; import { integer, int, sqliteTable, text, blob } from "drizzle-orm/sqlite-core"; -import { eq } from "drizzle-orm/expressions"; +import { eq, and } from "drizzle-orm/expressions"; import { type ColumnDef, type DisplayColumnDef, @@ -65,6 +65,7 @@ export function TableData({ }: TableDataProps) { const router = useRouter(); + // TODO: support composite primary keys const pkName = schema.columns.find( (col) => hasConstraint(col, "primary key") || @@ -144,9 +145,7 @@ export function TableData({ type: col.type === "integer" || col.type === "int" ? "number" : "string", }, })); - if (pkName) { - columns.push({ id: "edit", cell: EditCell }); - } + columns.push({ id: "edit", cell: EditCell }); const table = useReactTable({ data, @@ -306,6 +305,16 @@ export function TableData({ }); const executeStatements = () => { + const genWhereConstraints = (row: EditedRowData | DeletedRowData) => { + if (pkName) { + return eq(drizzleTable[pkName], row.originalData.data[pkName]); + } + const eqs = Object.entries(row.originalData.data).map(([key, value]) => { + return eq(drizzleTable[key], value); + }); + return and(...eqs); + }; + const sqlInsertItems = updates.new.map((row) => { return db.insert(drizzleTable).values(row.data); }); @@ -315,23 +324,21 @@ export function TableData({ return db .update(drizzleTable) .set(row.diff!) - .where(eq(drizzleTable[pkName!], row.originalData.data[pkName!])); + .where(genWhereConstraints(row)); }); const sqlDeleteItems = updates.deleted.map((row) => { - return db - .delete(drizzleTable) - .where(eq(drizzleTable[pkName!], row.originalData.data[pkName!])); + return db.delete(drizzleTable).where(genWhereConstraints(row)); }); - type Hmm = + type SQLItems = | (typeof sqlInsertItems)[number] | (typeof sqlUpdateItems)[number] | (typeof sqlDeleteItems)[number]; - const ugh = [ + const batch = [ ...sqlInsertItems, ...sqlUpdateItems, ...sqlDeleteItems, - ] as NonEmptyArray; - db.batch(ugh) + ] as NonEmptyArray; + db.batch(batch) .then((res) => { router.refresh(); }) From dce279be79396c83b0d86fbbe2a6276ccd1aa517 Mon Sep 17 00:00:00 2001 From: Aaron Sutula Date: Wed, 10 Jul 2024 16:48:19 -0600 Subject: [PATCH 14/25] disable ui when txn is pending, properly reset data on router refresh Signed-off-by: Aaron Sutula --- packages/web/components/edit-cell.tsx | 10 +++- packages/web/components/table-cell.tsx | 1 + packages/web/components/table-data.tsx | 69 +++++++++++++++++--------- packages/web/types.d.ts | 1 + 4 files changed, 56 insertions(+), 25 deletions(-) diff --git a/packages/web/components/edit-cell.tsx b/packages/web/components/edit-cell.tsx index 03f10d48..d16215b6 100644 --- a/packages/web/components/edit-cell.tsx +++ b/packages/web/components/edit-cell.tsx @@ -27,7 +27,13 @@ export function EditCell({
{/* Edit */} {meta?.accountPermissions?.privileges.update && type === "existing" && ( - )} @@ -38,6 +44,7 @@ export function EditCell({ size="icon" title="Revert row changes" onClick={revertRow} + disabled={meta?.pendingTxn} > @@ -50,6 +57,7 @@ export function EditCell({ size="icon" title="Delete row" onClick={deleteRow} + disabled={meta?.pendingTxn} > diff --git a/packages/web/components/table-cell.tsx b/packages/web/components/table-cell.tsx index cc6296ad..a98f8444 100644 --- a/packages/web/components/table-cell.tsx +++ b/packages/web/components/table-cell.tsx @@ -36,6 +36,7 @@ export default function TableCell({ ) } onBlur={onBlur} + disabled={table.options.meta?.pendingTxn} /> ); } diff --git a/packages/web/components/table-data.tsx b/packages/web/components/table-data.tsx index 792bdefe..7753659f 100644 --- a/packages/web/components/table-data.tsx +++ b/packages/web/components/table-data.tsx @@ -13,10 +13,10 @@ import { useReactTable, type Row, } from "@tanstack/react-table"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { updatedDiff } from "deep-object-diff"; import { type Schema, hasConstraint } from "@tableland/studio-store"; -import { ChevronDown } from "lucide-react"; +import { ChevronDown, Loader2 } from "lucide-react"; import { useRouter } from "next/navigation"; import { DataTable } from "./data-table"; import TableCell from "./table-cell"; @@ -29,6 +29,7 @@ import { type EditedRowData, type DeletedRowData, } from "./table-data-types"; +import { useToast } from "./ui/use-toast"; import { objectToTableData } from "@/lib/utils"; import { DropdownMenu, @@ -37,6 +38,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { type ACLItem } from "@/lib/validator-queries"; +import { ensureError } from "@/lib/ensure-error"; type NonEmptyArray = [T, ...T[]]; @@ -64,15 +66,9 @@ export function TableData({ accountPermissions, }: TableDataProps) { const router = useRouter(); + const { toast } = useToast(); - // TODO: support composite primary keys - const pkName = schema.columns.find( - (col) => - hasConstraint(col, "primary key") || - hasConstraint(col, "primary key autoincrement"), - )?.name; - - const tableSchema = useMemo( + const drizzleSchema = useMemo( () => schema.columns.reduce>((acc, col) => { if (col.type === "text") { @@ -90,8 +86,8 @@ export function TableData({ ); const drizzleTable = useMemo( - () => sqliteTable(tableName, tableSchema), - [tableName, tableSchema], + () => sqliteTable(tableName, drizzleSchema), + [tableName, drizzleSchema], ); const db = useMemo( @@ -99,14 +95,20 @@ export function TableData({ [drizzleTable], ); - initialData = objectToTableData(initialData); + const initialRows: ExistingRowData[] = useMemo( + () => + objectToTableData(initialData).map((row) => ({ + type: "existing", + data: { ...row }, + })), + [initialData], + ); - const initialRows: ExistingRowData[] = initialData.map((row) => ({ - type: "existing", - data: { ...row }, - })); + const [data, setData] = useState([]); - const [data, setData] = useState(() => [...initialRows]); + useEffect(() => { + setData(() => [...initialRows]); + }, [initialRows]); const updates = useMemo(() => { const res = data.reduce( @@ -147,6 +149,15 @@ export function TableData({ })); columns.push({ id: "edit", cell: EditCell }); + // TODO: support composite primary keys + const pkName = schema.columns.find( + (col) => + hasConstraint(col, "primary key") || + hasConstraint(col, "primary key autoincrement"), + )?.name; + + const [pendingTxn, setPendingTxn] = useState(false); + const table = useReactTable({ data, columns, @@ -164,6 +175,7 @@ export function TableData({ meta: { pkName, accountPermissions, + pendingTxn, editRow: (rowToEdit: Row) => { const tableRowData = rowToEdit.original; switch (tableRowData.type) { @@ -338,13 +350,17 @@ export function TableData({ ...sqlUpdateItems, ...sqlDeleteItems, ] as NonEmptyArray; + setPendingTxn(true); db.batch(batch) - .then((res) => { - router.refresh(); - }) + .then(() => router.refresh()) .catch((err) => { - console.log("ERR", err); - }); + toast({ + title: "Error executing SQL statements", + description: ensureError(err).message, + variant: "destructive", + }); + }) + .finally(() => setPendingTxn(false)); }; return ( @@ -353,10 +369,14 @@ export function TableData({
{editing && ( <> - + @@ -366,6 +386,7 @@ export function TableData({ diff --git a/packages/web/types.d.ts b/packages/web/types.d.ts index 999fa25d..44e5cba1 100644 --- a/packages/web/types.d.ts +++ b/packages/web/types.d.ts @@ -7,6 +7,7 @@ declare module "@tanstack/table-core" { getRowClassName: (row: Row) => string; pkName?: string; accountPermissions?: ACLItem; + pendingTxn?: boolean; editRow: (row: Row) => void; updateRowColumn: ( row: Row, From 2054a8b233e0ad021fce982d9b6cc926eadd68f2 Mon Sep 17 00:00:00 2001 From: Aaron Sutula Date: Thu, 11 Jul 2024 11:53:52 -0600 Subject: [PATCH 15/25] acl display Signed-off-by: Aaron Sutula --- packages/api/src/routers/users.ts | 9 +- packages/web/components/acl.tsx | 120 ++++++++++++++++++++++ packages/web/components/console.tsx | 4 +- packages/web/components/data-table.tsx | 18 +--- packages/web/components/hash-display.tsx | 10 +- packages/web/components/table-data.tsx | 3 +- packages/web/components/table-details.tsx | 11 +- packages/web/components/table.tsx | 16 ++- 8 files changed, 155 insertions(+), 36 deletions(-) create mode 100644 packages/web/components/acl.tsx diff --git a/packages/api/src/routers/users.ts b/packages/api/src/routers/users.ts index f0146706..f6caa9e3 100644 --- a/packages/api/src/routers/users.ts +++ b/packages/api/src/routers/users.ts @@ -8,7 +8,14 @@ export function usersRouter(store: Store) { .input(z.object({ addresses: z.array(z.string().trim()).min(1) })) .query(async ({ input }) => { const users = await store.users.usersForAddresses(input.addresses); - return users; + const res = users.reduce>( + (acc, item) => { + acc[item.user.address] = item; + return acc; + }, + {}, + ); + return res; }), }); } diff --git a/packages/web/components/acl.tsx b/packages/web/components/acl.tsx new file mode 100644 index 00000000..b43f3b26 --- /dev/null +++ b/packages/web/components/acl.tsx @@ -0,0 +1,120 @@ +import { + type ColumnDef, + getCoreRowModel, + useReactTable, + type Cell, +} from "@tanstack/react-table"; +import { type RouterOutputs } from "@tableland/studio-api"; +import { useEffect, useState } from "react"; +import { Check } from "lucide-react"; +import Link from "next/link"; +import { DataTable } from "./data-table"; +import HashDisplay from "./hash-display"; +import { type ACLItem } from "@/lib/validator-queries"; + +type TableRowData = ACLItem & { + isOwner: boolean; +} & RouterOutputs["users"]["usersForAddresses"][string]; + +interface Props { + acl: ACLItem[]; + authorizedStudioUsers: RouterOutputs["users"]["usersForAddresses"]; + owner: string; +} + +export default function ACL({ acl, authorizedStudioUsers, owner }: Props) { + const data: TableRowData[] = acl.map((item) => { + const studioUser = authorizedStudioUsers[item.controller]; + const isOwner = item.controller === owner; + return { + ...item, + ...studioUser, + isOwner, + }; + }); + + const columns: Array> = [ + { + accessorKey: "controller", + header: "Address", + cell: AddressCell, + }, + { + accessorKey: "team.name", + header: "Studio User", + cell: UserCell, + }, + { + accessorKey: "isOwner", + header: "Table Owner", + cell: BoolCell, + }, + { + accessorKey: "privileges.insert", + header: "Insert", + cell: BoolCell, + }, + { + accessorKey: "privileges.update", + header: "Update", + cell: BoolCell, + }, + { + accessorKey: "privileges.delete", + header: "Delete", + cell: BoolCell, + }, + ]; + + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + }); + return ; +} + +function AddressCell({ + getValue, +}: ReturnType["getContext"]>) { + const initialValue = getValue(); + + const [value, setValue] = useState(initialValue); + + useEffect(() => { + setValue(initialValue); + }, [initialValue]); + + return ( + + ); +} + +function BoolCell({ + getValue, +}: ReturnType["getContext"]>) { + const initialValue = getValue(); + + const [value, setValue] = useState(initialValue); + + useEffect(() => { + setValue(initialValue); + }, [initialValue]); + + return value ? : null; +} + +function UserCell({ + getValue, + row, +}: ReturnType["getContext"]>) { + const initialValue = getValue(); + + const [value, setValue] = useState(initialValue); + + useEffect(() => { + setValue(initialValue); + }, [initialValue]); + + return {value}; +} diff --git a/packages/web/components/console.tsx b/packages/web/components/console.tsx index 93149884..1165e26b 100644 --- a/packages/web/components/console.tsx +++ b/packages/web/components/console.tsx @@ -386,9 +386,7 @@ function ResultSetPane(props: any): React.JSX.Element { })}
)} - {!tab.error && !tab.messages?.length && ( - - )} + {!tab.error && !tab.messages?.length && }
); } diff --git a/packages/web/components/data-table.tsx b/packages/web/components/data-table.tsx index 834f4e60..279e9512 100644 --- a/packages/web/components/data-table.tsx +++ b/packages/web/components/data-table.tsx @@ -1,8 +1,4 @@ -import { - type ColumnDef, - flexRender, - type Table as TSTable, -} from "@tanstack/react-table"; +import { flexRender, type Table as TSTable } from "@tanstack/react-table"; import React from "react"; import { Button } from "@/components/ui/button"; import { @@ -14,17 +10,11 @@ import { TableRow, } from "@/components/ui/table"; -interface DataTableProps { - columns: Array>; - data: TData[]; +interface DataTableProps { table: TSTable; } -export function DataTable({ - columns, - data, - table, -}: DataTableProps) { +export function DataTable({ table }: DataTableProps) { return (
@@ -68,7 +58,7 @@ export function DataTable({ ) : ( No results. diff --git a/packages/web/components/hash-display.tsx b/packages/web/components/hash-display.tsx index 367a6c82..ee1ff57d 100644 --- a/packages/web/components/hash-display.tsx +++ b/packages/web/components/hash-display.tsx @@ -19,7 +19,7 @@ export default function HashDisplay({ hashDesc = "address", className, ...rest -}: HTMLProps & { +}: HTMLProps & { hash: string; numCharacters?: number; copy?: boolean; @@ -32,7 +32,13 @@ export default function HashDisplay({ : hash; return ( -
+
diff --git a/packages/web/components/table-data.tsx b/packages/web/components/table-data.tsx index 7753659f..dfb9dc9d 100644 --- a/packages/web/components/table-data.tsx +++ b/packages/web/components/table-data.tsx @@ -421,8 +421,7 @@ export function TableData({ )}
- -
{JSON.stringify(updates, null, "\t")}
+ ); } diff --git a/packages/web/components/table-details.tsx b/packages/web/components/table-details.tsx index adb54749..50788ead 100644 --- a/packages/web/components/table-details.tsx +++ b/packages/web/components/table-details.tsx @@ -9,6 +9,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs"; import SQLLogs from "./sql-logs"; import { TableData } from "./table-data"; import HashDisplay from "./hash-display"; +import ACL from "./acl"; import { cn } from "@/lib/utils"; import DefDetails from "@/components/def-details"; import { type TablePermissions } from "@/lib/validator-queries"; @@ -55,9 +56,7 @@ export default function TableDetails({ const authorizedStudioUser = useMemo( () => - authorizedStudioUsers?.find( - (item) => item.user.address === addressPostMount, - ), + addressPostMount ? authorizedStudioUsers[addressPostMount] : undefined, [addressPostMount, authorizedStudioUsers], ); @@ -168,7 +167,11 @@ export default function TableDetails({ -
{JSON.stringify(tablePermissions, null, 2)}
+
); diff --git a/packages/web/components/table.tsx b/packages/web/components/table.tsx index 1488357f..9d865baf 100644 --- a/packages/web/components/table.tsx +++ b/packages/web/components/table.tsx @@ -87,9 +87,7 @@ export default async function Table({ const openSeaLink = openSeaLinks.get(chainId); let tablePermissions: TablePermissions | undefined; - let authorizedStudioUsers: - | RouterOutputs["users"]["usersForAddresses"] - | undefined; + let authorizedStudioUsers: RouterOutputs["users"]["usersForAddresses"] = {}; let data: Result> | undefined; let error: Error | undefined; try { @@ -100,7 +98,7 @@ export default async function Table({ ? await api.users.usersForAddresses({ addresses: authorizedAddresses, }) - : []; + : {}; const tbl = new Database({ baseUrl }); data = await tbl.prepare(`SELECT * FROM ${tableName};`).all(); } catch (err) { @@ -111,9 +109,7 @@ export default async function Table({ await api.deployments.deploymentReferences({ chainId, tableId }) ).filter((p) => p.environment.id !== environment?.id); - const authorizedStudioUser = authorizedStudioUsers?.find( - (item) => item.user.address === owner, - ); + const ownerStudioUser = owner ? authorizedStudioUsers[owner] : undefined; const displayName = defData?.name ?? tableName; @@ -197,10 +193,10 @@ export default async function Table({ - {authorizedStudioUser && ( + {ownerStudioUser && ( - Studio user {authorizedStudioUser.team.name} - {authorizedStudioUser.user.teamId === session.auth?.user.teamId + Studio user {ownerStudioUser.team.name} + {ownerStudioUser.user.teamId === session.auth?.user.teamId ? " (you)" : ""} From c8f8c299f69dcf1ecb05ef45130ea8ed1540debd Mon Sep 17 00:00:00 2001 From: Aaron Sutula Date: Thu, 11 Jul 2024 15:35:41 -0600 Subject: [PATCH 16/25] return map for studio users Signed-off-by: Aaron Sutula --- packages/api/src/routers/users.ts | 6 +++--- packages/web/components/acl.tsx | 20 +++++++++++++++----- packages/web/components/table-details.tsx | 4 +++- packages/web/components/table.tsx | 8 +++++--- 4 files changed, 26 insertions(+), 12 deletions(-) diff --git a/packages/api/src/routers/users.ts b/packages/api/src/routers/users.ts index f6caa9e3..abbf5282 100644 --- a/packages/api/src/routers/users.ts +++ b/packages/api/src/routers/users.ts @@ -8,12 +8,12 @@ export function usersRouter(store: Store) { .input(z.object({ addresses: z.array(z.string().trim()).min(1) })) .query(async ({ input }) => { const users = await store.users.usersForAddresses(input.addresses); - const res = users.reduce>( + const res = users.reduce>( (acc, item) => { - acc[item.user.address] = item; + acc.set(item.user.address, item); return acc; }, - {}, + new Map(), ); return res; }), diff --git a/packages/web/components/acl.tsx b/packages/web/components/acl.tsx index b43f3b26..180ce310 100644 --- a/packages/web/components/acl.tsx +++ b/packages/web/components/acl.tsx @@ -12,9 +12,15 @@ import { DataTable } from "./data-table"; import HashDisplay from "./hash-display"; import { type ACLItem } from "@/lib/validator-queries"; -type TableRowData = ACLItem & { - isOwner: boolean; -} & RouterOutputs["users"]["usersForAddresses"][string]; +type UserValue = + RouterOutputs["users"]["usersForAddresses"] extends Map + ? I + : never; + +type TableRowData = ACLItem & + Partial & { + isOwner: boolean; + }; interface Props { acl: ACLItem[]; @@ -24,7 +30,7 @@ interface Props { export default function ACL({ acl, authorizedStudioUsers, owner }: Props) { const data: TableRowData[] = acl.map((item) => { - const studioUser = authorizedStudioUsers[item.controller]; + const studioUser = authorizedStudioUsers.get(item.controller); const isOwner = item.controller === owner; return { ...item, @@ -116,5 +122,9 @@ function UserCell({ setValue(initialValue); }, [initialValue]); - return {value}; + return row.original.team ? ( + {value} + ) : ( + {value} + ); } diff --git a/packages/web/components/table-details.tsx b/packages/web/components/table-details.tsx index 50788ead..620daaa1 100644 --- a/packages/web/components/table-details.tsx +++ b/packages/web/components/table-details.tsx @@ -56,7 +56,9 @@ export default function TableDetails({ const authorizedStudioUser = useMemo( () => - addressPostMount ? authorizedStudioUsers[addressPostMount] : undefined, + addressPostMount + ? authorizedStudioUsers.get(addressPostMount) + : undefined, [addressPostMount, authorizedStudioUsers], ); diff --git a/packages/web/components/table.tsx b/packages/web/components/table.tsx index 9d865baf..6124d6e0 100644 --- a/packages/web/components/table.tsx +++ b/packages/web/components/table.tsx @@ -87,7 +87,9 @@ export default async function Table({ const openSeaLink = openSeaLinks.get(chainId); let tablePermissions: TablePermissions | undefined; - let authorizedStudioUsers: RouterOutputs["users"]["usersForAddresses"] = {}; + let authorizedStudioUsers: + | RouterOutputs["users"]["usersForAddresses"] + | undefined; let data: Result> | undefined; let error: Error | undefined; try { @@ -98,7 +100,7 @@ export default async function Table({ ? await api.users.usersForAddresses({ addresses: authorizedAddresses, }) - : {}; + : undefined; const tbl = new Database({ baseUrl }); data = await tbl.prepare(`SELECT * FROM ${tableName};`).all(); } catch (err) { @@ -109,7 +111,7 @@ export default async function Table({ await api.deployments.deploymentReferences({ chainId, tableId }) ).filter((p) => p.environment.id !== environment?.id); - const ownerStudioUser = owner ? authorizedStudioUsers[owner] : undefined; + const ownerStudioUser = owner ? authorizedStudioUsers?.get(owner) : undefined; const displayName = defData?.name ?? tableName; From da26c59f35fef911318386131a93671a6eb34841 Mon Sep 17 00:00:00 2001 From: Aaron Sutula Date: Sun, 14 Jul 2024 17:46:59 -0600 Subject: [PATCH 17/25] fix console freezing Signed-off-by: Aaron Sutula --- packages/web/components/console.tsx | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/packages/web/components/console.tsx b/packages/web/components/console.tsx index 1165e26b..fa9f2f30 100644 --- a/packages/web/components/console.tsx +++ b/packages/web/components/console.tsx @@ -83,7 +83,7 @@ export function Console({ environmentId }: { environmentId: string }) { tab.error = null; tab.messages = []; tab.columns = columns; - tab.results = data.results; + tab.results = objectToTableData(data.results); // if there is a transactionHash it means that this is the response from a mutation // the template rendering logic will key off the existence of messages @@ -219,11 +219,7 @@ export function Console({ environmentId }: { environmentId: string }) { loading={loading} /> - +
); })} @@ -363,10 +359,9 @@ function TabLabel(props: { function ResultSetPane(props: any): React.JSX.Element { const { tab } = props; - const data = objectToTableData(tab.results); const table = useReactTable({ - data, + data: tab.results, columns: tab.columns, getCoreRowModel: getCoreRowModel(), }); From 3f68cdbbed269fa84d968e0f6f5a506a4598ed57 Mon Sep 17 00:00:00 2001 From: Aaron Sutula Date: Mon, 15 Jul 2024 14:09:47 -0600 Subject: [PATCH 18/25] use proper signer for table updates Signed-off-by: Aaron Sutula --- packages/web/components/acl.tsx | 2 +- packages/web/components/exec-deployment.tsx | 31 +------- packages/web/components/table-data.tsx | 85 ++++++++++++--------- packages/web/components/table-details.tsx | 1 + packages/web/lib/wagmi-ethers.ts | 28 +++++++ 5 files changed, 82 insertions(+), 65 deletions(-) create mode 100644 packages/web/lib/wagmi-ethers.ts diff --git a/packages/web/components/acl.tsx b/packages/web/components/acl.tsx index 180ce310..6157416f 100644 --- a/packages/web/components/acl.tsx +++ b/packages/web/components/acl.tsx @@ -46,7 +46,7 @@ export default function ACL({ acl, authorizedStudioUsers, owner }: Props) { cell: AddressCell, }, { - accessorKey: "team.name", + accessorFn: (row) => row.team?.name ?? "", header: "Studio User", cell: UserCell, }, diff --git a/packages/web/components/exec-deployment.tsx b/packages/web/components/exec-deployment.tsx index d0896d46..7201cd83 100644 --- a/packages/web/components/exec-deployment.tsx +++ b/packages/web/components/exec-deployment.tsx @@ -9,11 +9,10 @@ import { generateCreateTableStatement, type schema, } from "@tableland/studio-store"; -import { JsonRpcSigner, BrowserProvider } from "ethers"; +import { type JsonRpcSigner } from "ethers"; import { AlertCircle, CheckCircle2, CircleDashed, Loader2 } from "lucide-react"; import { useRouter } from "next/navigation"; -import { useMemo, useState, useTransition } from "react"; -import { useWalletClient, type WalletClient } from "wagmi"; +import { useState, useTransition } from "react"; import { getNetwork, getWalletClient, @@ -33,6 +32,7 @@ import { } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import ChainSelector from "@/components/chain-selector"; +import { walletClientToSigner } from "@/lib/wagmi-ethers"; export default function ExecDeployment({ open, @@ -305,28 +305,3 @@ function DeployStep({ } } } - -export function walletClientToSigner( - walletClient: WalletClient, -): JsonRpcSigner { - const { account, chain, transport } = walletClient; - const network = { - chainId: chain.id, - name: chain.name, - ensAddress: chain.contracts?.ensRegistry?.address, - }; - const provider = new BrowserProvider(transport, network); - const signer = new JsonRpcSigner(provider, account.address); - return signer; -} - -/** Hook to convert a viem Wallet Client to an ethers.js Signer. */ -export function useEthersSigner({ chainId }: { chainId?: number } = {}): - | JsonRpcSigner - | undefined { - const { data: walletClient } = useWalletClient({ chainId }); - return useMemo( - () => (walletClient ? walletClientToSigner(walletClient) : undefined), - [walletClient], - ); -} diff --git a/packages/web/components/table-data.tsx b/packages/web/components/table-data.tsx index dfb9dc9d..2e9e03e7 100644 --- a/packages/web/components/table-data.tsx +++ b/packages/web/components/table-data.tsx @@ -1,6 +1,6 @@ "use client"; -import { Database } from "@tableland/sdk"; +import { Database, helpers } from "@tableland/sdk"; import { drizzle } from "drizzle-orm/d1"; import { integer, int, sqliteTable, text, blob } from "drizzle-orm/sqlite-core"; import { eq, and } from "drizzle-orm/expressions"; @@ -18,6 +18,7 @@ import { updatedDiff } from "deep-object-diff"; import { type Schema, hasConstraint } from "@tableland/studio-store"; import { ChevronDown, Loader2 } from "lucide-react"; import { useRouter } from "next/navigation"; +import { getNetwork, getWalletClient, switchNetwork } from "wagmi/actions"; import { DataTable } from "./data-table"; import TableCell from "./table-cell"; import { EditCell } from "./edit-cell"; @@ -39,6 +40,7 @@ import { } from "@/components/ui/dropdown-menu"; import { type ACLItem } from "@/lib/validator-queries"; import { ensureError } from "@/lib/ensure-error"; +import { walletClientToSigner } from "@/lib/wagmi-ethers"; type NonEmptyArray = [T, ...T[]]; @@ -49,17 +51,15 @@ interface Updates { } interface TableDataProps { + chainId: number; tableName: string; schema: Schema; initialData: Array>; accountPermissions?: ACLItem; } -const tbl = new Database({ - autoWait: true, -}); - export function TableData({ + chainId, tableName, schema, initialData, @@ -68,33 +68,6 @@ export function TableData({ const router = useRouter(); const { toast } = useToast(); - const drizzleSchema = useMemo( - () => - schema.columns.reduce>((acc, col) => { - if (col.type === "text") { - acc[col.name] = text(col.name); - } else if (col.type === "integer") { - acc[col.name] = integer(col.name); - } else if (col.type === "int") { - acc[col.name] = int(col.name); - } else if (col.type === "blob") { - acc[col.name] = blob(col.name); - } - return acc; - }, {}), - [schema], - ); - - const drizzleTable = useMemo( - () => sqliteTable(tableName, drizzleSchema), - [tableName, drizzleSchema], - ); - - const db = useMemo( - () => drizzle(tbl, { schema: drizzleTable, logger: false }), - [drizzleTable], - ); - const initialRows: ExistingRowData[] = useMemo( () => objectToTableData(initialData).map((row) => ({ @@ -139,7 +112,7 @@ export function TableData({ const columns: | Array | DisplayColumnDef> | undefined = schema.columns.map((col) => ({ - accessorKey: `data.${col.name}`, + accessorFn: (row) => row.data[col.name] ?? "", header: col.name, cell: TableCell, meta: { @@ -316,7 +289,43 @@ export function TableData({ }, }); - const executeStatements = () => { + const executeStatements = async () => { + const drizzleSchema = schema.columns.reduce>( + (acc, col) => { + if (col.type === "text") { + acc[col.name] = text(col.name); + } else if (col.type === "integer") { + acc[col.name] = integer(col.name); + } else if (col.type === "int") { + acc[col.name] = int(col.name); + } else if (col.type === "blob") { + acc[col.name] = blob(col.name); + } + return acc; + }, + {}, + ); + const drizzleTable = sqliteTable(tableName, drizzleSchema); + + const currentNetwork = getNetwork(); + if (currentNetwork.chain?.id !== chainId) { + await switchNetwork({ chainId }); + } + + const walletClient = await getWalletClient({ + chainId, + }); + if (!walletClient) { + throw new Error("Unable to get wallet client"); + } + const signer = walletClientToSigner(walletClient); + const tbl = new Database({ + signer, + baseUrl: helpers.getBaseUrl(chainId), + autoWait: true, + }); + const db = drizzle(tbl, { schema: drizzleTable, logger: false }); + const genWhereConstraints = (row: EditedRowData | DeletedRowData) => { if (pkName) { return eq(drizzleTable[pkName], row.originalData.data[pkName]); @@ -350,8 +359,12 @@ export function TableData({ ...sqlUpdateItems, ...sqlDeleteItems, ] as NonEmptyArray; + await db.batch(batch); + }; + + const handleSave = () => { setPendingTxn(true); - db.batch(batch) + executeStatements() .then(() => router.refresh()) .catch((err) => { toast({ @@ -369,7 +382,7 @@ export function TableData({
{editing && ( <> - diff --git a/packages/web/components/table-details.tsx b/packages/web/components/table-details.tsx index 620daaa1..1cebf692 100644 --- a/packages/web/components/table-details.tsx +++ b/packages/web/components/table-details.tsx @@ -156,6 +156,7 @@ export default function TableDetails({
(walletClient ? walletClientToSigner(walletClient) : undefined), + [walletClient], + ); +} From 3c26a2731c5d9b3dcdeb82c48591dca093957a7a Mon Sep 17 00:00:00 2001 From: Aaron Sutula Date: Mon, 15 Jul 2024 16:18:35 -0600 Subject: [PATCH 19/25] remove console log Signed-off-by: Aaron Sutula --- packages/web/components/table-data.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/web/components/table-data.tsx b/packages/web/components/table-data.tsx index 2e9e03e7..bbdffac5 100644 --- a/packages/web/components/table-data.tsx +++ b/packages/web/components/table-data.tsx @@ -104,11 +104,11 @@ export function TableData({ return res; }, [data]); - const [columnVisibility, setColumnVisibility] = useState({}); - const editing = !!updates.new.length || !!updates.edited.length || !!updates.deleted.length; + const [columnVisibility, setColumnVisibility] = useState({}); + const columns: | Array | DisplayColumnDef> | undefined = schema.columns.map((col) => ({ @@ -186,7 +186,6 @@ export function TableData({ tableRowData.originalData.data, data, ); - console.log("DIFF", diff); return { ...tableRowData, data, From 584a0ed68dcaa3312f858b974e78d63350f30247 Mon Sep 17 00:00:00 2001 From: Aaron Sutula Date: Wed, 17 Jul 2024 10:21:05 -0600 Subject: [PATCH 20/25] pr feedback changes Signed-off-by: Aaron Sutula --- packages/web/components/data-table.tsx | 116 ++++++++++--------------- packages/web/components/table-cell.tsx | 2 +- packages/web/components/table-data.tsx | 7 -- 3 files changed, 47 insertions(+), 78 deletions(-) diff --git a/packages/web/components/data-table.tsx b/packages/web/components/data-table.tsx index 279e9512..cffb9ca1 100644 --- a/packages/web/components/data-table.tsx +++ b/packages/web/components/data-table.tsx @@ -1,6 +1,5 @@ import { flexRender, type Table as TSTable } from "@tanstack/react-table"; import React from "react"; -import { Button } from "@/components/ui/button"; import { Table, TableBody, @@ -16,76 +15,53 @@ interface DataTableProps { export function DataTable({ table }: DataTableProps) { return ( -
-
-
- - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => { - return ( - - {header.isPlaceholder - ? null - : flexRender( - header.column.columnDef.header, - header.getContext(), - )} - - ); - })} +
+
+ + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ); + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} - ))} - - - {table.getRowModel().rows?.length ? ( - table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - {flexRender( - cell.column.columnDef.cell, - cell.getContext(), - )} - - ))} - - )) - ) : ( - - - No results. - - - )} - -
-
-
- - -
+ )) + ) : ( + + + No results. + + + )} + +
); } diff --git a/packages/web/components/table-cell.tsx b/packages/web/components/table-cell.tsx index a98f8444..ad3ad7b5 100644 --- a/packages/web/components/table-cell.tsx +++ b/packages/web/components/table-cell.tsx @@ -30,7 +30,7 @@ export default function TableCell({ value={value} onChange={(e) => setValue( - columnMeta?.type === "number" + columnMeta?.type === "number" && !isNaN(e.target.valueAsNumber) ? e.target.valueAsNumber : e.target.value, ) diff --git a/packages/web/components/table-data.tsx b/packages/web/components/table-data.tsx index bbdffac5..a217c1dd 100644 --- a/packages/web/components/table-data.tsx +++ b/packages/web/components/table-data.tsx @@ -9,7 +9,6 @@ import { type DisplayColumnDef, type VisibilityState, getCoreRowModel, - getPaginationRowModel, useReactTable, type Row, } from "@tanstack/react-table"; @@ -135,13 +134,7 @@ export function TableData({ data, columns, getCoreRowModel: getCoreRowModel(), - getPaginationRowModel: getPaginationRowModel(), onColumnVisibilityChange: setColumnVisibility, - initialState: { - pagination: { - pageSize: 15, - }, - }, state: { columnVisibility, }, From 71a2c75739c737e20384e30d18ca7a632c3d1179 Mon Sep 17 00:00:00 2001 From: Aaron Sutula Date: Wed, 17 Jul 2024 10:24:43 -0600 Subject: [PATCH 21/25] use unique constraint in addition to pk for where clause calculation Signed-off-by: Aaron Sutula --- packages/web/components/table-data.tsx | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/packages/web/components/table-data.tsx b/packages/web/components/table-data.tsx index a217c1dd..21afbc23 100644 --- a/packages/web/components/table-data.tsx +++ b/packages/web/components/table-data.tsx @@ -121,12 +121,13 @@ export function TableData({ })); columns.push({ id: "edit", cell: EditCell }); - // TODO: support composite primary keys - const pkName = schema.columns.find( - (col) => - hasConstraint(col, "primary key") || - hasConstraint(col, "primary key autoincrement"), - )?.name; + // TODO: support composite constraints + const uniqueColumnName = + schema.columns.find( + (col) => + hasConstraint(col, "primary key") || + hasConstraint(col, "primary key autoincrement"), + )?.name ?? schema.columns.find((col) => hasConstraint(col, "unique"))?.name; const [pendingTxn, setPendingTxn] = useState(false); @@ -139,7 +140,7 @@ export function TableData({ columnVisibility, }, meta: { - pkName, + pkName: uniqueColumnName, accountPermissions, pendingTxn, editRow: (rowToEdit: Row) => { @@ -319,8 +320,11 @@ export function TableData({ const db = drizzle(tbl, { schema: drizzleTable, logger: false }); const genWhereConstraints = (row: EditedRowData | DeletedRowData) => { - if (pkName) { - return eq(drizzleTable[pkName], row.originalData.data[pkName]); + if (uniqueColumnName) { + return eq( + drizzleTable[uniqueColumnName], + row.originalData.data[uniqueColumnName], + ); } const eqs = Object.entries(row.originalData.data).map(([key, value]) => { return eq(drizzleTable[key], value); From 4a9c9c11fedaf69bfec7a1ab8ae58004d5d6bbe9 Mon Sep 17 00:00:00 2001 From: Aaron Sutula Date: Wed, 17 Jul 2024 10:56:43 -0600 Subject: [PATCH 22/25] warning ui for no unique constraint Signed-off-by: Aaron Sutula --- packages/web/components/table-data.tsx | 28 +++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/packages/web/components/table-data.tsx b/packages/web/components/table-data.tsx index 21afbc23..c0e6ef7f 100644 --- a/packages/web/components/table-data.tsx +++ b/packages/web/components/table-data.tsx @@ -15,7 +15,7 @@ import { import { useEffect, useMemo, useState } from "react"; import { updatedDiff } from "deep-object-diff"; import { type Schema, hasConstraint } from "@tableland/studio-store"; -import { ChevronDown, Loader2 } from "lucide-react"; +import { AlertTriangle, ChevronDown, Loader2 } from "lucide-react"; import { useRouter } from "next/navigation"; import { getNetwork, getWalletClient, switchNetwork } from "wagmi/actions"; import { DataTable } from "./data-table"; @@ -40,6 +40,12 @@ import { import { type ACLItem } from "@/lib/validator-queries"; import { ensureError } from "@/lib/ensure-error"; import { walletClientToSigner } from "@/lib/wagmi-ethers"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; type NonEmptyArray = [T, ...T[]]; @@ -376,6 +382,26 @@ export function TableData({ <>
+ {!uniqueColumnName && ( + + + + + + + Studio was unable to identify a unique column for this table. + This is due to the lack of a primary key or unique constraint, + or because the primary key or unique constraint is a composite + constraint (Studio will support composite constraints soon!). +
+
+ If your table contains rows with completely duplicate data, + and you edit or delete one of those rows, all duplicate rows + will be edited or deleted. Proceed with caution. +
+
+
+ )} {editing && ( <>