From 789775f2feca5fa958f179f5d289dcec8c050e2f Mon Sep 17 00:00:00 2001 From: Jonathan Date: Fri, 14 Nov 2025 19:22:46 +0100 Subject: [PATCH] Simplify editable cell signature Signed-off-by: Jonathan --- .../src/components/table/EditableTable.tsx | 20 +- .../src/components/table/GraphQLCell.tsx | 137 ++++++++ .../src/components/table/PeopleCell.tsx | 33 ++ .../src/components/table/VendorsCell.tsx | 63 ++++ apps/console/src/hooks/graph/AssetGraph.ts | 2 +- apps/console/src/hooks/graph/PeopleGraph.ts | 44 ++- apps/console/src/hooks/graph/VendorGraph.ts | 14 +- .../AssetGraphCreateMutation.graphql.ts | 6 +- .../pages/organizations/assets/AssetsPage.tsx | 160 ++++----- apps/console/src/routes/assetRoutes.ts | 9 + packages/hooks/src/index.ts | 1 + packages/hooks/src/useStateWithRef.ts | 18 + .../src/Atoms/DataTable/DataTable.stories.tsx | 28 -- packages/ui/src/Atoms/DataTable/DataTable.tsx | 9 +- .../src/Molecules/Table/DataTable.stories.tsx | 65 ++++ .../ui/src/Molecules/Table/EditableCell.tsx | 322 +++--------------- .../ui/src/Molecules/Table/EditableRow.tsx | 46 +++ .../ui/src/Molecules/Table/SelectCell.tsx | 164 +++++++++ packages/ui/src/Molecules/Table/TextCell.tsx | 47 +++ packages/ui/src/Molecules/Table/utils.ts | 18 + packages/ui/src/index.ts | 7 + 21 files changed, 762 insertions(+), 451 deletions(-) create mode 100644 apps/console/src/components/table/GraphQLCell.tsx create mode 100644 apps/console/src/components/table/PeopleCell.tsx create mode 100644 apps/console/src/components/table/VendorsCell.tsx create mode 100644 packages/hooks/src/useStateWithRef.ts delete mode 100644 packages/ui/src/Atoms/DataTable/DataTable.stories.tsx create mode 100644 packages/ui/src/Molecules/Table/DataTable.stories.tsx create mode 100644 packages/ui/src/Molecules/Table/EditableRow.tsx create mode 100644 packages/ui/src/Molecules/Table/SelectCell.tsx create mode 100644 packages/ui/src/Molecules/Table/TextCell.tsx create mode 100644 packages/ui/src/Molecules/Table/utils.ts diff --git a/apps/console/src/components/table/EditableTable.tsx b/apps/console/src/components/table/EditableTable.tsx index e6099571c..4ebf80e66 100644 --- a/apps/console/src/components/table/EditableTable.tsx +++ b/apps/console/src/components/table/EditableTable.tsx @@ -9,6 +9,7 @@ import { Button, Cell, CellHead, + EditableRow, IconCheckmark1, Row, RowButton, @@ -19,8 +20,8 @@ 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"; +import { useMutationWithToasts } from "/hooks/useMutationWithToasts.ts"; type ColumnDefinition = { label: string; field: string } | string; @@ -65,7 +66,7 @@ export function EditableTable< return ( "1fr"), "56px"]} + columns={[...props.columns.map(() => "minmax(min-content, 1fr)"), "56px"]} refetch={props.pagination.refetch} hasNext={props.pagination.hasNext} isLoadingNext={props.pagination.isLoadingNext} @@ -78,14 +79,14 @@ export function EditableTable< {props.items.map((item) => ( - + update(item.id, k, v)} key={item.id}> {props.row({ item, onUpdate: (key, value) => update(item.id, key as string, value), errors: {}, })} {props.action({ item })} - + ))} {showAdd ? ( ) : ( {props.addLabel} @@ -107,13 +109,14 @@ function NewItemRow(props: { defaultValue: z.infer; connectionId: string; mutation: GraphQLTaggedNode; + onSuccess: () => void; row: (props: EditableTableRowProps) => ReactNode; }) { const { update, errors, value } = useStateWithSchema( props.schema, props.defaultValue, ); - const [mutate, isMutating] = useMutation(props.mutation); + const [mutate, isMutating] = useMutationWithToasts(props.mutation); const isOk = Object.keys(errors ?? {}).length === 0; const onSubmit = async () => { @@ -122,15 +125,16 @@ function NewItemRow(props: { alert("Please fix the errors before submitting."); return; } - mutate({ + await mutate({ variables: { input: value, connections: [props.connectionId], }, + onSuccess: props.onSuccess, }); }; return ( - + {props.row({ errors, onUpdate: update })} - + ); } diff --git a/apps/console/src/components/table/GraphQLCell.tsx b/apps/console/src/components/table/GraphQLCell.tsx new file mode 100644 index 000000000..fb0be4d47 --- /dev/null +++ b/apps/console/src/components/table/GraphQLCell.tsx @@ -0,0 +1,137 @@ +import { type ReactNode, Suspense } from "react"; +import { useEditableCellRef } from "@probo/ui/src/Molecules/Table/EditableCell.tsx"; +import { getKey } from "@probo/ui/src/Molecules/Table/utils.ts"; +import { useStateWithRef } from "@probo/hooks"; +import { useEditableRowContext } from "@probo/ui/src/Molecules/Table/EditableRow.tsx"; +import { EditableCell, selectCell, SelectValue, Spinner } from "@probo/ui"; +import { Command } from "cmdk"; +import type { + GraphQLTaggedNode, + OperationType, + VariablesOf, +} from "relay-runtime"; +import { useLazyLoadQuery } from "react-relay"; +import { useTranslate } from "@probo/i18n"; + +type Props = { + name: string; + query: GraphQLTaggedNode; + variables: VariablesOf; + items: (v: ReturnType>) => T[]; + itemRenderer: (v: { item: T; onRemove?: (item: T) => void }) => ReactNode; +} & ( + | { defaultValue?: T; multiple?: undefined } + | { defaultValue: T[]; multiple: true } +); + +export function GraphQLCell(props: Props) { + const [value, setValue, valueRef] = useStateWithRef( + props.defaultValue, + ); + const cellRef = useEditableCellRef(); + const { __ } = useTranslate(); + const usedKeys = new Set( + Array.isArray(value) ? value.map(getKey) : [getKey(value)], + ); + const { onUpdate } = useEditableRowContext(); + + const onSelect = (item: T) => { + if (props.multiple) { + setValue([...((valueRef.current as T[]) ?? []), item]); + return; + } + setValue(item); + cellRef.current?.close(); + }; + + const onClose = () => { + if (valueRef.current === props.defaultValue) { + return; + } + // Only send ids when updating the value + onUpdate( + props.name, + Array.isArray(valueRef.current) + ? valueRef.current.map(getKey) + : getKey(valueRef.current), + ); + }; + + const classNames = selectCell(); + + return ( + } + onClose={onClose} + ref={cellRef} + > + +
+ {" "} + +
{" "} + {props.multiple && ( + + )} + + + + + } + > + + + +
+
+ ); +} + +function ItemList( + props: Props & { + className: string; + onSelect: (item: T) => void; + usedKeys: Set; + }, +) { + const data = useLazyLoadQuery(props.query, props.variables, { + fetchPolicy: "network-only", + }); + const items = props.items(data); + return ( + <> + {items + .filter((item) => !props.usedKeys.has(getKey(item))) + .map((item) => ( + props.onSelect(item)} + > + {props.itemRenderer({ item })} + + ))} + + ); +} diff --git a/apps/console/src/components/table/PeopleCell.tsx b/apps/console/src/components/table/PeopleCell.tsx new file mode 100644 index 000000000..ef2f6fb41 --- /dev/null +++ b/apps/console/src/components/table/PeopleCell.tsx @@ -0,0 +1,33 @@ +import { GraphQLCell } from "/components/table/GraphQLCell.tsx"; +import type { PeopleGraphQuery } from "/hooks/graph/__generated__/PeopleGraphQuery.graphql.ts"; +import { peopleQuery } from "/hooks/graph/PeopleGraph.ts"; +import { Avatar } from "@probo/ui"; + +type Props = { + name: string; + defaultValue?: { fullName: string; id: string }; + organizationId: string; +}; + +export function PeopleCell(props: Props) { + return ( + + name={props.name} + query={peopleQuery} + variables={{ + organizationId: props.organizationId, + filter: { excludeContractEnded: true }, + }} + items={(data) => + data.organization?.peoples?.edges.map((edge) => edge.node) ?? [] + } + itemRenderer={({ item }) => ( +
+ + {item.fullName} +
+ )} + defaultValue={props.defaultValue} + /> + ); +} diff --git a/apps/console/src/components/table/VendorsCell.tsx b/apps/console/src/components/table/VendorsCell.tsx new file mode 100644 index 000000000..8c577274b --- /dev/null +++ b/apps/console/src/components/table/VendorsCell.tsx @@ -0,0 +1,63 @@ +import { GraphQLCell } from "/components/table/GraphQLCell.tsx"; +import { vendorsSelectQuery } from "/hooks/graph/VendorGraph"; +import { Avatar, Badge, IconCrossLargeX } from "@probo/ui"; +import { faviconUrl } from "@probo/helpers"; +type Vendor = { + id: string; + name: string; + websiteUrl: string | null | undefined; +}; +import type { VendorGraphSelectQuery } from "/hooks/graph/__generated__/VendorGraphSelectQuery.graphql.ts"; + +type Props = { + name: string; + defaultValue?: Vendor[]; + organizationId: string; +}; + +const empty = [] as Vendor[]; + +export function VendorsCell(props: Props) { + return ( + + multiple + name={props.name} + query={vendorsSelectQuery} + variables={{ + organizationId: props.organizationId, + }} + items={(data) => + data.organization?.vendors?.edges.map((edge) => edge.node) ?? [] + } + itemRenderer={({ item, onRemove }) => ( + + )} + defaultValue={props.defaultValue ?? empty} + /> + ); +} + +function VendorBadge({ + vendor, + onRemove, +}: { + vendor: Vendor; + onRemove?: (v: Vendor) => void; +}) { + return ( + + + + {vendor.name} + + {onRemove && ( + + )} + + ); +} diff --git a/apps/console/src/hooks/graph/AssetGraph.ts b/apps/console/src/hooks/graph/AssetGraph.ts index 79c13a879..6bd534abd 100644 --- a/apps/console/src/hooks/graph/AssetGraph.ts +++ b/apps/console/src/hooks/graph/AssetGraph.ts @@ -52,7 +52,7 @@ export const createAssetMutation = graphql` $connections: [ID!]! ) { createAsset(input: $input) { - assetEdge @prependEdge(connections: $connections) { + assetEdge @appendEdge(connections: $connections) { node { id snapshotId diff --git a/apps/console/src/hooks/graph/PeopleGraph.ts b/apps/console/src/hooks/graph/PeopleGraph.ts index dec15875f..3f49c2033 100644 --- a/apps/console/src/hooks/graph/PeopleGraph.ts +++ b/apps/console/src/hooks/graph/PeopleGraph.ts @@ -12,14 +12,23 @@ import type { PeopleGraphPaginatedQuery } from "./__generated__/PeopleGraphPagin import type { PeopleGraphPaginatedFragment$key } from "./__generated__/PeopleGraphPaginatedFragment.graphql"; import { useConfirm, useToast } from "@probo/ui"; import type { PeopleGraphDeleteMutation } from "./__generated__/PeopleGraphDeleteMutation.graphql"; -import { promisifyMutation, sprintf, formatError, type GraphQLError } from "@probo/helpers"; +import { + promisifyMutation, + sprintf, + formatError, + type GraphQLError, +} from "@probo/helpers"; import { useTranslate } from "@probo/i18n"; -const peopleQuery = graphql` +export const peopleQuery = graphql` query PeopleGraphQuery($organizationId: ID!, $filter: PeopleFilter) { organization: node(id: $organizationId) { ... on Organization { - peoples(first: 1000, orderBy: { direction: ASC, field: FULL_NAME }, filter: $filter) { + peoples( + first: 1000 + orderBy: { direction: ASC, field: FULL_NAME } + filter: $filter + ) { edges { node { id @@ -36,14 +45,17 @@ const peopleQuery = graphql` /** * Return a list of people (used for people selectors) */ -export function usePeople(organizationId: string, { excludeContractEnded }: { excludeContractEnded?: boolean } = {}) { +export function usePeople( + organizationId: string, + { excludeContractEnded }: { excludeContractEnded?: boolean } = {}, +) { const data = useLazyLoadQuery( peopleQuery, { organizationId: organizationId, filter: excludeContractEnded ? { excludeContractEnded: true } : null, }, - { fetchPolicy: "network-only" } + { fetchPolicy: "network-only" }, ); return useMemo(() => { return data.organization?.peoples?.edges.map((edge) => edge.node) ?? []; @@ -66,7 +78,10 @@ export const paginatedPeopleFragment = graphql` @refetchable(queryName: "PeopleListQuery") @argumentDefinitions( first: { type: "Int", defaultValue: 50 } - order: { type: "PeopleOrder", defaultValue: { direction: ASC, field: FULL_NAME } } + order: { + type: "PeopleOrder" + defaultValue: { direction: ASC, field: FULL_NAME } + } filter: { type: "PeopleFilter", defaultValue: null } after: { type: "CursorKey", defaultValue: null } before: { type: "CursorKey", defaultValue: null } @@ -98,12 +113,12 @@ export const paginatedPeopleFragment = graphql` `; export function usePeopleQuery( - queryRef: PreloadedQuery + queryRef: PreloadedQuery, ) { const data = usePreloadedQuery(paginatedPeopleQuery, queryRef); const pagination = usePaginationFragment( paginatedPeopleFragment, - data.organization as PeopleGraphPaginatedFragment$key + data.organization as PeopleGraphPaginatedFragment$key, ); const people = pagination.data.peoples?.edges.map((edge) => edge.node); return { @@ -128,7 +143,7 @@ export const PeopleConnectionKey = "PeopleGraphPaginatedQuery_peoples"; export const useDeletePeople = ( people: { id?: string; fullName?: string }, - connectionId: string + connectionId: string, ) => { const [mutate] = useMutation(deletePeopleMutation); const confirm = useConfirm(); @@ -151,18 +166,21 @@ export const useDeletePeople = ( }).catch((error) => { toast({ title: __("Error"), - description: formatError(__("Failed to delete people"), error as GraphQLError), + description: formatError( + __("Failed to delete people"), + error as GraphQLError, + ), variant: "error", }); }), { message: sprintf( __( - 'This will permanently delete "%s". This action cannot be undone.' + 'This will permanently delete "%s". This action cannot be undone.', ), - people.fullName + people.fullName, ), - } + }, ); }; }; diff --git a/apps/console/src/hooks/graph/VendorGraph.ts b/apps/console/src/hooks/graph/VendorGraph.ts index 6139e5bd4..c011465f0 100644 --- a/apps/console/src/hooks/graph/VendorGraph.ts +++ b/apps/console/src/hooks/graph/VendorGraph.ts @@ -37,7 +37,7 @@ export function useCreateVendorMutation() { { successMessage: __("Vendor created successfully."), errorMessage: __("Failed to create vendor"), - } + }, ); } @@ -54,7 +54,7 @@ const deleteVendorMutation = graphql` export const useDeleteVendor = ( vendor: { id?: string; name?: string }, - connectionId: string + connectionId: string, ) => { const [mutate] = useMutation(deleteVendorMutation); const confirm = useConfirm(); @@ -77,11 +77,11 @@ export const useDeleteVendor = ( { message: sprintf( __( - 'This will permanently delete vendor "%s". This action cannot be undone.' + 'This will permanently delete vendor "%s". This action cannot be undone.', ), - vendor.name + vendor.name, ), - } + }, ); }; }; @@ -171,7 +171,7 @@ export const vendorNodeQuery = graphql` } `; -const vendorsSelectQuery = graphql` +export const vendorsSelectQuery = graphql` query VendorGraphSelectQuery($organizationId: ID!) { organization: node(id: $organizationId) { ... on Organization { @@ -195,7 +195,7 @@ export function useVendors(organizationId: string) { { organizationId: organizationId, }, - { fetchPolicy: "network-only" } + { fetchPolicy: "network-only" }, ); return useMemo(() => { return data.organization?.vendors?.edges.map((edge) => edge.node) ?? []; diff --git a/apps/console/src/hooks/graph/__generated__/AssetGraphCreateMutation.graphql.ts b/apps/console/src/hooks/graph/__generated__/AssetGraphCreateMutation.graphql.ts index 8bc1e8af9..3729134a9 100644 --- a/apps/console/src/hooks/graph/__generated__/AssetGraphCreateMutation.graphql.ts +++ b/apps/console/src/hooks/graph/__generated__/AssetGraphCreateMutation.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<<0f47a0632a7158414d8c87cf9de16a7a>> + * @generated SignedSource<<9ebcbef6d786f85d0b88c881d1478938>> * @lightSyntaxTransform * @nogrep */ @@ -262,7 +262,7 @@ return { "alias": null, "args": null, "filters": null, - "handle": "prependEdge", + "handle": "appendEdge", "key": "", "kind": "LinkedHandle", "name": "assetEdge", @@ -290,6 +290,6 @@ return { }; })(); -(node as any).hash = "1d36f3080ab525417516779740f5977c"; +(node as any).hash = "92fca40d88a9ea68f013e9953d46388e"; export default node; diff --git a/apps/console/src/pages/organizations/assets/AssetsPage.tsx b/apps/console/src/pages/organizations/assets/AssetsPage.tsx index c6b8455f1..0ab735997 100644 --- a/apps/console/src/pages/organizations/assets/AssetsPage.tsx +++ b/apps/console/src/pages/organizations/assets/AssetsPage.tsx @@ -1,14 +1,14 @@ import { ActionDropdown, - Avatar, Badge, Button, DropdownItem, - EditableCell, - IconCrossLargeX, + IconPencil, IconPlusLarge, IconTrashCan, PageHeader, + SelectCell, + TextCell, useConfirm, } from "@probo/ui"; import { useTranslate } from "@probo/i18n"; @@ -21,17 +21,15 @@ import { usePreloadedQuery, } from "react-relay"; import { useOrganizationId } from "/hooks/useOrganizationId"; -import { useParams } from "react-router"; -import { CreateAssetDialog } from "./dialogs/CreateAssetDialog"; +import { Link, useParams } from "react-router"; import { assetsQuery, createAssetMutation, deleteAssetMutation, updateAssetMutation, -} from "../../../hooks/graph/AssetGraph"; +} from "/hooks/graph/AssetGraph"; import type { AssetGraphListQuery } from "/hooks/graph/__generated__/AssetGraphListQuery.graphql"; import { - faviconUrl, getAssetTypeVariant, promisifyMutation, sprintf, @@ -39,9 +37,10 @@ import { import type { AssetsPageFragment$key } from "./__generated__/AssetsPageFragment.graphql"; import { SnapshotBanner } from "/components/SnapshotBanner"; import z from "zod"; -import { usePeople } from "/hooks/graph/PeopleGraph.ts"; -import { useVendors } from "/hooks/graph/VendorGraph.ts"; import { EditableTable } from "/components/table/EditableTable.tsx"; +import { PeopleCell } from "/components/table/PeopleCell.tsx"; +import { VendorsCell } from "/components/table/VendorsCell.tsx"; +import { CreateAssetDialog } from "./dialogs/CreateAssetDialog"; import { Authorized, isAuthorized } from "/permissions"; const paginatedAssetsFragment = graphql` @@ -98,7 +97,7 @@ type Props = { const schema = z.object({ name: z.string().trim().min(1, "Name is required"), - amount: z.coerce.number().min(0, "Amount is required"), + amount: z.coerce.number().min(1, "Amount is required"), assetType: z.enum(["PHYSICAL", "VIRTUAL"]), ownerId: z.string().trim().min(1, "Owner is required"), vendorIds: z.array(z.string()).optional(), @@ -122,6 +121,11 @@ export default function AssetsPage(props: Props) { const { snapshotId } = useParams<{ snapshotId?: string }>(); const isSnapshotMode = Boolean(snapshotId); + const assetUrl = (entry: { id: string }) => + isSnapshotMode && snapshotId + ? `/organizations/${organizationId}/snapshots/${snapshotId}/assets/${entry.id}` + : `/organizations/${organizationId}/assets/${entry.id}`; + const data = usePreloadedQuery(assetsQuery, props.queryRef); const pagination = usePaginationFragment( paginatedAssetsFragment, @@ -177,78 +181,59 @@ export default function AssetsPage(props: Props) { ...defaultValue, organizationId, }} - action={({ item }) => ( - - deleteAsset(item)} - variant="danger" - icon={IconTrashCan} - > - {__("Delete")} - - - )} - row={({ item, onUpdate, errors }) => ( + action={({ item }) => + hasAnyAction ? ( + + + + + {__("Edit")} + + + deleteAsset(item)} + variant="danger" + icon={IconTrashCan} + > + {__("Delete")} + + + ) : null + } + row={({ item }) => ( <> - onUpdate("name", v)} - blink={Boolean(errors?.name)} - /> - + ( {item === "PHYSICAL" ? __("Physical") : __("Virtual")} )} - onValueChange={(v) => onUpdate("assetType", v)} - blink={Boolean(errors?.assetType)} + defaultValue={item?.assetType ?? defaultValue.assetType} /> - onUpdate("dataTypesStored", v)} - blink={Boolean(errors?.dataTypesStored)} - /> - onUpdate("amount", v)} - blink={Boolean(errors?.amount)} - /> - - usePeople(organizationId, { excludeContractEnded: true }) + ( -
- - {item.fullName} -
- )} - onValueChange={(v) => onUpdate("ownerId", v.id)} - blink={Boolean(errors?.ownerId)} + required /> - 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)} + + + edge.node) ?? []} /> )} @@ -257,37 +242,6 @@ export default function AssetsPage(props: Props) { ); } -type Vendor = { - id: string; - name: string; - websiteUrl: string | null | undefined; -}; - -function VendorBadge({ - vendor, - onRemove, -}: { - vendor: Vendor; - onRemove?: () => void; -}) { - return ( - - - - {vendor.name} - - {onRemove && ( - - )} - - ); -} - const useDeleteAsset = (connectionId: string) => { const [mutate] = useMutation(deleteAssetMutation); const confirm = useConfirm(); diff --git a/apps/console/src/routes/assetRoutes.ts b/apps/console/src/routes/assetRoutes.ts index 2fcfd61c0..ad3aacb1d 100644 --- a/apps/console/src/routes/assetRoutes.ts +++ b/apps/console/src/routes/assetRoutes.ts @@ -26,6 +26,15 @@ export const assetRoutes = [ }), Component: lazy(() => import("/pages/organizations/assets/AssetsPage")), }, + { + path: "assets/:assetId", + fallback: PageSkeleton, + queryLoader: (params: Record) => + loadQuery(relayEnvironment, assetNodeQuery, { assetId: params.assetId }), + Component: lazy( + () => import("/pages/organizations/assets/AssetDetailsPage"), + ), + }, { path: "snapshots/:snapshotId/assets/:assetId", fallback: PageSkeleton, diff --git a/packages/hooks/src/index.ts b/packages/hooks/src/index.ts index 39277a1e3..bc44b993c 100644 --- a/packages/hooks/src/index.ts +++ b/packages/hooks/src/index.ts @@ -2,3 +2,4 @@ export { usePageTitle } from "./usePageTitle"; export { useToggle } from "./useToggle"; export { useRefSync } from "./useRefSync"; export { useList } from "./useList"; +export { useStateWithRef } from "./useStateWithRef"; diff --git a/packages/hooks/src/useStateWithRef.ts b/packages/hooks/src/useStateWithRef.ts new file mode 100644 index 000000000..c05c4d23a --- /dev/null +++ b/packages/hooks/src/useStateWithRef.ts @@ -0,0 +1,18 @@ +import { useCallback, useRef, useState } from "react"; + +/** + * A useState hook that also returns a ref to the current state (usable in callbacks) + */ +export function useStateWithRef(initialValue: T) { + const [state, setState] = useState(initialValue); + const ref = useRef(state); + + return [ + state, + useCallback((v: T) => { + setState(v); + ref.current = v; + }, []), + ref, + ] as const; +} diff --git a/packages/ui/src/Atoms/DataTable/DataTable.stories.tsx b/packages/ui/src/Atoms/DataTable/DataTable.stories.tsx deleted file mode 100644 index 75c9d3c3e..000000000 --- a/packages/ui/src/Atoms/DataTable/DataTable.stories.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; -import { DataTable, CellHead, Cell } from "./DataTable.tsx"; - -export default { - title: "Atoms/DataTable", - component: DataTable, - argTypes: {}, -} satisfies Meta; - -type Story = StoryObj; - -export const Default: Story = { - render: () => { - return ( - - Header 1 - Header 2 - Header 3 - Row 1, Cell 1 - Row 1, Cell 2 - Row 1, Cell 3 - Row 2, Cell 1 - Row 2, Cell 2 - Row 2, Cell 3 - - ); - }, -}; diff --git a/packages/ui/src/Atoms/DataTable/DataTable.tsx b/packages/ui/src/Atoms/DataTable/DataTable.tsx index a8e3b564c..5e913c27e 100644 --- a/packages/ui/src/Atoms/DataTable/DataTable.tsx +++ b/packages/ui/src/Atoms/DataTable/DataTable.tsx @@ -21,16 +21,17 @@ export function DataTable({ }; } return { - gridTemplateColumns: columns - .map((col) => `minmax(0, ${col})`) - .join(" "), + gridTemplateColumns: columns.join(" "), }; }; return (
{children} diff --git a/packages/ui/src/Molecules/Table/DataTable.stories.tsx b/packages/ui/src/Molecules/Table/DataTable.stories.tsx new file mode 100644 index 000000000..d767358e1 --- /dev/null +++ b/packages/ui/src/Molecules/Table/DataTable.stories.tsx @@ -0,0 +1,65 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { type FC, Fragment, useState } from "react"; +import { CellHead, DataTable, Row } from "../../Atoms/DataTable/DataTable.tsx"; +import { EditableRow } from "./EditableRow.tsx"; +import { TextCell } from "./TextCell.tsx"; +import { fn } from "@storybook/test"; +import { SelectCell } from "./SelectCell.tsx"; +import { Badge } from "../../Atoms/Badge/Badge.tsx"; + +type Component = FC<{ onUpdate: (key: string, value: unknown) => void }>; + +export default { + title: "Atoms/DataTable/Cells", + component: Fragment as Component, + argTypes: {}, + args: { onUpdate: fn() }, +} satisfies Meta; + +type Story = StoryObj; + +export const Default: Story = { + render: ({ onUpdate }) => { + const [state, setState] = useState({ + name: "John", + status: "delivered", + statuses: ["delivered", "pending"], + }); + const updateField = (key: string, value: unknown) => { + onUpdate(key, value); + setState({ + ...state, + [key]: value, + }); + }; + return ( + + + Nom + Status + Statuses + + + + {item}} + name="status" + defaultValue={state.status} + /> + ( + onRemove?.(item)}> + {item} + + )} + name="statuses" + defaultValue={state.statuses} + /> + + + ); + }, +}; diff --git a/packages/ui/src/Molecules/Table/EditableCell.tsx b/packages/ui/src/Molecules/Table/EditableCell.tsx index 147281b65..aec825d0a 100644 --- a/packages/ui/src/Molecules/Table/EditableCell.tsx +++ b/packages/ui/src/Molecules/Table/EditableCell.tsx @@ -1,79 +1,39 @@ import * as Popover from "@radix-ui/react-popover"; import { + type CSSProperties, type KeyboardEventHandler, type ReactNode, - Suspense, useRef, useState, } from "react"; import { Cell } from "../../Atoms/DataTable/DataTable.tsx"; -import { Command } from "cmdk"; -import { useTranslate } from "@probo/i18n"; -import { Spinner } from "../../Atoms/Spinner/Spinner.tsx"; import { focusSiblingElement } from "@probo/helpers"; +import { useEditableRowContext } from "./EditableRow.tsx"; -type Props = - | { - type: "text"; - value?: T; - blink?: boolean; - onValueChange: (value: string) => void; - itemRenderer?: undefined; - } - | { - type: "select"; - items: T[] | (() => T[]); - itemRenderer: (v: { item: T }) => ReactNode; - value?: T; - blink?: boolean; - onValueChange: (value: T) => void; - } - | { - type: "multiple"; - items: T[] | (() => T[]); - itemRenderer: (v: { item: T; onRemove?: () => void }) => ReactNode; - value?: T[]; - blink?: boolean; - onValueChange: (value: T[]) => void; - }; - -type PropsField = Props & { - type: Type; - onOpenChange: (open: boolean) => void; - padding: string; - height: number; -}; - -function getKey(item: T): string { - if ( - item && - typeof item === "object" && - "id" in item && - typeof item.id === "string" - ) { - return item.id.toString(); - } - if (typeof item === "string" || typeof item === "number") { - return item.toString(); - } - if (item === undefined) { - return ""; - } - console.error("Cannot compute a key from item", item); - return ""; +export function useEditableCellRef() { + return useRef<{ close: () => void } | null>(null); } -export function EditableCell(props: Props) { +/** + * Base component to create an editable table cell + */ +export function EditableCell(props: { + // Name of the field (used to retrieve errors) + name?: string; + // Label displayed inside the cell + label: ReactNode; + // Callback when the edit is dismissed + onClose: () => void; + // Content of the popover (e.g. a field) + children: ReactNode; + // Ref used to control the popover (used to close it programatically) + ref: ReturnType; +}) { + const { errors } = useEditableRowContext(); const [isOpen, setOpen] = useState(false); - const [value, setValueState] = useState(props.value); const [height, setHeight] = useState(undefined); const [padding, setPadding] = useState("12px"); const td = useRef(null); - const valueRef = useRef(value); - const setValue = (value: T) => { - valueRef.current = value; - setValueState(value); - }; // When opening the popover, remember the height and padding of the cell const onOpenChange = (open: boolean) => { @@ -81,37 +41,17 @@ export function EditableCell(props: Props) { setHeight(td.current?.offsetHeight ?? undefined); setPadding(getComputedStyle(td.current!).paddingLeft); } else { + props.onClose(); setHeight(undefined); } - // Send the value when closing the popover - if (!open && valueRef.current !== props.value) { - // @ts-expect-error - cannot unpack value type - props.onValueChange(valueRef.current); - } setOpen(open); }; - const fieldProps = { - height, - onValueChange: setValue, - onOpenChange, - padding, - value, - } as any; - - const children = (() => { - if (!value) { - return ""; - } - if (props.type === "select") { - // @ts-expect-error TS cannot understand the link between props.type and value - return props.itemRenderer({ item: value }); - } - if (Array.isArray(value) && props.type === "multiple") { - return <>{value.map((v) => props.itemRenderer({ item: v }))}; - } - return value as ReactNode; - })(); + if (props.ref) { + props.ref.current = { + close: () => onOpenChange(false), + }; + } // Handle keyboard navigation inside the cells const onKeyDown: KeyboardEventHandler = (e) => { @@ -129,6 +69,7 @@ export function EditableCell(props: Props) { ); } }; + const hasError = errors && props.name && props.name in errors; return ( @@ -140,9 +81,9 @@ export function EditableCell(props: Props) { className="flex flex-row justify-start hover:bg-level-2 flex-wrap gap-1 items-center relative" style={{ height }} > - {children} - {props.blink && ( -
+ {props.label} + {hasError && ( +
)} @@ -153,205 +94,18 @@ export function EditableCell(props: Props) { side="bottom" align="start" sideOffset={height ? height * -1 : 0} - style={{ - minHeight: height, - }} + style={ + { + minHeight: height, + "--padding": padding, + "--height": height + "px", + } as CSSProperties + } className="border border-border-low bg-level-2 min-w-[200px] flex flex-col justify-center rounded-sm" > - {props.type === "text" && ( - - )} - {props.type === "select" && ( - props.onValueChange(e.currentTarget.value)} - style={{ paddingLeft: props.padding }} - /> - ); -} - -function Select(props: PropsField<"select", T>) { - const { __ } = useTranslate(); - const showSearch = false; - return ( - -
props.onOpenChange(false)} - > - {props.value ? props.itemRenderer({ item: props.value }) : ""} -
- {showSearch && ( - - )} - - {Array.isArray(props.items) ? ( - props.items - .filter((item) => item !== props.value) - .map((item) => ( - { - props.onValueChange(item); - props.onOpenChange(false); - }} - > - {props.itemRenderer({ item })} - - )) - ) : ( - - -
- } - > - { - props.onValueChange(item); - props.onOpenChange(false); - }} - /> - - )} - - - ); -} - -function Multiple(props: PropsField<"multiple", T>) { - const { __ } = useTranslate(); - const showSearch = true; - - const pushValue = (item: T) => { - props.onValueChange([...(props.value ?? []), item]); - }; - - const removeValue = (item: T) => { - props.onValueChange( - props.value!.filter((v) => getKey(v) !== getKey(item)), - ); - }; - - return ( - - {props.value && props.value.length > 0 && ( -
- {props.value && - props.value.map((item) => - props.itemRenderer({ - item, - onRemove: () => removeValue(item), - }), - )} -
- )} - {showSearch && ( - - )} - - {Array.isArray(props.items) ? ( - props.items - .filter((item) => item !== props.value) - .map((item, k) => ( - { - pushValue(item); - }} - > - {props.itemRenderer({ - item, - })} - - )) - ) : ( - - -
- } - > - { - pushValue(item); - }} - /> - - )} - - - ); -} -/** - * Resolve items with a suspense - */ -function SelectItems(props: { - value?: T | T[]; - itemRenderer: (v: { item: T }) => ReactNode; - items: () => T[]; - onSelect: (item: T) => void; -}) { - const items = props.items(); - const keys = Array.isArray(props.value) - ? props.value.map(getKey) - : [getKey(props.value)]; - return ( - <> - {items - .filter((item) => !keys.includes(getKey(item))) - .map((item) => ( - props.onSelect(item)} - > - {props.itemRenderer({ item })} - - ))} - - ); -} diff --git a/packages/ui/src/Molecules/Table/EditableRow.tsx b/packages/ui/src/Molecules/Table/EditableRow.tsx new file mode 100644 index 000000000..ca01a001a --- /dev/null +++ b/packages/ui/src/Molecules/Table/EditableRow.tsx @@ -0,0 +1,46 @@ +import { + createContext, + type ReactNode, + useContext, + useMemo, + useRef, +} from "react"; +import { Row } from "../../Atoms/DataTable/DataTable.tsx"; + +type Props = { + onUpdate: (key: string, value: unknown) => void; + errors?: Record; + children: ReactNode; +}; + +export const EditableRowContext = createContext>( + null, +); + +export const useEditableRowContext = () => { + const context = useContext(EditableRowContext); + if (!context) { + throw new Error( + "useEditableRowContext must be used within an EditableRow", + ); + } + return context; +}; + +export function EditableRow(props: Props) { + const onUpdateRef = useRef(props.onUpdate); + onUpdateRef.current = props.onUpdate; + const value = useMemo( + () => ({ + errors: props.errors, + onUpdate: (key: string, value: unknown) => + onUpdateRef.current(key, value), + }), + [props.errors], + ); + return ( + + {props.children} + + ); +} diff --git a/packages/ui/src/Molecules/Table/SelectCell.tsx b/packages/ui/src/Molecules/Table/SelectCell.tsx new file mode 100644 index 000000000..c7a77d67b --- /dev/null +++ b/packages/ui/src/Molecules/Table/SelectCell.tsx @@ -0,0 +1,164 @@ +import { EditableCell, useEditableCellRef } from "./EditableCell.tsx"; +import { Command } from "cmdk"; +import { Fragment, type ReactNode } from "react"; +import { getKey } from "./utils.ts"; +import { useTranslate } from "@probo/i18n"; +import { useEditableRowContext } from "./EditableRow.tsx"; +import { useStateWithRef } from "@probo/hooks"; +import { tv } from "tailwind-variants"; +import { Badge } from "../../Atoms/Badge/Badge.tsx"; + +type Props = { + name: string; + items: T[]; + itemRenderer: (v: { item: T; onRemove?: (item: T) => void }) => ReactNode; +} & ( + | { defaultValue: T; multiple?: undefined } + | { defaultValue: T[]; multiple: true } +); + +export const selectCell = tv({ + slots: { + command: + "text-txt-primary absolute left-0 top-0 right-0 bg-level-2 border border-border-low rounded-b-sm", + value: "flex flex-col gap-2 py-3 justify-center", + input: "text-sm text-txt-secondary border-y border-border-low py-2 px-3 w-full focus:outline-txt-accent outline", + item: "py-2 px-3 hover:bg-level-3 data-[selected=true]:bg-level-3", + }, +}); + +export function SelectCell(props: Props) { + const [value, setValue, valueRef] = useStateWithRef( + props.defaultValue, + ); + const cellRef = useEditableCellRef(); + const { __ } = useTranslate(); + const usedKeys = new Set( + Array.isArray(value) ? value.map(getKey) : [getKey(value)], + ); + const { onUpdate } = useEditableRowContext(); + + const onSelect = (item: T) => { + if (props.multiple) { + setValue([...((valueRef.current as T[]) ?? []), item]); + return; + } + setValue(item); + cellRef.current?.close(); + }; + + const onClose = () => { + if (valueRef.current === props.defaultValue) { + return; + } + onUpdate(props.name, valueRef.current); + }; + + const classNames = selectCell(); + + return ( + + } + onClose={onClose} + ref={cellRef} + > + +
+ {" "} + +
{" "} + {props.multiple && ( + + )} + + {props.items + .filter((item) => !usedKeys.has(getKey(item))) + .map((item) => ( + onSelect(item)} + > + {props.itemRenderer({ item })} + + ))} + +
+
+ ); +} + +export function SelectValue(props: { + itemRenderer: Props["itemRenderer"]; + onValueChange?: (value: T | T[]) => void; + value: T | T[] | undefined; +}) { + if (!props.value) { + return ""; + } + if (!Array.isArray(props.value)) { + return props.value ? props.itemRenderer({ item: props.value }) : ""; + } + + const removeValue = (item: T) => { + if (!Array.isArray(props.value) || !props.onValueChange) { + return; + } + props.onValueChange( + props.value!.filter((v) => getKey(v) !== getKey(item)), + ); + }; + + if (!props.onValueChange && props.value.length > 0) { + return ( + <> + {props.value.slice(0, 3).map((item) => ( + + {props.itemRenderer({ + item, + onRemove: props.onValueChange + ? () => removeValue(item) + : undefined, + })} + + ))} + {props.value.length > 3 && ( + + +{props.value.length - 3} + + )} + + ); + } + + return ( + <> + {props.value.map((item) => ( + + {props.itemRenderer({ + item, + onRemove: props.onValueChange + ? () => removeValue(item) + : undefined, + })} + + ))} + + ); +} diff --git a/packages/ui/src/Molecules/Table/TextCell.tsx b/packages/ui/src/Molecules/Table/TextCell.tsx new file mode 100644 index 000000000..5c229ee30 --- /dev/null +++ b/packages/ui/src/Molecules/Table/TextCell.tsx @@ -0,0 +1,47 @@ +import { EditableCell, useEditableCellRef } from "./EditableCell.tsx"; +import { type KeyboardEventHandler, useRef, useState } from "react"; +import { useEditableRowContext } from "./EditableRow.tsx"; + +type Props = { + name: string; + defaultValue: string; + required?: boolean; +}; + +export function TextCell(props: Props) { + const [value, setValue] = useState(props.defaultValue); + const inputRef = useRef(null); + const cellRef = useEditableCellRef(); + const blurOnTab: KeyboardEventHandler = (e) => { + if (e.key === "Tab") { + cellRef.current?.close(); + } + }; + const { onUpdate } = useEditableRowContext(); + const onClose = () => { + const inputValue = (inputRef.current?.value ?? "").trim(); + // Do not propagate empty value for required fields + if (props.required && inputValue === "") { + return; + } + setValue(inputValue); + onUpdate(props.name, inputValue); + }; + return ( + + + + ); +} diff --git a/packages/ui/src/Molecules/Table/utils.ts b/packages/ui/src/Molecules/Table/utils.ts new file mode 100644 index 000000000..821627249 --- /dev/null +++ b/packages/ui/src/Molecules/Table/utils.ts @@ -0,0 +1,18 @@ +export function getKey(item: T): string { + if ( + item && + typeof item === "object" && + "id" in item && + typeof item.id === "string" + ) { + return item.id.toString(); + } + if (typeof item === "string" || typeof item === "number") { + return item.toString(); + } + if (item === undefined) { + return ""; + } + console.error("Cannot compute a key from item", item); + return ""; +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 94673f32c..bc5ae30bc 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -79,6 +79,13 @@ export { ImpactOptions } from "./Molecules/Select/ImpactOptions"; export { DurationPicker } from "./Molecules/DurationPicker/DurationPicker"; export { FrameworkLogo } from "./Molecules/Badge/FrameworkLogo"; export { EditableCell } from "./Molecules/Table/EditableCell"; +export { TextCell } from "./Molecules/Table/TextCell"; +export { + SelectCell, + selectCell, + SelectValue, +} from "./Molecules/Table/SelectCell"; +export { EditableRow } from "./Molecules/Table/EditableRow"; // Hooks export { useToast, Toasts } from "./Atoms/Toasts/Toasts";