Refactor AssetsPage and introduce a new EditableTable component
Signed-off-by: Jonathan <contact@grafikart.fr>
This commit is contained in:
158
apps/console/src/components/table/EditableTable.tsx
Normal file
158
apps/console/src/components/table/EditableTable.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
108
apps/console/src/components/table/SortableDataTable.tsx
Normal file
108
apps/console/src/components/table/SortableDataTable.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,31 +1,39 @@
|
|||||||
import { z, ZodError, type ZodTypeAny } from "zod";
|
import { z, ZodError, type ZodTypeAny } from "zod";
|
||||||
import { useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
|
||||||
export function useStateWithSchema<T extends ZodTypeAny>(
|
export function useStateWithSchema<T extends ZodTypeAny>(
|
||||||
schema: T,
|
schema: T,
|
||||||
initialValue: z.infer<T>,
|
initialValue: z.infer<T>,
|
||||||
) {
|
) {
|
||||||
const [state, setState] = useState(initialValue);
|
const [state, setState] = useState(initialValue);
|
||||||
const errors = useMemo(() => {
|
const [value, errors] = useMemo((): [z.infer<T>, Record<string, string>] => {
|
||||||
try {
|
try {
|
||||||
schema.parse(state);
|
return [schema.parse(state), {}];
|
||||||
return {};
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ZodError) {
|
if (error instanceof ZodError) {
|
||||||
return Object.fromEntries(
|
return [
|
||||||
error.issues.map((issue) => [issue.path.join("."), issue.message]) ??
|
state,
|
||||||
[],
|
Object.fromEntries(
|
||||||
);
|
error.issues.map((issue) => [
|
||||||
|
issue.path.join("."),
|
||||||
|
issue.message,
|
||||||
|
]) ?? [],
|
||||||
|
),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
return {};
|
return [state, {}];
|
||||||
}
|
}
|
||||||
}, [state, schema]);
|
}, [state, schema]);
|
||||||
|
|
||||||
return [
|
return {
|
||||||
state,
|
rawValue: value,
|
||||||
(key: keyof z.infer<T>, value: z.infer<T>[typeof key]) => {
|
value,
|
||||||
setState((prevState) => ({ ...prevState, [key]: value }));
|
|
||||||
},
|
|
||||||
errors,
|
errors,
|
||||||
] as const;
|
update: useCallback(
|
||||||
|
(key: keyof z.infer<T>, value: z.infer<T>[typeof key]) => {
|
||||||
|
setState((prevState) => ({ ...prevState, [key]: value }));
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,25 +3,20 @@ import {
|
|||||||
Avatar,
|
Avatar,
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
Cell,
|
|
||||||
CellHead,
|
|
||||||
DataTable,
|
|
||||||
DropdownItem,
|
DropdownItem,
|
||||||
EditableCell,
|
EditableCell,
|
||||||
IconCheckmark1,
|
|
||||||
IconCrossLargeX,
|
IconCrossLargeX,
|
||||||
IconPlusLarge,
|
IconPlusLarge,
|
||||||
IconTrashCan,
|
IconTrashCan,
|
||||||
PageHeader,
|
PageHeader,
|
||||||
Row,
|
useConfirm,
|
||||||
RowButton,
|
|
||||||
Spinner,
|
|
||||||
} from "@probo/ui";
|
} from "@probo/ui";
|
||||||
import { useTranslate } from "@probo/i18n";
|
import { useTranslate } from "@probo/i18n";
|
||||||
import { usePageTitle, useToggle } from "@probo/hooks";
|
import { usePageTitle } from "@probo/hooks";
|
||||||
import {
|
import {
|
||||||
graphql,
|
graphql,
|
||||||
type PreloadedQuery,
|
type PreloadedQuery,
|
||||||
|
useMutation,
|
||||||
usePaginationFragment,
|
usePaginationFragment,
|
||||||
usePreloadedQuery,
|
usePreloadedQuery,
|
||||||
} from "react-relay";
|
} from "react-relay";
|
||||||
@@ -30,30 +25,24 @@ import { useParams } from "react-router";
|
|||||||
import { CreateAssetDialog } from "./dialogs/CreateAssetDialog";
|
import { CreateAssetDialog } from "./dialogs/CreateAssetDialog";
|
||||||
import {
|
import {
|
||||||
assetsQuery,
|
assetsQuery,
|
||||||
|
createAssetMutation,
|
||||||
|
deleteAssetMutation,
|
||||||
updateAssetMutation,
|
updateAssetMutation,
|
||||||
useCreateAsset,
|
|
||||||
useDeleteAsset,
|
|
||||||
} from "../../../hooks/graph/AssetGraph";
|
} from "../../../hooks/graph/AssetGraph";
|
||||||
import type { AssetGraphListQuery } from "/hooks/graph/__generated__/AssetGraphListQuery.graphql";
|
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 {
|
import {
|
||||||
type MutationFieldUpdate,
|
faviconUrl,
|
||||||
useMutateField,
|
getAssetTypeVariant,
|
||||||
} from "/hooks/useMutateField.tsx";
|
promisifyMutation,
|
||||||
import type { UpdateAssetInput } from "/hooks/graph/__generated__/AssetGraphUpdateMutation.graphql.ts";
|
sprintf,
|
||||||
|
} from "@probo/helpers";
|
||||||
|
import type { AssetsPageFragment$key } from "./__generated__/AssetsPageFragment.graphql";
|
||||||
|
import { SnapshotBanner } from "/components/SnapshotBanner";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
import { useStateWithSchema } from "/hooks/useStateWithSchema.ts";
|
|
||||||
import { usePeople } from "/hooks/graph/PeopleGraph.ts";
|
import { usePeople } from "/hooks/graph/PeopleGraph.ts";
|
||||||
import { useVendors } from "/hooks/graph/VendorGraph.ts";
|
import { useVendors } from "/hooks/graph/VendorGraph.ts";
|
||||||
import clsx from "clsx";
|
import { EditableTable } from "/components/table/EditableTable.tsx";
|
||||||
import { Authorized } from "/permissions";
|
import { Authorized, isAuthorized } from "/permissions";
|
||||||
import { isAuthorized } from "/permissions";
|
|
||||||
|
|
||||||
const paginatedAssetsFragment = graphql`
|
const paginatedAssetsFragment = graphql`
|
||||||
fragment AssetsPageFragment on Organization
|
fragment AssetsPageFragment on Organization
|
||||||
@@ -103,12 +92,30 @@ const paginatedAssetsFragment = graphql`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
type AssetEntry = NodeOf<AssetsPageFragment$data["assets"]>;
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
queryRef: PreloadedQuery<AssetGraphListQuery>;
|
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) {
|
export default function AssetsPage(props: Props) {
|
||||||
const { __ } = useTranslate();
|
const { __ } = useTranslate();
|
||||||
const organizationId = useOrganizationId();
|
const organizationId = useOrganizationId();
|
||||||
@@ -122,16 +129,14 @@ export default function AssetsPage(props: Props) {
|
|||||||
);
|
);
|
||||||
const assets = pagination.data.assets?.edges.map((edge) => edge.node);
|
const assets = pagination.data.assets?.edges.map((edge) => edge.node);
|
||||||
const connectionId = pagination.data.assets.__id;
|
const connectionId = pagination.data.assets.__id;
|
||||||
|
const deleteAsset = useDeleteAsset(connectionId);
|
||||||
|
|
||||||
|
const hasAnyAction =
|
||||||
|
!isSnapshotMode &&
|
||||||
|
(isAuthorized(organizationId, "Asset", "updateAsset") ||
|
||||||
|
isAuthorized(organizationId, "Asset", "deleteAsset"));
|
||||||
usePageTitle(__("Assets"));
|
usePageTitle(__("Assets"));
|
||||||
|
|
||||||
const hasAnyAction = !isSnapshotMode && (
|
|
||||||
isAuthorized(organizationId, "Asset", "updateAsset") ||
|
|
||||||
isAuthorized(organizationId, "Asset", "deleteAsset")
|
|
||||||
);
|
|
||||||
const { update } = useMutateField<UpdateAssetInput>(updateAssetMutation);
|
|
||||||
const [showAdd, toggleAdd] = useToggle(false);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{snapshotId && <SnapshotBanner snapshotId={snapshotId} />}
|
{snapshotId && <SnapshotBanner snapshotId={snapshotId} />}
|
||||||
@@ -152,193 +157,30 @@ export default function AssetsPage(props: Props) {
|
|||||||
</Authorized>
|
</Authorized>
|
||||||
)}
|
)}
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
<DataTable
|
<EditableTable
|
||||||
columns={[...Array.from({ length: 6 }).map(() => "1fr"), "56px"]}
|
connectionId={connectionId}
|
||||||
>
|
pagination={pagination}
|
||||||
<Row>
|
items={assets}
|
||||||
<CellHead>{__("Name")}</CellHead>
|
columns={[
|
||||||
<CellHead>{__("Type")}</CellHead>
|
__("Name"),
|
||||||
<CellHead>{__("Data Types stored")}</CellHead>
|
__("Type"),
|
||||||
<CellHead>{__("Amount")}</CellHead>
|
__("Data Types stored"),
|
||||||
<CellHead>{__("Owner")}</CellHead>
|
__("Amount"),
|
||||||
<CellHead>{__("Vendors")}</CellHead>
|
__("Owner"),
|
||||||
<CellHead></CellHead>
|
__("Vendors"),
|
||||||
</Row>
|
]}
|
||||||
{assets.map((entry) => (
|
schema={schema}
|
||||||
<AssetRow
|
updateMutation={updateAssetMutation}
|
||||||
key={entry.id}
|
createMutation={createAssetMutation}
|
||||||
entry={entry}
|
addLabel={__("Add a new asset")}
|
||||||
connectionId={connectionId}
|
defaultValue={{
|
||||||
onUpdate={(field, value) => update(entry.id, field, value)}
|
...defaultValue,
|
||||||
/>
|
organizationId,
|
||||||
))}
|
}}
|
||||||
{showAdd ? (
|
action={({ item }) => (
|
||||||
<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 && (
|
|
||||||
<ActionDropdown>
|
<ActionDropdown>
|
||||||
<DropdownItem
|
<DropdownItem
|
||||||
onClick={deleteAsset}
|
onClick={() => deleteAsset(item)}
|
||||||
variant="danger"
|
variant="danger"
|
||||||
icon={IconTrashCan}
|
icon={IconTrashCan}
|
||||||
>
|
>
|
||||||
@@ -346,8 +188,72 @@ function AssetRow({
|
|||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
</ActionDropdown>
|
</ActionDropdown>
|
||||||
)}
|
)}
|
||||||
</Cell>
|
row={({ item, onUpdate, errors }) => (
|
||||||
</Row>
|
<>
|
||||||
|
<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>
|
</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,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user