Fix snapshot mode should not be editable

Signed-off-by: Émile Ré <nemile.re@gmail.com>
This commit is contained in:
Émile Ré
2025-12-01 17:12:57 +04:00
parent d0cefdee2f
commit 7739eb1930
3 changed files with 280 additions and 159 deletions

View File

@@ -0,0 +1,167 @@
import { getAssetTypeVariant, promisifyMutation, sprintf } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import { ActionDropdown, Badge, DropdownItem, IconPencil, IconTrashCan, SelectCell, TextCell, useConfirm } from "@probo/ui";
import { useMutation } from "react-relay";
import type { usePaginationFragmentHookType } from "react-relay/relay-hooks/usePaginationFragment";
import { Link } from "react-router";
import { z } from "zod";
import { EditableTable } from "../table/EditableTable";
import { PeopleCell } from "../table/PeopleCell";
import { VendorsCell } from "../table/VendorsCell";
import { createAssetMutation, deleteAssetMutation, updateAssetMutation } from "/hooks/graph/AssetGraph";
import type { AssetGraphDeleteMutation } from "/hooks/graph/__generated__/AssetGraphDeleteMutation.graphql";
import type { AssetsPageFragment$data, AssetsPageFragment$key } from "/pages/organizations/assets/__generated__/AssetsPageFragment.graphql";
import type { OperationType } from "relay-runtime";
import { useOrganizationId } from "/hooks/useOrganizationId";
type Props = {
connectionId: string;
pagination: usePaginationFragmentHookType<
OperationType,
AssetsPageFragment$key,
AssetsPageFragment$data
>;
assets: AssetsPageFragment$data["assets"]["edges"][0]["node"][];
hasAnyAction: boolean;
};
const schema = z.object({
name: z.string().trim().min(1, "Name is required"),
amount: z.coerce.number().min(1, "Amount is required"),
assetType: z.enum(["PHYSICAL", "VIRTUAL"]),
ownerId: z.string().trim().min(1, "Owner is required"),
vendorIds: z.array(z.string()).optional(),
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 function AssetsTable(props: Props) {
const { connectionId, pagination, assets, hasAnyAction } = props;
const organizationId = useOrganizationId();
const { __ } = useTranslate();
const deleteAsset = useDeleteAsset(connectionId);
return (
<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 }) =>
hasAnyAction ? (
<ActionDropdown>
<DropdownItem asChild>
<Link to={`/organizations/${organizationId}/assets/${item.id}`}>
<IconPencil size={16} />
{__("Edit")}
</Link>
</DropdownItem>
<DropdownItem
onClick={() => deleteAsset(item)}
variant="danger"
icon={IconTrashCan}
>
{__("Delete")}
</DropdownItem>
</ActionDropdown>
) : null
}
row={({ item }) => (
<>
<TextCell name="name" defaultValue={item?.name ?? ""} required />
<SelectCell
name="assetType"
items={["VIRTUAL", "PHYSICAL"]}
itemRenderer={({ item }) => (
<Badge variant={getAssetTypeVariant(item ?? "VIRTUAL")}>
{item === "PHYSICAL" ? __("Physical") : __("Virtual")}
</Badge>
)}
defaultValue={item?.assetType ?? defaultValue.assetType}
/>
<TextCell
name="dataTypesStored"
defaultValue={
item?.dataTypesStored ?? defaultValue.dataTypesStored
}
required
/>
<TextCell
name="amount"
defaultValue={(item?.amount ?? defaultValue.amount).toString()}
required
/>
<PeopleCell
name="ownerId"
defaultValue={item?.owner}
organizationId={organizationId}
/>
<VendorsCell
name="vendorIds"
organizationId={organizationId}
defaultValue={
item?.vendors?.edges?.map((edge) => edge.node) ?? []
}
/>
</>
)}
/>
);
}
const useDeleteAsset = (connectionId: string) => {
const [mutate] = useMutation<AssetGraphDeleteMutation>(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,
),
},
);
};
};

View File

