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/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..abbf5282 --- /dev/null +++ b/packages/api/src/routers/users.ts @@ -0,0 +1,21 @@ +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); + const res = users.reduce>( + (acc, item) => { + acc.set(item.user.address, item); + return acc; + }, + new Map(), + ); + return res; + }), + }); +} 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/[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/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/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/acl.tsx b/packages/web/components/acl.tsx new file mode 100644 index 00000000..6157416f --- /dev/null +++ b/packages/web/components/acl.tsx @@ -0,0 +1,130 @@ +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 UserValue = + RouterOutputs["users"]["usersForAddresses"] extends Map + ? I + : never; + +type TableRowData = ACLItem & + Partial & { + isOwner: boolean; + }; + +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.get(item.controller); + const isOwner = item.controller === owner; + return { + ...item, + ...studioUser, + isOwner, + }; + }); + + const columns: Array> = [ + { + accessorKey: "controller", + header: "Address", + cell: AddressCell, + }, + { + accessorFn: (row) => row.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 row.original.team ? ( + {value} + ) : ( + {value} + ); +} diff --git a/packages/web/components/console.tsx b/packages/web/components/console.tsx index 54ed76a3..fa9f2f30 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"; @@ -82,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 @@ -218,11 +219,7 @@ export function Console({ environmentId }: { environmentId: string }) { loading={loading} /> - + ); })} @@ -362,7 +359,12 @@ function TabLabel(props: { function ResultSetPane(props: any): React.JSX.Element { const { tab } = props; - const formattedData = objectToTableData(tab.results); + + const table = useReactTable({ + data: tab.results, + columns: tab.columns, + getCoreRowModel: getCoreRowModel(), + }); return (
@@ -379,9 +381,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 d63b731a..cffb9ca1 100644 --- a/packages/web/components/data-table.tsx +++ b/packages/web/components/data-table.tsx @@ -1,22 +1,5 @@ -"use client"; - -import { - type ColumnDef, - type VisibilityState, - flexRender, - getCoreRowModel, - getPaginationRowModel, - useReactTable, -} from "@tanstack/react-table"; -import { ChevronDown } from "lucide-react"; +import { flexRender, type Table as TSTable } from "@tanstack/react-table"; import React from "react"; -import { Button } from "@/components/ui/button"; -import { - DropdownMenu, - DropdownMenuCheckboxItem, - DropdownMenuContent, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; import { Table, TableBody, @@ -26,134 +9,59 @@ import { TableRow, } from "@/components/ui/table"; -interface DataTableProps { - columns: Array>; - data: TData[]; +interface DataTableProps { + table: TSTable; } -export function DataTable({ - columns, - data, -}: DataTableProps) { - const [columnVisibility, setColumnVisibility] = - React.useState({}); - - const table = useReactTable({ - data, - columns, - getCoreRowModel: getCoreRowModel(), - getPaginationRowModel: getPaginationRowModel(), - onColumnVisibilityChange: setColumnVisibility, - initialState: { - pagination: { - pageSize: 15, - }, - }, - state: { - columnVisibility, - }, - }); - +export function DataTable({ table }: DataTableProps) { return ( -
-
- {!!data.length && ( - - - - - - {table - .getAllColumns() - .filter((column) => column.getCanHide()) - .map((column) => { - return ( - - column.toggleVisibility(!!value) - } - > - {column.id} - - ); - })} - - - )} -
-
- - - {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(), - )} - - ))} - - )) - ) : ( - - - No results. - +
+
+ + {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())} + + ))} - )} - -
-
-
- - -
+ )) + ) : ( + + + No results. + + + )} + +
); } 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 new file mode 100644 index 00000000..d16215b6 --- /dev/null +++ b/packages/web/components/edit-cell.tsx @@ -0,0 +1,67 @@ +import { type Cell } from "@tanstack/react-table"; +import { Undo2, Trash2, Pencil } from "lucide-react"; +import { type TableRowData } from "./table-data-types"; +import { Button } from "./ui/button"; + +export function EditCell({ + row, + table, +}: ReturnType["getContext"]>) { + const meta = table.options.meta; + + const editRow = () => { + meta?.editRow(row); + }; + + const revertRow = () => { + meta?.revertRow(row); + }; + + const deleteRow = () => { + meta?.deleteRow(row); + }; + + const type = row.original.type; + + return ( +
+ {/* Edit */} + {meta?.accountPermissions?.privileges.update && type === "existing" && ( + + )} + {/* Revert */} + {(type === "edited" || type === "deleted") && ( + + )} + {/* Delete */} + {meta?.accountPermissions?.privileges.delete && + (type === "existing" || type === "edited" || type === "new") && ( + + )} +
+ ); +} 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/hash-display.tsx b/packages/web/components/hash-display.tsx index a927bcfa..b8b4236d 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,13 +32,11 @@ export default function HashDisplay({ : hash; return ( -
+
- - {slicedHash} - + {slicedHash}

{hash}

@@ -54,7 +52,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-cell.tsx b/packages/web/components/table-cell.tsx new file mode 100644 index 00000000..ad3ad7b5 --- /dev/null +++ b/packages/web/components/table-cell.tsx @@ -0,0 +1,48 @@ +import { useEffect, useState } from "react"; +import { type Cell } from "@tanstack/react-table"; +import { Input } from "./ui/input"; +import { type TableRowData } from "./table-data-types"; + +export default function TableCell({ + getValue, + row, + column, + table, +}: ReturnType["getContext"]>) { + const initialValue = getValue(); + const columnMeta = column.columnDef.meta; + + const [value, setValue] = useState(isValue(initialValue) ? initialValue : ""); + + useEffect(() => { + setValue(isValue(initialValue) ? initialValue : ""); + }, [initialValue]); + + const onBlur = () => { + if (!columnMeta) return; + table.options.meta?.updateRowColumn(row, columnMeta.columnName, value); + }; + + if (row.original.type === "edited" || row.original.type === "new") { + return ( + + setValue( + columnMeta?.type === "number" && !isNaN(e.target.valueAsNumber) + ? e.target.valueAsNumber + : e.target.value, + ) + } + onBlur={onBlur} + disabled={table.options.meta?.pendingTxn} + /> + ); + } + 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-types.ts b/packages/web/components/table-data-types.ts new file mode 100644 index 00000000..d3a61c82 --- /dev/null +++ b/packages/web/components/table-data-types.ts @@ -0,0 +1,28 @@ +export interface NewRowData { + type: "new"; + data: Record; +} + +export interface ExistingRowData { + type: "existing"; + data: Record; +} + +export interface EditedRowData { + type: "edited"; + data: Record; + originalData: ExistingRowData; + diff?: object; +} + +export interface DeletedRowData { + type: "deleted"; + data: Record; + originalData: ExistingRowData | EditedRowData; +} + +export type TableRowData = + | NewRowData + | ExistingRowData + | EditedRowData + | DeletedRowData; diff --git a/packages/web/components/table-data.tsx b/packages/web/components/table-data.tsx new file mode 100644 index 00000000..5184301c --- /dev/null +++ b/packages/web/components/table-data.tsx @@ -0,0 +1,478 @@ +"use client"; + +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"; +import { + type ColumnDef, + type DisplayColumnDef, + type VisibilityState, + getCoreRowModel, + useReactTable, + type Row, +} from "@tanstack/react-table"; +import { useEffect, useMemo, useState } from "react"; +import { updatedDiff } from "deep-object-diff"; +import { type Schema, hasConstraint } from "@tableland/studio-store"; +import { AlertTriangle, ChevronDown, Loader2 } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { getNetwork, getWalletClient, switchNetwork } from "wagmi/actions"; +import { decodeBase64 } from "ethers"; +import { DataTable } from "./data-table"; +import TableCell from "./table-cell"; +import { EditCell } from "./edit-cell"; +import { Button } from "./ui/button"; +import { + type TableRowData, + type ExistingRowData, + type NewRowData, + type EditedRowData, + type DeletedRowData, +} from "./table-data-types"; +import { useToast } from "./ui/use-toast"; +import { objectToTableData } from "@/lib/utils"; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +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[]]; + +interface Updates { + new: NewRowData[]; + edited: EditedRowData[]; + deleted: DeletedRowData[]; +} + +interface TableDataProps { + chainId: number; + tableName: string; + schema: Schema; + initialData: Array>; + accountPermissions?: ACLItem; +} + +export function TableData({ + chainId, + tableName, + schema, + initialData, + accountPermissions, +}: TableDataProps) { + const router = useRouter(); + const { toast } = useToast(); + + const initialRows: ExistingRowData[] = useMemo( + () => + objectToTableData(initialData).map((row) => ({ + type: "existing", + data: { ...row }, + })), + [initialData], + ); + + const [data, setData] = useState([]); + + useEffect(() => { + setData(() => [...initialRows]); + }, [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 editing = + !!updates.new.length || !!updates.edited.length || !!updates.deleted.length; + + const [columnVisibility, setColumnVisibility] = useState({}); + + const columns: + | Array | DisplayColumnDef> + | undefined = schema.columns.map((col) => ({ + accessorFn: (row) => row.data[col.name] ?? "", + header: col.name, + cell: TableCell, + meta: { + columnName: col.name, + type: col.type === "integer" || col.type === "int" ? "number" : "string", + }, + })); + columns.push({ id: "edit", cell: EditCell }); + + // 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); + + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + onColumnVisibilityChange: setColumnVisibility, + state: { + columnVisibility, + }, + meta: { + pkName: uniqueColumnName, + accountPermissions, + pendingTxn, + editRow: (rowToEdit: Row) => { + const tableRowData = rowToEdit.original; + switch (tableRowData.type) { + case "existing": + setData((old) => + old.map((row, index) => + index === rowToEdit.index + ? { + type: "edited", + data: { ...row.data }, + originalData: tableRowData, + } + : row, + ), + ); + break; + } + }, + updateRowColumn: ( + rowToUpdate: Row, + columnName: string, + value: string | number, + ) => { + const tableRowData = rowToUpdate.original; + switch (tableRowData.type) { + case "edited": + setData((old) => + old.map((row, index) => { + if (rowToUpdate.index === index) { + const data = { + ...tableRowData.data, + [columnName]: value, + }; + const diff = updatedDiff( + tableRowData.originalData.data, + data, + ); + return { + ...tableRowData, + data, + diff: Object.keys(diff).length ? diff : undefined, + }; + } + return row; + }), + ); + break; + case "new": + setData((old) => + old.map((row, index) => { + if (rowToUpdate.index === index) { + return { + ...tableRowData, + data: { + ...tableRowData.data, + [columnName]: value, + }, + }; + } + return row; + }), + ); + break; + } + }, + addRow: () => { + setData((old) => [{ type: "new", data: {} }, ...old]); + }, + deleteRow: (rowToDelete: Row) => { + const tableRowData = rowToDelete.original; + switch (tableRowData.type) { + case "existing": + setData((old) => + old.map((row, index) => + index === rowToDelete.index + ? { + data: { ...row.data }, + type: "deleted", + originalData: tableRowData, + } + : row, + ), + ); + break; + case "edited": + setData((old) => + old.map((row, index) => + index === rowToDelete.index + ? { + data: { ...row.data }, + type: "deleted", + originalData: tableRowData.originalData, + } + : row, + ), + ); + break; + case "new": + setData((old) => + old.filter((_, index) => index !== rowToDelete.index), + ); + 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" + : ""; + }, + }, + }); + + 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, { mode: "buffer" }); + } + 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: { [tableName]: drizzleTable }, + logger: false, + }); + + const genWhereConstraints = (row: EditedRowData | DeletedRowData) => { + 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); + }); + return and(...eqs); + }; + + const convertBlobFields = function (obj: Record) { + for (const [key, val] of Object.entries(obj)) { + if (typeof val !== "string") continue; + const column = schema.columns.find((c) => c.name === key); + if (column?.type === "blob") { + obj[key] = decodeBase64(val); + } + } + }; + + const sqlInsertItems = updates.new.map((row) => { + convertBlobFields(row.data); + return db.insert(drizzleTable).values(row.data); + }); + const sqlUpdateItems = updates.edited + .filter((update) => update.diff) + .map((row) => { + convertBlobFields(row.diff! as Record); + return db + .update(drizzleTable) + .set(row.diff!) + .where(genWhereConstraints(row)); + }); + const sqlDeleteItems = updates.deleted.map((row) => { + return db.delete(drizzleTable).where(genWhereConstraints(row)); + }); + type SQLItems = + | (typeof sqlInsertItems)[number] + | (typeof sqlUpdateItems)[number] + | (typeof sqlDeleteItems)[number]; + const batch = [ + ...sqlInsertItems, + ...sqlUpdateItems, + ...sqlDeleteItems, + ] as NonEmptyArray; + await db.batch(batch); + }; + + const handleSave = () => { + setPendingTxn(true); + executeStatements() + .then(() => router.refresh()) + .catch((err) => { + toast({ + title: "Error executing SQL statements", + description: ensureError(err).message, + variant: "destructive", + }); + }) + .finally(() => setPendingTxn(false)); + }; + + return ( + <> +
+
+ {!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 && ( + <> + + + + )} + {accountPermissions?.privileges.insert && ( + + )} +
+ {!!columns.length && ( + + + + + + {table + .getAllColumns() + .filter((column) => column.getCanHide()) + .map((column) => { + return ( + + column.toggleVisibility(!!value) + } + > + {column.id} + + ); + })} + + + )} +
+ + + ); +} diff --git a/packages/web/components/table-details.tsx b/packages/web/components/table-details.tsx new file mode 100644 index 00000000..518f28d0 --- /dev/null +++ b/packages/web/components/table-details.tsx @@ -0,0 +1,181 @@ +"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 ACL from "./acl"; +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( + () => + addressPostMount + ? authorizedStudioUsers.get(addressPostMount) + : undefined, + [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
  • + )} +
