Fix snapshot mode should not be editable
Signed-off-by: Émile Ré <nemile.re@gmail.com>
This commit is contained in:
167
apps/console/src/components/assets/AssetsTable.tsx
Normal file
167
apps/console/src/components/assets/AssetsTable.tsx
Normal 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,
|
||||
),
|
||||
},
|
||||
);
|
||||
};
|
||||
};
|
||||
99
apps/console/src/components/assets/SnapshotAssetsTable.tsx
Normal file
99
apps/console/src/components/assets/SnapshotAssetsTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
},
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user