@@ -0,0 +1,99 @@
import { Avatar, Badge, Tbody, Td, Th, Thead, Tr } from "@probo/ui";
import { SortableTable } from "../SortableTable";
import { useTranslate } from "@probo/i18n";
import type { usePaginationFragmentHookType } from "react-relay/relay-hooks/usePaginationFragment";
import type { AssetsPageFragment$data, AssetsPageFragment$key } from "/pages/organizations/assets/__generated__/AssetsPageFragment.graphql";
import type { OperationType } from "relay-runtime";
import type { NodeOf } from "/types";
import { useOrganizationId } from "/hooks/useOrganizationId";
import { useParams } from "react-router";
import { faviconUrl, getAssetTypeVariant } from "@probo/helpers";
type AssetEntry = NodeOf<AssetsPageFragment$data["assets"]>;
type Props = {
pagination: usePaginationFragmentHookType<
OperationType,
AssetsPageFragment$key,
AssetsPageFragment$data
>;
assets: AssetEntry[];
};
export function SnapshotAssetsTable(props: Props) {
const { pagination, assets } = props;
const { __ } = useTranslate();
return (
<SortableTable {...pagination}>
<Thead>
<Tr>
<Th>{__("Name")}</Th>
<Th>{__("Type")}</Th>
<Th>{__("Amount")}</Th>
<Th>{__("Owner")}</Th>
<Th>{__("Vendors")}</Th>
</Tr>
</Thead>
<Tbody>
{assets.map((entry) => (
<AssetRow
key={entry.id}
entry={entry}
/>
))}
</Tbody>
</SortableTable>
);
}
function AssetRow({
entry,
}: {
entry: AssetEntry;
}) {
const organizationId = useOrganizationId();
const { __ } = useTranslate();
const { snapshotId } = useParams<{ snapshotId?: string }>();
const vendors = entry.vendors?.edges.map((edge) => edge.node) ?? [];
return (
<Tr to={`/organizations/${organizationId}/snapshots/${snapshotId}/assets/${entry.id}`}>
<Td>{entry.name}</Td>
<Td>
<Badge variant={getAssetTypeVariant(entry.assetType)}>
{entry.assetType === "PHYSICAL" ? __("Physical") : __("Virtual")}
</Badge>
</Td>
<Td>{entry.amount}</Td>
<Td>{entry.owner?.fullName ?? __("Unassigned")}</Td>
<Td>
{vendors.length > 0 ? (
<div className="flex flex-wrap gap-1">
{vendors.slice(0, 3).map((vendor) => (
<Badge
key={vendor.id}
variant="neutral"
className="flex items-center gap-1"
>
<Avatar
name={vendor.name}
src={faviconUrl(vendor.websiteUrl)}
size="s"
/>
<span className="text-xs">{vendor.name}</span>
</Badge>
))}
{vendors.length > 3 && (
<Badge variant="neutral" className="text-xs">
+{vendors.length - 3}
</Badge>
)}
</div>
) : (
<span className="text-txt-secondary text-sm">{__("None")}</span>
)}
</Td>
</Tr>
);
}

View File