+
+
+
+ )} +
+
+ )} +
+ + + + + + + + + + + + +
+ ); +} diff --git a/packages/web/components/table.tsx b/packages/web/components/table.tsx index cfed46a3..6124d6e0 100644 --- a/packages/web/components/table.tsx +++ b/packages/web/components/table.tsx @@ -1,6 +1,5 @@ import { Database, type Schema, helpers, type Result } from "@tableland/sdk"; import { type schema } from "@tableland/studio-store"; -import { type ColumnDef } from "@tanstack/react-table"; import { Blocks, Coins, @@ -9,10 +8,12 @@ import { Rocket, Table2, Workflow, + Crown, } from "lucide-react"; import Link from "next/link"; -import { type RouterOutputs } from "@tableland/studio-api"; -import { DataTable } from "./data-table"; +import { getSession, type RouterOutputs } from "@tableland/studio-api"; +import { cookies, headers } from "next/headers"; +import { cache } from "react"; import { MetricCard, MetricCardContent, @@ -20,32 +21,22 @@ 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 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, objectToTableData } 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"; - -interface Props { - tableName: string; - chainId: number; - tableId: string; - createdAt: Date; - schema: Schema; - environment?: schema.Environment; - defData?: DefData; - deploymentData?: DeploymentData; - isAuthorized?: RouterOutputs["teams"]["isAuthorized"]; -} +import { + type TablePermissions, + getTablePermissions, +} from "@/lib/validator-queries"; interface DefData { id: string; @@ -61,10 +52,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, @@ -72,33 +77,44 @@ 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 tablePermissions: TablePermissions | undefined; + let authorizedStudioUsers: + | RouterOutputs["users"]["usersForAddresses"] + | undefined; let data: Result> | undefined; let error: Error | undefined; try { const baseUrl = helpers.getBaseUrl(chainId); + tablePermissions = await getTablePermissions(chainId, tableId); + const authorizedAddresses = Object.keys(tablePermissions); + authorizedStudioUsers = authorizedAddresses.length + ? await api.users.usersForAddresses({ + addresses: authorizedAddresses, + }) + : undefined; 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); + const ownerStudioUser = owner ? authorizedStudioUsers?.get(owner) : undefined; + + const displayName = defData?.name ?? tableName; + return (
@@ -165,6 +181,30 @@ export default async function Table({ )} + {owner && ( + + + + Owner + + + + + {ownerStudioUser && ( + + Studio user {ownerStudioUser.team.name} + {ownerStudioUser.user.teamId === session.auth?.user.teamId + ? " (you)" + : ""} + + )} + + )} {deploymentData?.txnHash && ( )}
- - - - {data && formattedData && columns ? ( - <> - Table Data - SQL Logs - Schema - - ) : ( -

- Definition -

- )} -
- {formattedData && columns && ( - - - - )} - {data && ( - - - - )} - - - -
+ {owner && authorizedStudioUsers && tablePermissions && 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/lib/validator-queries.ts b/packages/web/lib/validator-queries.ts index 904f6c02..d2caff2a 100644 --- a/packages/web/lib/validator-queries.ts +++ b/packages/web/lib/validator-queries.ts @@ -1,7 +1,7 @@ -import { helpers } from "@tableland/sdk"; +import { Validator, helpers } from "@tableland/sdk"; import { chainsMap } from "./chains-map"; -export interface Table { +export interface RegistryRecord { chain_id: number; controller: string; created_at: number; @@ -10,6 +10,30 @@ export interface Table { 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 }); + const [res] = await validator.queryByStatement({ + 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 +49,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 +103,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, @@ -174,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); diff --git a/packages/web/lib/wagmi-ethers.ts b/packages/web/lib/wagmi-ethers.ts new file mode 100644 index 00000000..9d07e7fa --- /dev/null +++ b/packages/web/lib/wagmi-ethers.ts @@ -0,0 +1,28 @@ +import { BrowserProvider, JsonRpcSigner } from "ethers"; +import { useMemo } from "react"; +import { type WalletClient, useWalletClient } from "wagmi"; + +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/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/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..44e5cba1 --- /dev/null +++ b/packages/web/types.d.ts @@ -0,0 +1,26 @@ +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; + accountPermissions?: ACLItem; + pendingTxn?: boolean; + editRow: (row: Row) => void; + updateRowColumn: ( + row: Row, + 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"; + } +}