From cb5daab17583c255481afb34e9649bffaf917bbd Mon Sep 17 00:00:00 2001 From: Jonathan Date: Fri, 14 Nov 2025 12:44:27 +0100 Subject: [PATCH] Refactor AssetsPage and introduce a new EditableTable component Signed-off-by: Jonathan --- .../src/components/table/EditableTable.tsx | 158 ++++++++ .../components/table/SortableDataTable.tsx | 108 +++++ apps/console/src/hooks/useStateWithSchema.ts | 38 +- .../pages/organizations/assets/AssetsPage.tsx | 379 ++++++++---------- 4 files changed, 447 insertions(+), 236 deletions(-) create mode 100644 apps/console/src/components/table/EditableTable.tsx create mode 100644 apps/console/src/components/table/SortableDataTable.tsx diff --git a/apps/console/src/components/table/EditableTable.tsx b/apps/console/src/components/table/EditableTable.tsx new file mode 100644 index 000000000..e6099571c --- /dev/null +++ b/apps/console/src/components/table/EditableTable.tsx @@ -0,0 +1,158 @@ +import { + SortableCellHead, + SortableDataTable, +} from "/components/table/SortableDataTable.tsx"; +import type { GraphQLTaggedNode, OperationType } from "relay-runtime"; +import type { KeyType, KeyTypeData } from "react-relay/relay-hooks/helpers"; +import type { usePaginationFragmentHookType } from "react-relay/relay-hooks/usePaginationFragment"; +import { + Button, + Cell, + CellHead, + IconCheckmark1, + Row, + RowButton, + Spinner, +} from "@probo/ui"; +import { z } from "zod"; +import { useMutateField } from "/hooks/useMutateField.tsx"; +import { type ReactNode } from "react"; +import { useToggle } from "@probo/hooks"; +import { useStateWithSchema } from "/hooks/useStateWithSchema.ts"; +import { useMutation } from "react-relay"; +import clsx from "clsx"; + +type ColumnDefinition = { label: string; field: string } | string; + +type EditableTableRowProps = { + item?: T; + onUpdate: (key: keyof z.infer, value: z.infer[typeof key]) => void; + errors: Record; +}; + +/** + * A "all-in-one" component to create a table with editable cells. + */ +export function EditableTable< + T extends { id: string }, + S extends z.ZodSchema, +>(props: { + // Schema to create a new item + schema: S; + // GraphQL related props + connectionId: string; + pagination: usePaginationFragmentHookType< + OperationType, + KeyType, + KeyTypeData + >; + updateMutation: GraphQLTaggedNode; + createMutation: GraphQLTaggedNode; + items: T[]; + // List of the columns + columns: ColumnDefinition[]; + // Render a row for each item and to create a new item + row: (props: EditableTableRowProps) => ReactNode; + // Render the content of the last cell + action: (props: { item: T }) => ReactNode; + // Label used when adding a new item + addLabel: string; + // Default value used when creating a new item + defaultValue: z.infer; +}) { + const { update } = useMutateField(props.updateMutation); + const [showAdd, toggleAdd] = useToggle(false); + + return ( + "1fr"), "56px"]} + refetch={props.pagination.refetch} + hasNext={props.pagination.hasNext} + isLoadingNext={props.pagination.isLoadingNext} + loadNext={props.pagination.loadNext} + > + + {props.columns.map((column, index) => ( + + ))} + + + {props.items.map((item) => ( + + {props.row({ + item, + onUpdate: (key, value) => update(item.id, key as string, value), + errors: {}, + })} + {props.action({ item })} + + ))} + {showAdd ? ( + + ) : ( + {props.addLabel} + )} + + ); +} + +function NewItemRow(props: { + schema: S; + defaultValue: z.infer; + connectionId: string; + mutation: GraphQLTaggedNode; + row: (props: EditableTableRowProps) => ReactNode; +}) { + const { update, errors, value } = useStateWithSchema( + props.schema, + props.defaultValue, + ); + const [mutate, isMutating] = useMutation(props.mutation); + const isOk = Object.keys(errors ?? {}).length === 0; + + const onSubmit = async () => { + // This should never happen, but we don't want to send bad data + if (!isOk) { + alert("Please fix the errors before submitting."); + return; + } + mutate({ + variables: { + input: value, + connections: [props.connectionId], + }, + }); + }; + return ( + + {props.row({ errors, onUpdate: update })} + + + + + ); +} + +function EditableTableHead(props: { column: ColumnDefinition }) { + if (typeof props.column === "string") { + return {props.column}; + } + return ( + + {props.column.label} + + ); +} diff --git a/apps/console/src/components/table/SortableDataTable.tsx b/apps/console/src/components/table/SortableDataTable.tsx new file mode 100644 index 000000000..70d88749e --- /dev/null +++ b/apps/console/src/components/table/SortableDataTable.tsx @@ -0,0 +1,108 @@ +import { useTranslate } from "@probo/i18n"; +import { + Button, + CellHead, + DataTable, + IconChevronDown, + IconChevronTriangleDownSmall, + Spinner, +} from "@probo/ui"; +import clsx from "clsx"; +import { + type ComponentProps, + createContext, + startTransition, + useContext, + useState, +} from "react"; + +type Order = { + direction: string; + field: string; +}; + +export const SortableContext = createContext({ + order: { + direction: "DESC", + field: "CREATED_AT", + }, + onOrderChange: (() => {}) as (order: Order) => void, +}); + +const defaultOrder = { + direction: "DESC", + field: "CREATED_AT", +} as Order; + +export function SortableDataTable({ + refetch, + hasNext, + loadNext, + isLoadingNext, + ...props +}: ComponentProps & { + refetch: (o: { order: Order }) => void; + hasNext?: boolean; + loadNext?: (...args: any[]) => void; + isLoadingNext?: boolean; +}) { + const { __ } = useTranslate(); + const [order, setOrder] = useState(defaultOrder); + const onOrderChange = (o: Order) => { + startTransition(() => { + setOrder(o); + refetch({ order: o }); + }); + }; + return ( + +
+ + {hasNext && loadNext && ( + + )} +
+
+ ); +} + +export function SortableCellHead({ + children, + field, + ...props +}: ComponentProps & { field: string }) { + const { order, onOrderChange } = useContext(SortableContext); + const isCurrentField = order.field === field; + const isDesc = order.direction === "DESC"; + const changeOrder = () => { + onOrderChange({ + direction: isDesc && isCurrentField ? "ASC" : "DESC", + field, + }); + }; + return ( + + + + ); +} diff --git a/apps/console/src/hooks/useStateWithSchema.ts b/apps/console/src/hooks/useStateWithSchema.ts index 20c297720..29e4b33c4 100644 --- a/apps/console/src/hooks/useStateWithSchema.ts +++ b/apps/console/src/hooks/useStateWithSchema.ts @@ -1,31 +1,39 @@ import { z, ZodError, type ZodTypeAny } from "zod"; -import { useMemo, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; export function useStateWithSchema( schema: T, initialValue: z.infer, ) { const [state, setState] = useState(initialValue); - const errors = useMemo(() => { + const [value, errors] = useMemo((): [z.infer, Record] => { try { - schema.parse(state); - return {}; + return [schema.parse(state), {}]; } catch (error) { if (error instanceof ZodError) { - return Object.fromEntries( - error.issues.map((issue) => [issue.path.join("."), issue.message]) ?? - [], - ); + return [ + state, + Object.fromEntries( + error.issues.map((issue) => [ + issue.path.join("."), + issue.message, + ]) ?? [], + ), + ]; } - return {}; + return [state, {}]; } }, [state, schema]); - return [ - state, - (key: keyof z.infer, value: z.infer[typeof key]) => { - setState((prevState) => ({ ...prevState, [key]: value })); - }, + return { + rawValue: value, + value, errors, - ] as const; + update: useCallback( + (key: keyof z.infer, value: z.infer[typeof key]) => { + setState((prevState) => ({ ...prevState, [key]: value })); + }, + [], + ), + }; } diff --git a/apps/console/src/pages/organizations/assets/AssetsPage.tsx b/apps/console/src/pages/organizations/assets/AssetsPage.tsx index 0ffcb15a8..c6b8455f1 100644 --- a/apps/console/src/pages/organizations/assets/AssetsPage.tsx +++ b/apps/console/src/pages/organizations/assets/AssetsPage.tsx @@ -3,25 +3,20 @@ import { Avatar, Badge, Button, - Cell, - CellHead, - DataTable, DropdownItem, EditableCell, - IconCheckmark1, IconCrossLargeX, IconPlusLarge, IconTrashCan, PageHeader, - Row, - RowButton, - Spinner, + useConfirm, } from "@probo/ui"; import { useTranslate } from "@probo/i18n"; -import { usePageTitle, useToggle } from "@probo/hooks"; +import { usePageTitle } from "@probo/hooks"; import { graphql, type PreloadedQuery, + useMutation, usePaginationFragment, usePreloadedQuery, } from "react-relay"; @@ -30,30 +25,24 @@ import { useParams } from "react-router"; import { CreateAssetDialog } from "./dialogs/CreateAssetDialog"; import { assetsQuery, + createAssetMutation, + deleteAssetMutation, updateAssetMutation, - useCreateAsset, - useDeleteAsset, } from "../../../hooks/graph/AssetGraph"; import type { AssetGraphListQuery } from "/hooks/graph/__generated__/AssetGraphListQuery.graphql"; -import { faviconUrl, getAssetTypeVariant } from "@probo/helpers"; -import type { NodeOf } from "/types"; -import type { - AssetsPageFragment$data, - AssetsPageFragment$key, -} from "./__generated__/AssetsPageFragment.graphql"; -import { SnapshotBanner } from "/components/SnapshotBanner"; import { - type MutationFieldUpdate, - useMutateField, -} from "/hooks/useMutateField.tsx"; -import type { UpdateAssetInput } from "/hooks/graph/__generated__/AssetGraphUpdateMutation.graphql.ts"; + faviconUrl, + getAssetTypeVariant, + promisifyMutation, + sprintf, +} from "@probo/helpers"; +import type { AssetsPageFragment$key } from "./__generated__/AssetsPageFragment.graphql"; +import { SnapshotBanner } from "/components/SnapshotBanner"; import z from "zod"; -import { useStateWithSchema } from "/hooks/useStateWithSchema.ts"; import { usePeople } from "/hooks/graph/PeopleGraph.ts"; import { useVendors } from "/hooks/graph/VendorGraph.ts"; -import clsx from "clsx"; -import { Authorized } from "/permissions"; -import { isAuthorized } from "/permissions"; +import { EditableTable } from "/components/table/EditableTable.tsx"; +import { Authorized, isAuthorized } from "/permissions"; const paginatedAssetsFragment = graphql` fragment AssetsPageFragment on Organization @@ -103,12 +92,30 @@ const paginatedAssetsFragment = graphql` } `; -type AssetEntry = NodeOf; - type Props = { queryRef: PreloadedQuery; }; +const schema = z.object({ + name: z.string().trim().min(1, "Name is required"), + amount: z.coerce.number().min(0, "Amount is required"), + assetType: z.enum(["PHYSICAL", "VIRTUAL"]), + ownerId: z.string().trim().min(1, "Owner is required"), + vendorIds: z.array(z.string()).optional(), + dataTypesStored: z.string().trim().min(1, "Data types stored is required"), + organizationId: z.string().trim().min(1, "Organization is required"), +}); + +const defaultValue = { + name: "", + amount: 0, + assetType: "VIRTUAL", + ownerId: "", + vendorIds: [], + dataTypesStored: "", + organizationId: "", +} satisfies z.infer; + export default function AssetsPage(props: Props) { const { __ } = useTranslate(); const organizationId = useOrganizationId(); @@ -122,16 +129,14 @@ export default function AssetsPage(props: Props) { ); const assets = pagination.data.assets?.edges.map((edge) => edge.node); const connectionId = pagination.data.assets.__id; + const deleteAsset = useDeleteAsset(connectionId); + const hasAnyAction = + !isSnapshotMode && + (isAuthorized(organizationId, "Asset", "updateAsset") || + isAuthorized(organizationId, "Asset", "deleteAsset")); usePageTitle(__("Assets")); - const hasAnyAction = !isSnapshotMode && ( - isAuthorized(organizationId, "Asset", "updateAsset") || - isAuthorized(organizationId, "Asset", "deleteAsset") - ); - const { update } = useMutateField(updateAssetMutation); - const [showAdd, toggleAdd] = useToggle(false); - return (
{snapshotId && } @@ -152,193 +157,30 @@ export default function AssetsPage(props: Props) { )} - "1fr"), "56px"]} - > - - {__("Name")} - {__("Type")} - {__("Data Types stored")} - {__("Amount")} - {__("Owner")} - {__("Vendors")} - - - {assets.map((entry) => ( - update(entry.id, field, value)} - /> - ))} - {showAdd ? ( - - ) : ( - {__("Add a new asset")} - )} - -
- ); -} - -const schema = z.object({ - name: z.string().min(1, "Name is required"), - amount: z.coerce.number().min(1, "Amount is required"), - assetType: z.enum(["PHYSICAL", "VIRTUAL"]), - ownerId: z.string().min(1, "Owner is required"), - vendorIds: z.array(z.string()).optional(), - dataTypesStored: z.string().min(1, "Data types stored is required"), -}); - -function AssetAddRow({ - organizationId, - onSuccess, - connection, -}: { - organizationId: string; - onSuccess: () => void; - connection: string; -}) { - const [value, setValue, errors] = useStateWithSchema(schema, { - name: "", - amount: 0, - assetType: "VIRTUAL", - ownerId: "", - vendorIds: [], - dataTypesStored: "", - }); - - const [createAsset, isMutating] = useCreateAsset(connection); - - const onSubmit = async () => { - await createAsset({ - ...value, - organizationId, - }); - onSuccess(); - }; - - return ( - - ); -} - -function AssetRow({ - entry, - connectionId, - onUpdate, - onSubmit, - errors, - loading, -}: { - entry?: AssetEntry; - connectionId?: string; - onUpdate: MutationFieldUpdate; - onSubmit?: () => void; - errors?: Record; - loading?: boolean; -}) { - const organizationId = useOrganizationId(); - const { __ } = useTranslate(); - const { snapshotId } = useParams<{ snapshotId?: string }>(); - const isSnapshotMode = Boolean(snapshotId); - const deleteAsset = useDeleteAsset(entry, connectionId); - const isOk = Object.keys(errors ?? {}).length === 0; - return ( - - onUpdate("name", v)} - blink={Boolean(errors?.name)} - /> - ( - - {item === "PHYSICAL" ? __("Physical") : __("Virtual")} - - )} - onValueChange={(v) => onUpdate("assetType", v)} - blink={Boolean(errors?.assetType)} - /> - onUpdate("dataTypesStored", v)} - blink={Boolean(errors?.dataTypeStored)} - /> - onUpdate("amount", v)} - blink={Boolean(errors?.amount)} - /> - usePeople(organizationId, { excludeContractEnded: true })} - value={entry?.owner} - itemRenderer={({ item }) => ( -
- - {item.fullName} -
- )} - onValueChange={(v) => onUpdate("ownerId", v.id)} - blink={Boolean(errors?.ownerId)} - /> - useVendors(organizationId)} - value={entry?.vendors.edges.map((edge) => edge.node)} - itemRenderer={({ item, onRemove }) => ( - - )} - onValueChange={(v) => - onUpdate( - "vendorIds", - v.map((v) => v.id), - ) - } - blink={Boolean(errors?.vendorIds)} - /> - - {loading && ( - - )} - {onSubmit && !loading && ( - - )} - {!isSnapshotMode && entry && ( + ( deleteAsset(item)} variant="danger" icon={IconTrashCan} > @@ -346,8 +188,72 @@ function AssetRow({ )} - -
+ row={({ item, onUpdate, errors }) => ( + <> + onUpdate("name", v)} + blink={Boolean(errors?.name)} + /> + ( + + {item === "PHYSICAL" ? __("Physical") : __("Virtual")} + + )} + onValueChange={(v) => onUpdate("assetType", v)} + blink={Boolean(errors?.assetType)} + /> + onUpdate("dataTypesStored", v)} + blink={Boolean(errors?.dataTypesStored)} + /> + onUpdate("amount", v)} + blink={Boolean(errors?.amount)} + /> + + usePeople(organizationId, { excludeContractEnded: true }) + } + value={item?.owner} + itemRenderer={({ item }) => ( +
+ + {item.fullName} +
+ )} + onValueChange={(v) => onUpdate("ownerId", v.id)} + blink={Boolean(errors?.ownerId)} + /> + useVendors(organizationId)} + value={item?.vendors.edges.map((edge) => edge.node)} + itemRenderer={({ item, onRemove }) => ( + + )} + onValueChange={(v) => + onUpdate( + "vendorIds", + v.map((v) => v.id), + ) + } + blink={Boolean(errors?.vendorIds)} + /> + + )} + /> + ); } @@ -381,3 +287,34 @@ function VendorBadge({ ); } + +const useDeleteAsset = (connectionId: string) => { + const [mutate] = useMutation(deleteAssetMutation); + const confirm = useConfirm(); + const { __ } = useTranslate(); + + return (asset: { id: string; name: string }) => { + if (!asset.id || !asset.name) { + return alert(__("Failed to delete asset: missing id or name")); + } + confirm( + () => + promisifyMutation(mutate)({ + variables: { + input: { + assetId: asset.id!, + }, + connections: [connectionId], + }, + }), + { + message: sprintf( + __( + 'This will permanently delete "%s". This action cannot be undone.', + ), + asset.name, + ), + }, + ); + }; +};