@@ -1,47 +1,28 @@
import {
ActionDropdown,
Badge,
Button,
DropdownItem,
IconPencil,
IconPlusLarge,
IconTrashCan,
PageHeader,
SelectCell,
TextCell,
useConfirm,
} from "@probo/ui";
import { useTranslate } from "@probo/i18n";
import { usePageTitle } from "@probo/hooks";
import {
graphql,
type PreloadedQuery,
useMutation,
usePaginationFragment,
usePreloadedQuery,
} from "react-relay";
import { useOrganizationId } from "/hooks/useOrganizationId";
import { Link, useParams } from "react-router";
import { useParams } from "react-router";
import {
assetsQuery,
createAssetMutation,
deleteAssetMutation,
updateAssetMutation,
} from "/hooks/graph/AssetGraph";
import type { AssetGraphListQuery } from "/hooks/graph/__generated__/AssetGraphListQuery.graphql";
import {
getAssetTypeVariant,
promisifyMutation,
sprintf,
} from "@probo/helpers";
import type { AssetsPageFragment$key } from "./__generated__/AssetsPageFragment.graphql";
import { SnapshotBanner } from "/components/SnapshotBanner";
import z from "zod";
import { EditableTable } from "/components/table/EditableTable.tsx";
import { PeopleCell } from "/components/table/PeopleCell.tsx";
import { VendorsCell } from "/components/table/VendorsCell.tsx";
import { CreateAssetDialog } from "./dialogs/CreateAssetDialog";
import { Authorized, isAuthorized } from "/permissions";
import { AssetsTable } from "../../../components/assets/AssetsTable";
import { SnapshotAssetsTable } from "/components/assets/SnapshotAssetsTable";
const paginatedAssetsFragment = graphql`
fragment AssetsPageFragment on Organization
@@ -95,45 +76,19 @@ type Props = {
queryRef: PreloadedQuery<AssetGraphListQuery>;
};
const schema = z.object({
name: z.string().trim().min(1, "Name is required"),
amount: z.coerce.number().min(1, "Amount is required"),
assetType: z.enum(["PHYSICAL", "VIRTUAL"]),
ownerId: z.string().trim().min(1, "Owner is required"),
vendorIds: z.array(z.string()).optional(),
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();
const { snapshotId } = useParams<{ snapshotId?: string }>();
const isSnapshotMode = Boolean(snapshotId);
const assetUrl = (entry: { id: string }) =>
isSnapshotMode && snapshotId
? `/organizations/${organizationId}/snapshots/${snapshotId}/assets/${entry.id}`
: `/organizations/${organizationId}/assets/${entry.id}`;
const data = usePreloadedQuery(assetsQuery, props.queryRef);
const data = usePreloadedQuery<AssetGraphListQuery>(assetsQuery, props.queryRef);
const pagination = usePaginationFragment(
paginatedAssetsFragment,
data.node as AssetsPageFragment$key,
);
const assets = pagination.data.assets?.edges.map((edge) => edge.node);
const connectionId = pagination.data.assets.__id;
const deleteAsset = useDeleteAsset(connectionId);
const hasAnyAction =
!isSnapshotMode &&
@@ -161,116 +116,16 @@ export default function AssetsPage(props: Props) {
</Authorized>
)}
</PageHeader>
<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 }) =>
hasAnyAction ? (
<ActionDropdown>
<DropdownItem asChild>
<Link to={assetUrl(item)}>
<IconPencil size={16} />
{__("Edit")}
</Link>
</DropdownItem>
<DropdownItem
onClick={() => deleteAsset(item)}
variant="danger"
icon={IconTrashCan}
>
{__("Delete")}
</DropdownItem>
</ActionDropdown>
) : null
}
row={({ item }) => (
<>
<TextCell name="name" defaultValue={item?.name ?? ""} required />
<SelectCell
name="assetType"
items={["VIRTUAL", "PHYSICAL"]}
itemRenderer={({ item }) => (
<Badge variant={getAssetTypeVariant(item ?? "VIRTUAL")}>
{item === "PHYSICAL" ? __("Physical") : __("Virtual")}
</Badge>
)}
defaultValue={item?.assetType ?? defaultValue.assetType}
/>
<TextCell
name="dataTypesStored"
defaultValue={
item?.dataTypesStored ?? defaultValue.dataTypesStored
}
required
/>
<TextCell
name="amount"
defaultValue={(item?.amount ?? defaultValue.amount).toString()}
required
/>
<PeopleCell
name="ownerId"
defaultValue={item?.owner}
organizationId={organizationId}
/>
<VendorsCell
name="vendorIds"
organizationId={organizationId}
defaultValue={
item?.vendors?.edges?.map((edge) => edge.node) ?? []
}
/>
</>
)}
/>
{isSnapshotMode ?
<SnapshotAssetsTable pagination={pagination} assets={assets} />
:
<AssetsTable
connectionId={connectionId}
pagination={pagination}
assets={assets}
hasAnyAction={hasAnyAction}
/>
}
</div>
);
}
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,
),
},
);
};
};