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 { 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 }));
|
||||
},
|
||||
[],
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
},
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user