Refactor AssetsPage and introduce a new EditableTable component

Signed-off-by: Jonathan <contact@grafikart.fr>
This commit is contained in:
Jonathan
2025-11-14 12:44:27 +01:00
committed by Émile Ré
parent 218566505b
commit cb5daab175
4 changed files with 447 additions and 236 deletions

View File

@@ -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<T, S extends z.ZodSchema> = {
item?: T;
onUpdate: (key: keyof z.infer<S>, value: z.infer<S>[typeof key]) => void;
errors: Record<string, string>;
};
/**
* 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<KeyType>
>;
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<T, S>) => 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<S>;
}) {
const { update } = useMutateField(props.updateMutation);
const [showAdd, toggleAdd] = useToggle(false);
return (
<SortableDataTable
columns={[...props.columns.map(() => "1fr"), "56px"]}
refetch={props.pagination.refetch}
hasNext={props.pagination.hasNext}
isLoadingNext={props.pagination.isLoadingNext}
loadNext={props.pagination.loadNext}
>
<Row>
{props.columns.map((column, index) => (
<EditableTableHead column={column} key={index} />
))}
<CellHead />
</Row>
{props.items.map((item) => (
<Row key={item.id}>
{props.row({
item,
onUpdate: (key, value) => update(item.id, key as string, value),
errors: {},
})}
<Cell>{props.action({ item })}</Cell>
</Row>
))}
{showAdd ? (
<NewItemRow
schema={props.schema}
defaultValue={props.defaultValue}
connectionId={props.connectionId}
row={props.row}
mutation={props.createMutation}
/>
) : (
<RowButton onClick={toggleAdd}>{props.addLabel}</RowButton>
)}
</SortableDataTable>
);
}
function NewItemRow<S extends z.ZodSchema>(props: {
schema: S;
defaultValue: z.infer<S>;
connectionId: string;
mutation: GraphQLTaggedNode;
row: (props: EditableTableRowProps<any, S>) => 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 (
<Row>
{props.row({ errors, onUpdate: update })}
<Cell>
<Button
disabled={!isOk || isMutating}
variant="tertiary"
className={clsx(isOk ? "text-txt-success" : "text-txt-secondary")}
onClick={onSubmit}
>
{isMutating ? <Spinner size={16} /> : <IconCheckmark1 size={16} />}
</Button>
</Cell>
</Row>
);
}
function EditableTableHead(props: { column: ColumnDefinition }) {
if (typeof props.column === "string") {
return <CellHead>{props.column}</CellHead>;
}
return (
<SortableCellHead field={props.column.field}>
{props.column.label}
</SortableCellHead>
);
}

View File

@@ -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<typeof DataTable> & {
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 (
<SortableContext value={{ order, onOrderChange }}>
<div className="space-y-4">
<DataTable {...props} />
{hasNext && loadNext && (
<Button
variant="tertiary"
onClick={() => loadNext()}
className="mt-3 mx-auto"
disabled={isLoadingNext}
icon={isLoadingNext ? Spinner : IconChevronDown}
>
{__("Show more")}
</Button>
)}
</div>
</SortableContext>
);
}
export function SortableCellHead({
children,
field,
...props
}: ComponentProps<typeof CellHead> & { 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 (
<CellHead {...props}>
<button
className="flex items-center cursor-pointer hover:text-txt-primary"
onClick={changeOrder}
>
{children}
<IconChevronTriangleDownSmall
size={16}
className={clsx(
isCurrentField && "text-txt-primary",
isCurrentField && !isDesc && "rotate-180",
)}
/>
</button>
</CellHead>
);
}

View File

@@ -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<T extends ZodTypeAny>(
schema: T,
initialValue: z.infer<T>,
) {
const [state, setState] = useState(initialValue);
const errors = useMemo(() => {
const [value, errors] = useMemo((): [z.infer<T>, Record<string, string>] => {
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<T>, value: z.infer<T>[typeof key]) => {
setState((prevState) => ({ ...prevState, [key]: value }));
},
return {
rawValue: value,
value,
errors,
] as const;
update: useCallback(
(key: keyof z.infer<T>, value: z.infer<T>[typeof key]) => {
setState((prevState) => ({ ...prevState, [key]: value }));
},
[],
),
};
}

View File

@@ -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<AssetsPageFragment$data["assets"]>;
type Props = {
queryRef: PreloadedQuery<AssetGraphListQuery>;
};
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<typeof schema>;
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<UpdateAssetInput>(updateAssetMutation);
const [showAdd, toggleAdd] = useToggle(false);
return (
<div className="space-y-6">
{snapshotId && <SnapshotBanner snapshotId={snapshotId} />}
@@ -152,193 +157,30 @@ export default function AssetsPage(props: Props) {
</Authorized>
)}
</PageHeader>
<DataTable
columns={[...Array.from({ length: 6 }).map(() => "1fr"), "56px"]}
>
<Row>
<CellHead>{__("Name")}</CellHead>
<CellHead>{__("Type")}</CellHead>
<CellHead>{__("Data Types stored")}</CellHead>
<CellHead>{__("Amount")}</CellHead>
<CellHead>{__("Owner")}</CellHead>
<CellHead>{__("Vendors")}</CellHead>
<CellHead></CellHead>
</Row>
{assets.map((entry) => (
<AssetRow
key={entry.id}
entry={entry}
connectionId={connectionId}
onUpdate={(field, value) => update(entry.id, field, value)}
/>
))}
{showAdd ? (
<AssetAddRow
organizationId={organizationId}
onSuccess={toggleAdd}
connection={connectionId}
/>
) : (
<RowButton onClick={toggleAdd}>{__("Add a new asset")}</RowButton>
)}
</DataTable>
</div>
);
}
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 (
<AssetRow
// @ts-expect-error - TS doesn't know form value match schema
onUpdate={setValue}
onSubmit={onSubmit}
errors={errors}
loading={isMutating}
/>
);
}
function AssetRow({
entry,
connectionId,
onUpdate,
onSubmit,
errors,
loading,
}: {
entry?: AssetEntry;
connectionId?: string;
onUpdate: MutationFieldUpdate<UpdateAssetInput>;
onSubmit?: () => void;
errors?: Record<string, string>;
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 (
<Row>
<EditableCell
type="text"
value={entry?.name ?? ""}
onValueChange={(v) => onUpdate("name", v)}
blink={Boolean(errors?.name)}
/>
<EditableCell
type="select"
items={["VIRTUAL", "PHYSICAL"]}
value={entry?.assetType ?? "VIRTUAL"}
itemRenderer={({ item }) => (
<Badge variant={getAssetTypeVariant(item ?? "VIRTUAL")}>
{item === "PHYSICAL" ? __("Physical") : __("Virtual")}
</Badge>
)}
onValueChange={(v) => onUpdate("assetType", v)}
blink={Boolean(errors?.assetType)}
/>
<EditableCell
type="text"
value={entry?.dataTypesStored ?? ""}
onValueChange={(v) => onUpdate("dataTypesStored", v)}
blink={Boolean(errors?.dataTypeStored)}
/>
<EditableCell
type="text"
value={entry?.amount.toString() ?? ""}
onValueChange={(v) => onUpdate("amount", v)}
blink={Boolean(errors?.amount)}
/>
<EditableCell
type="select"
items={() => usePeople(organizationId, { excludeContractEnded: true })}
value={entry?.owner}
itemRenderer={({ item }) => (
<div className="flex gap-2">
<Avatar name={item.fullName} />
{item.fullName}
</div>
)}
onValueChange={(v) => onUpdate("ownerId", v.id)}
blink={Boolean(errors?.ownerId)}
/>
<EditableCell
type="multiple"
items={() => useVendors(organizationId)}
value={entry?.vendors.edges.map((edge) => edge.node)}
itemRenderer={({ item, onRemove }) => (
<VendorBadge key={item.id} vendor={item} onRemove={onRemove} />
)}
onValueChange={(v) =>
onUpdate(
"vendorIds",
v.map((v) => v.id),
)
}
blink={Boolean(errors?.vendorIds)}
/>
<Cell className="text-end">
{loading && (
<Button
disabled={true}
variant="tertiary"
className="text-txt-secondary"
>
<Spinner size={16} />
</Button>
)}
{onSubmit && !loading && (
<Button
disabled={!isOk}
variant="tertiary"
className={clsx(isOk ? "text-txt-success" : "text-txt-secondary")}
onClick={onSubmit}
>
<IconCheckmark1 size={16} />
</Button>
)}
{!isSnapshotMode && entry && (
<EditableTable
connectionId={connectionId}
pagination={pagination}
items={assets}
columns={[
__("Name"),
__("Type"),
__("Data Types stored"),
__("Amount"),
__("Owner"),
__("Vendors"),
]}
schema={schema}
updateMutation={updateAssetMutation}
createMutation={createAssetMutation}
addLabel={__("Add a new asset")}
defaultValue={{
...defaultValue,
organizationId,
}}
action={({ item }) => (
<ActionDropdown>
<DropdownItem
onClick={deleteAsset}
onClick={() => deleteAsset(item)}
variant="danger"
icon={IconTrashCan}
>
@@ -346,8 +188,72 @@ function AssetRow({
</DropdownItem>
</ActionDropdown>
)}
</Cell>
</Row>
row={({ item, onUpdate, errors }) => (
<>
<EditableCell
type="text"
value={item?.name ?? ""}
onValueChange={(v) => onUpdate("name", v)}
blink={Boolean(errors?.name)}
/>
<EditableCell
type="select"
items={["VIRTUAL", "PHYSICAL"]}
value={item?.assetType ?? "VIRTUAL"}
itemRenderer={({ item }) => (
<Badge variant={getAssetTypeVariant(item ?? "VIRTUAL")}>
{item === "PHYSICAL" ? __("Physical") : __("Virtual")}
</Badge>
)}
onValueChange={(v) => onUpdate("assetType", v)}
blink={Boolean(errors?.assetType)}
/>
<EditableCell
type="text"
value={item?.dataTypesStored ?? ""}
onValueChange={(v) => onUpdate("dataTypesStored", v)}
blink={Boolean(errors?.dataTypesStored)}
/>
<EditableCell
type="text"
value={item?.amount.toString() ?? "0"}
onValueChange={(v) => onUpdate("amount", v)}
blink={Boolean(errors?.amount)}
/>
<EditableCell
type="select"
items={() =>
usePeople(organizationId, { excludeContractEnded: true })
}
value={item?.owner}
itemRenderer={({ item }) => (
<div className="flex gap-2">
<Avatar name={item.fullName} />
{item.fullName}
</div>
)}
onValueChange={(v) => onUpdate("ownerId", v.id)}
blink={Boolean(errors?.ownerId)}
/>
<EditableCell
type="multiple"
items={() => useVendors(organizationId)}
value={item?.vendors.edges.map((edge) => edge.node)}
itemRenderer={({ item, onRemove }) => (
<VendorBadge key={item.id} vendor={item} onRemove={onRemove} />
)}
onValueChange={(v) =>
onUpdate(
"vendorIds",
v.map((v) => v.id),
)
}
blink={Boolean(errors?.vendorIds)}
/>
</>
)}
/>
</div>
);
}
@@ -381,3 +287,34 @@ function VendorBadge({
</Badge>
);
}
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,
),
},
);
};
};