From 94684e56ff65bd2205fd6b57d9ec7e4745ebe54a Mon Sep 17 00:00:00 2001 From: Jonathan Date: Thu, 13 Nov 2025 19:48:36 +0100 Subject: [PATCH] Ref EditableCell signature Signed-off-by: Jonathan --- .../src/components/form/PeopleSelectField.tsx | 97 ++--- apps/console/src/hooks/graph/AssetGraph.ts | 91 +++-- apps/console/src/hooks/useMutateField.tsx | 33 ++ apps/console/src/hooks/useStateWithSchema.ts | 31 ++ .../pages/organizations/assets/AssetsPage.tsx | 313 ++++++++++----- .../assets/dialogs/CreateAssetDialog.tsx | 2 +- apps/console/src/routes/assetRoutes.ts | 23 +- package-lock.json | 17 + packages/ui/package.json | 1 + packages/ui/src/Atoms/DataTable/DataTable.tsx | 30 +- packages/ui/src/Atoms/Table/Table.tsx | 3 +- .../ui/src/Molecules/Table/EditableCell.tsx | 370 ++++++++++++++---- packages/ui/src/index.ts | 8 +- 13 files changed, 705 insertions(+), 314 deletions(-) create mode 100644 apps/console/src/hooks/useMutateField.tsx create mode 100644 apps/console/src/hooks/useStateWithSchema.ts diff --git a/apps/console/src/components/form/PeopleSelectField.tsx b/apps/console/src/components/form/PeopleSelectField.tsx index 28bd79f0a..a116312b8 100644 --- a/apps/console/src/components/form/PeopleSelectField.tsx +++ b/apps/console/src/components/form/PeopleSelectField.tsx @@ -46,75 +46,32 @@ function PeopleSelectWithQuery( const people = usePeople(organizationId, { excludeContractEnded: true }); return ( - <> - ( - - )} - /> - - ); -} - -type OptionsProps = { - organizationId: string; - optional?: boolean; -} & ComponentProps; - -export function PeopleSelectOptions({ - organizationId, - ...props -}: OptionsProps) { - return ( - } - > - - - ); -} - -function PeopleSelectOptionsWithQuery( - props: Pick, -) { - const { __ } = useTranslate(); - const { organizationId } = props; - const people = usePeople(organizationId, { excludeContractEnded: true }); - - return ( - <> - {props.optional && } - {people?.map((p) => ( - - ))} - + ( + + )} + /> ); } diff --git a/apps/console/src/hooks/graph/AssetGraph.ts b/apps/console/src/hooks/graph/AssetGraph.ts index ea126eb7b..79c13a879 100644 --- a/apps/console/src/hooks/graph/AssetGraph.ts +++ b/apps/console/src/hooks/graph/AssetGraph.ts @@ -121,13 +121,17 @@ export const deleteAssetMutation = graphql` `; export const useDeleteAsset = ( - asset: { id?: string; name?: string }, - connectionId: string + asset?: { id?: string; name?: string }, + connectionId?: string, ) => { const [mutate] = useMutation(deleteAssetMutation); const confirm = useConfirm(); const { __ } = useTranslate(); + if (!asset) { + return () => {}; + } + return () => { if (!asset.id || !asset.name) { return alert(__("Failed to delete asset: missing id or name")); @@ -145,56 +149,61 @@ export const useDeleteAsset = ( { message: sprintf( __( - 'This will permanently delete "%s". This action cannot be undone.' + 'This will permanently delete "%s". This action cannot be undone.', ), - asset.name + asset.name, ), - } + }, ); }; }; export const useCreateAsset = (connectionId: string) => { - const [mutate] = useMutation(createAssetMutation); + const [mutate, isMutating] = useMutation(createAssetMutation); const { __ } = useTranslate(); - return (input: { - name: string; - amount: number; - assetType: string; - ownerId: string; - organizationId: string; - vendorIds?: string[]; - dataTypesStored: string; - }) => { - if (!input.name?.trim()) { - return alert(__("Failed to create asset: name is required")); - } - if (!input.ownerId) { - return alert(__("Failed to create asset: owner is required")); - } - if (!input.organizationId) { - return alert(__("Failed to create asset: organization is required")); - } - if (!input.dataTypesStored) { - return alert(__("Failed to create asset: data types stored is required")); - } + return [ + (input: { + name: string; + amount: number; + assetType: string; + ownerId: string; + organizationId: string; + vendorIds?: string[]; + dataTypesStored: string; + }) => { + if (!input.name?.trim()) { + return alert(__("Failed to create asset: name is required")); + } + if (!input.ownerId) { + return alert(__("Failed to create asset: owner is required")); + } + if (!input.organizationId) { + return alert(__("Failed to create asset: organization is required")); + } + if (!input.dataTypesStored) { + return alert( + __("Failed to create asset: data types stored is required"), + ); + } - return promisifyMutation(mutate)({ - variables: { - input: { - name: input.name, - amount: input.amount, - assetType: input.assetType, - dataTypesStored: input.dataTypesStored || "", - ownerId: input.ownerId, - organizationId: input.organizationId, - vendorIds: input.vendorIds || [], + return promisifyMutation(mutate)({ + variables: { + input: { + name: input.name, + amount: input.amount, + assetType: input.assetType, + dataTypesStored: input.dataTypesStored || "", + ownerId: input.ownerId, + organizationId: input.organizationId, + vendorIds: input.vendorIds || [], + }, + connections: [connectionId], }, - connections: [connectionId], - }, - }); - }; + }); + }, + isMutating, + ] as const; }; export const useUpdateAsset = () => { diff --git a/apps/console/src/hooks/useMutateField.tsx b/apps/console/src/hooks/useMutateField.tsx new file mode 100644 index 000000000..ef82cd9bb --- /dev/null +++ b/apps/console/src/hooks/useMutateField.tsx @@ -0,0 +1,33 @@ +import type { GraphQLTaggedNode } from "relay-runtime"; +import { useMutation } from "react-relay"; + +export type MutationFieldUpdate = ( + field: keyof T, + value: T[typeof field], +) => void; + +/** + * Mutate a single field from a graphql mutation + */ +export function useMutateField>( + mutation: GraphQLTaggedNode, +) { + const [mutate, isUpdating] = useMutation(mutation); + + return { + update(id: string, fieldName: T, value: Input[T]) { + if (!id) { + return; + } + mutate({ + variables: { + input: { + id: id, + [fieldName]: value, + }, + }, + }); + }, + isUpdating, + }; +} diff --git a/apps/console/src/hooks/useStateWithSchema.ts b/apps/console/src/hooks/useStateWithSchema.ts new file mode 100644 index 000000000..20c297720 --- /dev/null +++ b/apps/console/src/hooks/useStateWithSchema.ts @@ -0,0 +1,31 @@ +import { z, ZodError, type ZodTypeAny } from "zod"; +import { useMemo, useState } from "react"; + +export function useStateWithSchema( + schema: T, + initialValue: z.infer, +) { + const [state, setState] = useState(initialValue); + const errors = useMemo(() => { + try { + schema.parse(state); + return {}; + } catch (error) { + if (error instanceof ZodError) { + return Object.fromEntries( + error.issues.map((issue) => [issue.path.join("."), issue.message]) ?? + [], + ); + } + return {}; + } + }, [state, schema]); + + return [ + state, + (key: keyof z.infer, value: z.infer[typeof key]) => { + setState((prevState) => ({ ...prevState, [key]: value })); + }, + errors, + ] as const; +} diff --git a/apps/console/src/pages/organizations/assets/AssetsPage.tsx b/apps/console/src/pages/organizations/assets/AssetsPage.tsx index 6ca7e340f..0ffcb15a8 100644 --- a/apps/console/src/pages/organizations/assets/AssetsPage.tsx +++ b/apps/console/src/pages/organizations/assets/AssetsPage.tsx @@ -1,55 +1,59 @@ import { - Button, - IconPlusLarge, - PageHeader, - Thead, - Tbody, - Tr, - Th, - Td, - Badge, ActionDropdown, - DropdownItem, - IconTrashCan, Avatar, - EditableCell, - Select, - Option, - DataTable, - CellHead, + Badge, + Button, Cell, + CellHead, + DataTable, + DropdownItem, + EditableCell, + IconCheckmark1, + IconCrossLargeX, + IconPlusLarge, + IconTrashCan, + PageHeader, Row, + RowButton, + Spinner, } from "@probo/ui"; import { useTranslate } from "@probo/i18n"; -import { usePageTitle } from "@probo/hooks"; +import { usePageTitle, useToggle } from "@probo/hooks"; import { graphql, + type PreloadedQuery, usePaginationFragment, usePreloadedQuery, - type PreloadedQuery, - useMutation, } from "react-relay"; import { useOrganizationId } from "/hooks/useOrganizationId"; import { useParams } from "react-router"; import { CreateAssetDialog } from "./dialogs/CreateAssetDialog"; import { - useDeleteAsset, assetsQuery, updateAssetMutation, + useCreateAsset, + useDeleteAsset, } from "../../../hooks/graph/AssetGraph"; import type { AssetGraphListQuery } from "/hooks/graph/__generated__/AssetGraphListQuery.graphql"; -import { faviconUrl } from "@probo/helpers"; +import { faviconUrl, getAssetTypeVariant } from "@probo/helpers"; import type { NodeOf } from "/types"; -import { getAssetTypeVariant } from "@probo/helpers"; import type { AssetsPageFragment$data, AssetsPageFragment$key, } from "./__generated__/AssetsPageFragment.graphql"; -import { SortableTable } from "/components/SortableTable"; import { SnapshotBanner } from "/components/SnapshotBanner"; +import { + type MutationFieldUpdate, + useMutateField, +} from "/hooks/useMutateField.tsx"; +import type { UpdateAssetInput } from "/hooks/graph/__generated__/AssetGraphUpdateMutation.graphql.ts"; +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 { PeopleSelectOptions } from "/components/form/PeopleSelectField.tsx"; const paginatedAssetsFragment = graphql` fragment AssetsPageFragment on Organization @@ -125,6 +129,8 @@ export default function AssetsPage(props: Props) { isAuthorized(organizationId, "Asset", "updateAsset") || isAuthorized(organizationId, "Asset", "deleteAsset") ); + const { update } = useMutateField(updateAssetMutation); + const [showAdd, toggleAdd] = useToggle(false); return (
@@ -146,130 +152,190 @@ 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; + 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 vendors = entry.vendors?.edges.map((edge) => edge.node) ?? []; - - const assetUrl = - isSnapshotMode && snapshotId - ? `/organizations/${organizationId}/snapshots/${snapshotId}/assets/${entry.id}` - : `/organizations/${organizationId}/assets/${entry.id}`; - - const [mutate, isLoading] = useMutation(updateAssetMutation); - const updater = (fieldName: keyof typeof entry) => (value: string) => { - // Only send an update if the value changed - if (entry[fieldName] === value) { - return; - } - mutate({ - variables: { - input: { - id: entry.id, - [fieldName]: value, - }, - }, - }); - }; - + const isOk = Object.keys(errors ?? {}).length === 0; return ( onUpdate("name", v)} + blink={Boolean(errors?.name)} /> - - - - } - > - - {entry.assetType === "PHYSICAL" ? __("Physical") : __("Virtual")} - - + items={["VIRTUAL", "PHYSICAL"]} + value={entry?.assetType ?? "VIRTUAL"} + itemRenderer={({ item }) => ( + + {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)} /> } - > - {entry.owner?.fullName ?? __("Unassigned")} - - - {vendors.length > 0 ? ( -
- {vendors.slice(0, 3).map((vendor) => ( - - - {vendor.name} - - ))} - {vendors.length > 3 && ( - - +{vendors.length - 3} - - )} + items={() => usePeople(organizationId, { excludeContractEnded: true })} + value={entry?.owner} + itemRenderer={({ item }) => ( +
+ + {item.fullName}
- ) : ( - {__("None")} )} - + 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)} + /> - {!isSnapshotMode && ( + {loading && ( + + )} + {onSubmit && !loading && ( + + )} + {!isSnapshotMode && entry && ( ); } + +type Vendor = { + id: string; + name: string; + websiteUrl: string | null | undefined; +}; + +function VendorBadge({ + vendor, + onRemove, +}: { + vendor: Vendor; + onRemove?: () => void; +}) { + return ( + + + + {vendor.name} + + {onRemove && ( + + )} + + ); +} diff --git a/apps/console/src/pages/organizations/assets/dialogs/CreateAssetDialog.tsx b/apps/console/src/pages/organizations/assets/dialogs/CreateAssetDialog.tsx index 177d49e4e..660552f5a 100644 --- a/apps/console/src/pages/organizations/assets/dialogs/CreateAssetDialog.tsx +++ b/apps/console/src/pages/organizations/assets/dialogs/CreateAssetDialog.tsx @@ -48,7 +48,7 @@ export function CreateAssetDialog({ }, }); const ref = useDialogRef(); - const createAsset = useCreateAsset(connection); + const [createAsset] = useCreateAsset(connection); const onSubmit = handleSubmit(async (data) => { try { diff --git a/apps/console/src/routes/assetRoutes.ts b/apps/console/src/routes/assetRoutes.ts index a26323fcd..2fcfd61c0 100644 --- a/apps/console/src/routes/assetRoutes.ts +++ b/apps/console/src/routes/assetRoutes.ts @@ -12,11 +12,9 @@ export const assetRoutes = [ queryLoader: (params: Record) => loadQuery(relayEnvironment, assetsQuery, { organizationId: params.organizationId, - snapshotId: null + snapshotId: null, }), - Component: lazy( - () => import("/pages/organizations/assets/AssetsPage") - ), + Component: lazy(() => import("/pages/organizations/assets/AssetsPage")), }, { path: "snapshots/:snapshotId/assets", @@ -24,20 +22,9 @@ export const assetRoutes = [ queryLoader: (params: Record) => loadQuery(relayEnvironment, assetsQuery, { organizationId: params.organizationId, - snapshotId: params.snapshotId + snapshotId: params.snapshotId, }), - 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") - ), + Component: lazy(() => import("/pages/organizations/assets/AssetsPage")), }, { path: "snapshots/:snapshotId/assets/:assetId", @@ -45,7 +32,7 @@ export const assetRoutes = [ queryLoader: (params: Record) => loadQuery(relayEnvironment, assetNodeQuery, { assetId: params.assetId }), Component: lazy( - () => import("/pages/organizations/assets/AssetDetailsPage") + () => import("/pages/organizations/assets/AssetDetailsPage"), ), }, ] satisfies AppRoute[]; diff --git a/package-lock.json b/package-lock.json index 9ddf3709b..60954101a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5756,6 +5756,22 @@ "node": ">=6" } }, + "node_modules/cmdk": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz", + "integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "^1.1.1", + "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-id": "^1.1.0", + "@radix-ui/react-primitive": "^2.0.2" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "react-dom": "^18 || ^19 || ^19.0.0-rc" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -12884,6 +12900,7 @@ "@radix-ui/react-tabs": "^1.1.12", "@tailwindcss/vite": "^4.1.7", "clsx": "^2.1.1", + "cmdk": "^1.1.1", "react-dropzone": "^14.3.8", "react-intersection-observer": "^9.16.0", "react-markdown": "^10.1.0", diff --git a/packages/ui/package.json b/packages/ui/package.json index b2fd4319c..041730693 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -24,6 +24,7 @@ "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-tabs": "^1.1.13", "clsx": "^2.1.1", + "cmdk": "^1.1.1", "react-dropzone": "^14.3.8", "react-intersection-observer": "^9.16.0", "react-markdown": "^10.1.0", diff --git a/packages/ui/src/Atoms/DataTable/DataTable.tsx b/packages/ui/src/Atoms/DataTable/DataTable.tsx index 6c0d0b79f..a8e3b564c 100644 --- a/packages/ui/src/Atoms/DataTable/DataTable.tsx +++ b/packages/ui/src/Atoms/DataTable/DataTable.tsx @@ -1,7 +1,13 @@ -import { type ComponentPropsWithRef, type PropsWithChildren } from "react"; +import { + type ComponentPropsWithRef, + type FC, + type PropsWithChildren, + type ReactNode, +} from "react"; import { Card } from "../Card/Card"; import clsx from "clsx"; import { type AsChildProps, Slot } from "../Slot.tsx"; +import { IconPlusLarge } from "../Icons"; export function DataTable({ children, @@ -72,3 +78,25 @@ export function Cell({ /> ); } + +export function RowButton({ + icon = IconPlusLarge, + children, + ...props +}: { + colspan?: number; + children: ReactNode; + icon?: FC<{ size: number; className?: string }>; +} & ComponentPropsWithRef<"button">) { + const IconComponent = icon; + return ( + + ); +} diff --git a/packages/ui/src/Atoms/Table/Table.tsx b/packages/ui/src/Atoms/Table/Table.tsx index 421da0dad..418596f63 100644 --- a/packages/ui/src/Atoms/Table/Table.tsx +++ b/packages/ui/src/Atoms/Table/Table.tsx @@ -1,12 +1,11 @@ import { createContext, - useContext, type FC, type HTMLAttributes, type PropsWithChildren, type ReactNode, type ThHTMLAttributes, - type ComponentPropsWithRef, + useContext, } from "react"; import { Card } from "../Card/Card"; import { Link } from "react-router"; diff --git a/packages/ui/src/Molecules/Table/EditableCell.tsx b/packages/ui/src/Molecules/Table/EditableCell.tsx index 001521906..ac5952575 100644 --- a/packages/ui/src/Molecules/Table/EditableCell.tsx +++ b/packages/ui/src/Molecules/Table/EditableCell.tsx @@ -1,112 +1,338 @@ +import * as Popover from "@radix-ui/react-popover"; import { - type FocusEventHandler, type KeyboardEventHandler, type ReactNode, + Suspense, useRef, useState, } from "react"; -import * as Popover from "@radix-ui/react-popover"; -import { Select } from "../../Atoms/Select/Select.tsx"; -import { Spinner } from "../../Atoms/Spinner/Spinner.tsx"; import { Cell } from "../../Atoms/DataTable/DataTable.tsx"; +import { Command } from "cmdk"; +import { useTranslate } from "@probo/i18n"; +import { Spinner } from "../../Atoms/Spinner/Spinner.tsx"; -type Props = { - type: "text" | "select"; - onValueChange: (value: string) => void; - defaultValue?: ReactNode; - children?: ReactNode; - options?: ReactNode; - isLoading?: boolean; +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; }; -export function EditableCell({ - options, - children, - isLoading, - onValueChange, - defaultValue, - type, -}: Props) { - const td = useRef(null); - const [height, setHeight] = useState(0); - const [padding, setPadding] = useState("12px"); +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 EditableCell(props: Props) { + 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) => { if (open) { - setOpen(open); - setHeight(td.current?.offsetHeight ?? 0); + setHeight(td.current?.offsetHeight ?? undefined); setPadding(getComputedStyle(td.current!).paddingLeft); + } else { + setHeight(undefined); } - }; - - const onInputBlur: FocusEventHandler = (e) => { - setOpen(false); - onValueChange(e.target.value); - }; - - const blurOnTab: KeyboardEventHandler = (e) => { - if (e.key === "Tab") { - setOpen(false); - onValueChange(e.currentTarget.value); + // 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 [isOpen, setOpen] = useState(false); + 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; + })(); return ( - + - {type === "select" && ( - <> - - + {props.type === "text" && ( + )} - {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/index.ts b/packages/ui/src/index.ts index 8d302b25b..94673f32c 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -38,7 +38,13 @@ export { InfiniteScrollTrigger } from "./Atoms/InfiniteScrollTrigger/InfiniteScr export { PriorityLevel } from "./Atoms/PriorityLevel/PriorityLevel.tsx"; export { TaskStateIcon } from "./Atoms/Icons/TaskStateIcon"; export { Checkbox } from "./Atoms/Checkbox/Checkbox"; -export { DataTable, Cell, CellHead, Row } from "./Atoms/DataTable/DataTable"; +export { + DataTable, + Cell, + CellHead, + Row, + RowButton, +} from "./Atoms/DataTable/DataTable"; // Molecules export {