Ref EditableCell signature
Signed-off-by: Jonathan <contact@grafikart.fr>
This commit is contained in:
@@ -46,75 +46,32 @@ function PeopleSelectWithQuery(
|
||||
const people = usePeople(organizationId, { excludeContractEnded: true });
|
||||
|
||||
return (
|
||||
<>
|
||||
<Controller
|
||||
control={control}
|
||||
name={name}
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
disabled={props.disabled}
|
||||
id={name}
|
||||
variant="editor"
|
||||
placeholder={__("Select an owner")}
|
||||
onValueChange={(value) =>
|
||||
field.onChange(value === "__NONE__" ? null : value)
|
||||
}
|
||||
key={people?.length.toString() ?? "0"}
|
||||
{...field}
|
||||
className="w-full"
|
||||
value={field.value ?? (props.optional ? "__NONE__" : "")}
|
||||
>
|
||||
{props.optional && <Option value="__NONE__">{__("None")}</Option>}
|
||||
{people?.map((p) => (
|
||||
<Option key={p.id} value={p.id} className="flex gap-2">
|
||||
<Avatar name={p.fullName} />
|
||||
{p.fullName}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type OptionsProps = {
|
||||
organizationId: string;
|
||||
optional?: boolean;
|
||||
} & ComponentProps<typeof Field>;
|
||||
|
||||
export function PeopleSelectOptions({
|
||||
organizationId,
|
||||
...props
|
||||
}: OptionsProps) {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={<Select variant="editor" loading placeholder="Loading..." />}
|
||||
>
|
||||
<PeopleSelectOptionsWithQuery
|
||||
organizationId={organizationId}
|
||||
optional={props.optional}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function PeopleSelectOptionsWithQuery(
|
||||
props: Pick<Props, "organizationId" | "disabled" | "optional">,
|
||||
) {
|
||||
const { __ } = useTranslate();
|
||||
const { organizationId } = props;
|
||||
const people = usePeople(organizationId, { excludeContractEnded: true });
|
||||
|
||||
return (
|
||||
<>
|
||||
{props.optional && <Option value="__NONE__">{__("None")}</Option>}
|
||||
{people?.map((p) => (
|
||||
<Option key={p.id} value={p.id} className="flex gap-2">
|
||||
<Avatar name={p.fullName} />
|
||||
{p.fullName}
|
||||
</Option>
|
||||
))}
|
||||
</>
|
||||
<Controller
|
||||
control={control}
|
||||
name={name}
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
disabled={props.disabled}
|
||||
id={name}
|
||||
variant="editor"
|
||||
placeholder={__("Select an owner")}
|
||||
onValueChange={(value) =>
|
||||
field.onChange(value === "__NONE__" ? null : value)
|
||||
}
|
||||
key={people?.length.toString() ?? "0"}
|
||||
{...field}
|
||||
className="w-full"
|
||||
value={field.value ?? (props.optional ? "__NONE__" : "")}
|
||||
>
|
||||
{props.optional && <Option value="__NONE__">{__("None")}</Option>}
|
||||
{people?.map((p) => (
|
||||
<Option key={p.id} value={p.id} className="flex gap-2">
|
||||
<Avatar name={p.fullName} />
|
||||
{p.fullName}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -121,13 +121,17 @@ export const deleteAssetMutation = graphql`
|
||||
`;
|
||||
|
||||
export const useDeleteAsset = (
|
||||
asset: { id?: string; name?: string },
|
||||
connectionId: string
|
||||
asset?: { id?: string; name?: string },
|
||||
connectionId?: string,
|
||||
) => {
|
||||
const [mutate] = useMutation(deleteAssetMutation);
|
||||
const confirm = useConfirm();
|
||||
const { __ } = useTranslate();
|
||||
|
||||
if (!asset) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (!asset.id || !asset.name) {
|
||||
return alert(__("Failed to delete asset: missing id or name"));
|
||||
@@ -145,56 +149,61 @@ export const useDeleteAsset = (
|
||||
{
|
||||
message: sprintf(
|
||||
__(
|
||||
'This will permanently delete "%s". This action cannot be undone.'
|
||||
'This will permanently delete "%s". This action cannot be undone.',
|
||||
),
|
||||
asset.name
|
||||
asset.name,
|
||||
),
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
export const useCreateAsset = (connectionId: string) => {
|
||||
const [mutate] = useMutation(createAssetMutation);
|
||||
const [mutate, isMutating] = useMutation(createAssetMutation);
|
||||
const { __ } = useTranslate();
|
||||
|
||||
return (input: {
|
||||
name: string;
|
||||
amount: number;
|
||||
assetType: string;
|
||||
ownerId: string;
|
||||
organizationId: string;
|
||||
vendorIds?: string[];
|
||||
dataTypesStored: string;
|
||||
}) => {
|
||||
if (!input.name?.trim()) {
|
||||
return alert(__("Failed to create asset: name is required"));
|
||||
}
|
||||
if (!input.ownerId) {
|
||||
return alert(__("Failed to create asset: owner is required"));
|
||||
}
|
||||
if (!input.organizationId) {
|
||||
return alert(__("Failed to create asset: organization is required"));
|
||||
}
|
||||
if (!input.dataTypesStored) {
|
||||
return alert(__("Failed to create asset: data types stored is required"));
|
||||
}
|
||||
return [
|
||||
(input: {
|
||||
name: string;
|
||||
amount: number;
|
||||
assetType: string;
|
||||
ownerId: string;
|
||||
organizationId: string;
|
||||
vendorIds?: string[];
|
||||
dataTypesStored: string;
|
||||
}) => {
|
||||
if (!input.name?.trim()) {
|
||||
return alert(__("Failed to create asset: name is required"));
|
||||
}
|
||||
if (!input.ownerId) {
|
||||
return alert(__("Failed to create asset: owner is required"));
|
||||
}
|
||||
if (!input.organizationId) {
|
||||
return alert(__("Failed to create asset: organization is required"));
|
||||
}
|
||||
if (!input.dataTypesStored) {
|
||||
return alert(
|
||||
__("Failed to create asset: data types stored is required"),
|
||||
);
|
||||
}
|
||||
|
||||
return promisifyMutation(mutate)({
|
||||
variables: {
|
||||
input: {
|
||||
name: input.name,
|
||||
amount: input.amount,
|
||||
assetType: input.assetType,
|
||||
dataTypesStored: input.dataTypesStored || "",
|
||||
ownerId: input.ownerId,
|
||||
organizationId: input.organizationId,
|
||||
vendorIds: input.vendorIds || [],
|
||||
return promisifyMutation(mutate)({
|
||||
variables: {
|
||||
input: {
|
||||
name: input.name,
|
||||
amount: input.amount,
|
||||
assetType: input.assetType,
|
||||
dataTypesStored: input.dataTypesStored || "",
|
||||
ownerId: input.ownerId,
|
||||
organizationId: input.organizationId,
|
||||
vendorIds: input.vendorIds || [],
|
||||
},
|
||||
connections: [connectionId],
|
||||
},
|
||||
connections: [connectionId],
|
||||
},
|
||||
});
|
||||
};
|
||||
});
|
||||
},
|
||||
isMutating,
|
||||
] as const;
|
||||
};
|
||||
|
||||
export const useUpdateAsset = () => {
|
||||
|
||||
33
apps/console/src/hooks/useMutateField.tsx
Normal file
33
apps/console/src/hooks/useMutateField.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { GraphQLTaggedNode } from "relay-runtime";
|
||||
import { useMutation } from "react-relay";
|
||||
|
||||
export type MutationFieldUpdate<T> = (
|
||||
field: keyof T,
|
||||
value: T[typeof field],
|
||||
) => void;
|
||||
|
||||
/**
|
||||
* Mutate a single field from a graphql mutation
|
||||
*/
|
||||
export function useMutateField<Input extends Record<string, unknown>>(
|
||||
mutation: GraphQLTaggedNode,
|
||||
) {
|
||||
const [mutate, isUpdating] = useMutation(mutation);
|
||||
|
||||
return {
|
||||
update<T extends keyof Input>(id: string, fieldName: T, value: Input[T]) {
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
mutate({
|
||||
variables: {
|
||||
input: {
|
||||
id: id,
|
||||
[fieldName]: value,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
isUpdating,
|
||||
};
|
||||
}
|
||||
31
apps/console/src/hooks/useStateWithSchema.ts
Normal file
31
apps/console/src/hooks/useStateWithSchema.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { z, ZodError, type ZodTypeAny } from "zod";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
export function useStateWithSchema<T extends ZodTypeAny>(
|
||||
schema: T,
|
||||
initialValue: z.infer<T>,
|
||||
) {
|
||||
const [state, setState] = useState(initialValue);
|
||||
const errors = useMemo(() => {
|
||||
try {
|
||||
schema.parse(state);
|
||||
return {};
|
||||
} catch (error) {
|
||||
if (error instanceof ZodError) {
|
||||
return Object.fromEntries(
|
||||
error.issues.map((issue) => [issue.path.join("."), issue.message]) ??
|
||||
[],
|
||||
);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
}, [state, schema]);
|
||||
|
||||
return [
|
||||
state,
|
||||
(key: keyof z.infer<T>, value: z.infer<T>[typeof key]) => {
|
||||
setState((prevState) => ({ ...prevState, [key]: value }));
|
||||
},
|
||||
errors,
|
||||
] as const;
|
||||
}
|
||||
@@ -1,55 +1,59 @@
|
||||
import {
|
||||
Button,
|
||||
IconPlusLarge,
|
||||
PageHeader,
|
||||
Thead,
|
||||
Tbody,
|
||||
Tr,
|
||||
Th,
|
||||
Td,
|
||||
Badge,
|
||||
ActionDropdown,
|
||||
DropdownItem,
|
||||
IconTrashCan,
|
||||
Avatar,
|
||||
EditableCell,
|
||||
Select,
|
||||
Option,
|
||||
DataTable,
|
||||
CellHead,
|
||||
Badge,
|
||||
Button,
|
||||
Cell,
|
||||
CellHead,
|
||||
DataTable,
|
||||
DropdownItem,
|
||||
EditableCell,
|
||||
IconCheckmark1,
|
||||
IconCrossLargeX,
|
||||
IconPlusLarge,
|
||||
IconTrashCan,
|
||||
PageHeader,
|
||||
Row,
|
||||
RowButton,
|
||||
Spinner,
|
||||
} from "@probo/ui";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import { usePageTitle } from "@probo/hooks";
|
||||
import { usePageTitle, useToggle } from "@probo/hooks";
|
||||
import {
|
||||
graphql,
|
||||
type PreloadedQuery,
|
||||
usePaginationFragment,
|
||||
usePreloadedQuery,
|
||||
type PreloadedQuery,
|
||||
useMutation,
|
||||
} from "react-relay";
|
||||
import { useOrganizationId } from "/hooks/useOrganizationId";
|
||||
import { useParams } from "react-router";
|
||||
import { CreateAssetDialog } from "./dialogs/CreateAssetDialog";
|
||||
import {
|
||||
useDeleteAsset,
|
||||
assetsQuery,
|
||||
updateAssetMutation,
|
||||
useCreateAsset,
|
||||
useDeleteAsset,
|
||||
} from "../../../hooks/graph/AssetGraph";
|
||||
import type { AssetGraphListQuery } from "/hooks/graph/__generated__/AssetGraphListQuery.graphql";
|
||||
import { faviconUrl } from "@probo/helpers";
|
||||
import { faviconUrl, getAssetTypeVariant } from "@probo/helpers";
|
||||
import type { NodeOf } from "/types";
|
||||
import { getAssetTypeVariant } from "@probo/helpers";
|
||||
import type {
|
||||
AssetsPageFragment$data,
|
||||
AssetsPageFragment$key,
|
||||
} from "./__generated__/AssetsPageFragment.graphql";
|
||||
import { SortableTable } from "/components/SortableTable";
|
||||
import { SnapshotBanner } from "/components/SnapshotBanner";
|
||||
import {
|
||||
type MutationFieldUpdate,
|
||||
useMutateField,
|
||||
} from "/hooks/useMutateField.tsx";
|
||||
import type { UpdateAssetInput } from "/hooks/graph/__generated__/AssetGraphUpdateMutation.graphql.ts";
|
||||
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 { PeopleSelectOptions } from "/components/form/PeopleSelectField.tsx";
|
||||
|
||||
const paginatedAssetsFragment = graphql`
|
||||
fragment AssetsPageFragment on Organization
|
||||
@@ -125,6 +129,8 @@ export default function AssetsPage(props: Props) {
|
||||
isAuthorized(organizationId, "Asset", "updateAsset") ||
|
||||
isAuthorized(organizationId, "Asset", "deleteAsset")
|
||||
);
|
||||
const { update } = useMutateField<UpdateAssetInput>(updateAssetMutation);
|
||||
const [showAdd, toggleAdd] = useToggle(false);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -146,130 +152,190 @@ export default function AssetsPage(props: Props) {
|
||||
</Authorized>
|
||||
)}
|
||||
</PageHeader>
|
||||
<DataTable columns={["1fr", "1fr", "1fr", "1fr", "1fr", "56px"]}>
|
||||
<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} />
|
||||
<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;
|
||||
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 vendors = entry.vendors?.edges.map((edge) => edge.node) ?? [];
|
||||
|
||||
const assetUrl =
|
||||
isSnapshotMode && snapshotId
|
||||
? `/organizations/${organizationId}/snapshots/${snapshotId}/assets/${entry.id}`
|
||||
: `/organizations/${organizationId}/assets/${entry.id}`;
|
||||
|
||||
const [mutate, isLoading] = useMutation(updateAssetMutation);
|
||||
const updater = (fieldName: keyof typeof entry) => (value: string) => {
|
||||
// Only send an update if the value changed
|
||||
if (entry[fieldName] === value) {
|
||||
return;
|
||||
}
|
||||
mutate({
|
||||
variables: {
|
||||
input: {
|
||||
id: entry.id,
|
||||
[fieldName]: value,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const isOk = Object.keys(errors ?? {}).length === 0;
|
||||
return (
|
||||
<Row>
|
||||
<EditableCell
|
||||
type="text"
|
||||
defaultValue={entry.name}
|
||||
onValueChange={updater("name")}
|
||||
value={entry?.name ?? ""}
|
||||
onValueChange={(v) => onUpdate("name", v)}
|
||||
blink={Boolean(errors?.name)}
|
||||
/>
|
||||
<EditableCell
|
||||
type="select"
|
||||
isLoading={isLoading}
|
||||
onValueChange={updater("assetType")}
|
||||
options={
|
||||
<>
|
||||
<Option value="VIRTUAL">
|
||||
<Badge variant={getAssetTypeVariant("VIRTUAL")}>
|
||||
{__("Virtual")}
|
||||
</Badge>
|
||||
</Option>
|
||||
<Option value="PHYSICAL">
|
||||
<Badge variant={getAssetTypeVariant("PHYSICAL")}>
|
||||
{__("Physical")}
|
||||
</Badge>
|
||||
</Option>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Badge variant={getAssetTypeVariant(entry.assetType)}>
|
||||
{entry.assetType === "PHYSICAL" ? __("Physical") : __("Virtual")}
|
||||
</Badge>
|
||||
</EditableCell>
|
||||
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"
|
||||
defaultValue={entry.amount}
|
||||
onValueChange={updater("amount")}
|
||||
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"
|
||||
isLoading={isLoading}
|
||||
onValueChange={updater("owner")}
|
||||
options={<PeopleSelectOptions organizationId={organizationId} />}
|
||||
>
|
||||
{entry.owner?.fullName ?? __("Unassigned")}
|
||||
</EditableCell>
|
||||
<Cell>
|
||||
{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>
|
||||
)}
|
||||
items={() => usePeople(organizationId, { excludeContractEnded: true })}
|
||||
value={entry?.owner}
|
||||
itemRenderer={({ item }) => (
|
||||
<div className="flex gap-2">
|
||||
<Avatar name={item.fullName} />
|
||||
{item.fullName}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-txt-secondary text-sm">{__("None")}</span>
|
||||
)}
|
||||
</Cell>
|
||||
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">
|
||||
{!isSnapshotMode && (
|
||||
{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>
|
||||
<DropdownItem
|
||||
onClick={deleteAsset}
|
||||
@@ -284,3 +350,34 @@ function AssetRow({
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
type Vendor = {
|
||||
id: string;
|
||||
name: string;
|
||||
websiteUrl: string | null | undefined;
|
||||
};
|
||||
|
||||
function VendorBadge({
|
||||
vendor,
|
||||
onRemove,
|
||||
}: {
|
||||
vendor: Vendor;
|
||||
onRemove?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Badge variant="neutral" className="flex items-center gap-1">
|
||||
<Avatar name={vendor.name} src={faviconUrl(vendor.websiteUrl)} size="s" />
|
||||
<span className="max-w-[100px] text-ellipsis overflow-hidden min-w-0 block">
|
||||
{vendor.name}
|
||||
</span>
|
||||
{onRemove && (
|
||||
<button
|
||||
onClick={onRemove}
|
||||
className="size-4 hover:text-txt-primary cursor-pointer"
|
||||
>
|
||||
<IconCrossLargeX size={14} />
|
||||
</button>
|
||||
)}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ export function CreateAssetDialog({
|
||||
},
|
||||
});
|
||||
const ref = useDialogRef();
|
||||
const createAsset = useCreateAsset(connection);
|
||||
const [createAsset] = useCreateAsset(connection);
|
||||
|
||||
const onSubmit = handleSubmit(async (data) => {
|
||||
try {
|
||||
|
||||
@@ -12,11 +12,9 @@ export const assetRoutes = [
|
||||
queryLoader: (params: Record<string, string>) =>
|
||||
loadQuery(relayEnvironment, assetsQuery, {
|
||||
organizationId: params.organizationId,
|
||||
snapshotId: null
|
||||
snapshotId: null,
|
||||
}),
|
||||
Component: lazy(
|
||||
() => import("/pages/organizations/assets/AssetsPage")
|
||||
),
|
||||
Component: lazy(() => import("/pages/organizations/assets/AssetsPage")),
|
||||
},
|
||||
{
|
||||
path: "snapshots/:snapshotId/assets",
|
||||
@@ -24,20 +22,9 @@ export const assetRoutes = [
|
||||
queryLoader: (params: Record<string, string>) =>
|
||||
loadQuery(relayEnvironment, assetsQuery, {
|
||||
organizationId: params.organizationId,
|
||||
snapshotId: params.snapshotId
|
||||
snapshotId: params.snapshotId,
|
||||
}),
|
||||
Component: lazy(
|
||||
() => import("/pages/organizations/assets/AssetsPage")
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "assets/:assetId",
|
||||
fallback: PageSkeleton,
|
||||
queryLoader: (params: Record<string, string>) =>
|
||||
loadQuery(relayEnvironment, assetNodeQuery, { assetId: params.assetId }),
|
||||
Component: lazy(
|
||||
() => import("/pages/organizations/assets/AssetDetailsPage")
|
||||
),
|
||||
Component: lazy(() => import("/pages/organizations/assets/AssetsPage")),
|
||||
},
|
||||
{
|
||||
path: "snapshots/:snapshotId/assets/:assetId",
|
||||
@@ -45,7 +32,7 @@ export const assetRoutes = [
|
||||
queryLoader: (params: Record<string, string>) =>
|
||||
loadQuery(relayEnvironment, assetNodeQuery, { assetId: params.assetId }),
|
||||
Component: lazy(
|
||||
() => import("/pages/organizations/assets/AssetDetailsPage")
|
||||
() => import("/pages/organizations/assets/AssetDetailsPage"),
|
||||
),
|
||||
},
|
||||
] satisfies AppRoute[];
|
||||
|
||||
17
package-lock.json
generated
17
package-lock.json
generated
@@ -5756,6 +5756,22 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/cmdk": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz",
|
||||
"integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "^1.1.1",
|
||||
"@radix-ui/react-dialog": "^1.1.6",
|
||||
"@radix-ui/react-id": "^1.1.0",
|
||||
"@radix-ui/react-primitive": "^2.0.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18 || ^19 || ^19.0.0-rc",
|
||||
"react-dom": "^18 || ^19 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
@@ -12884,6 +12900,7 @@
|
||||
"@radix-ui/react-tabs": "^1.1.12",
|
||||
"@tailwindcss/vite": "^4.1.7",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"react-dropzone": "^14.3.8",
|
||||
"react-intersection-observer": "^9.16.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"react-dropzone": "^14.3.8",
|
||||
"react-intersection-observer": "^9.16.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { type ComponentPropsWithRef, type PropsWithChildren } from "react";
|
||||
import {
|
||||
type ComponentPropsWithRef,
|
||||
type FC,
|
||||
type PropsWithChildren,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { Card } from "../Card/Card";
|
||||
import clsx from "clsx";
|
||||
import { type AsChildProps, Slot } from "../Slot.tsx";
|
||||
import { IconPlusLarge } from "../Icons";
|
||||
|
||||
export function DataTable({
|
||||
children,
|
||||
@@ -72,3 +78,25 @@ export function Cell({
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function RowButton({
|
||||
icon = IconPlusLarge,
|
||||
children,
|
||||
...props
|
||||
}: {
|
||||
colspan?: number;
|
||||
children: ReactNode;
|
||||
icon?: FC<{ size: number; className?: string }>;
|
||||
} & ComponentPropsWithRef<"button">) {
|
||||
const IconComponent = icon;
|
||||
return (
|
||||
<button
|
||||
{...props}
|
||||
className="py-2 bg-highlight hover:bg-highlight-hover active:bg-highlight-pressed cursor-pointer w-full flex gap-2 items-center justify-center text-sm text-txt-secondary"
|
||||
style={{ gridColumnEnd: -1, gridColumnStart: 1 }}
|
||||
>
|
||||
<IconComponent size={16} />
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
type FC,
|
||||
type HTMLAttributes,
|
||||
type PropsWithChildren,
|
||||
type ReactNode,
|
||||
type ThHTMLAttributes,
|
||||
type ComponentPropsWithRef,
|
||||
useContext,
|
||||
} from "react";
|
||||
import { Card } from "../Card/Card";
|
||||
import { Link } from "react-router";
|
||||
|
||||
@@ -1,112 +1,338 @@
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
import {
|
||||
type FocusEventHandler,
|
||||
type KeyboardEventHandler,
|
||||
type ReactNode,
|
||||
Suspense,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
import { Select } from "../../Atoms/Select/Select.tsx";
|
||||
import { Spinner } from "../../Atoms/Spinner/Spinner.tsx";
|
||||
import { Cell } from "../../Atoms/DataTable/DataTable.tsx";
|
||||
import { Command } from "cmdk";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import { Spinner } from "../../Atoms/Spinner/Spinner.tsx";
|
||||
|
||||
type Props = {
|
||||
type: "text" | "select";
|
||||
onValueChange: (value: string) => void;
|
||||
defaultValue?: ReactNode;
|
||||
children?: ReactNode;
|
||||
options?: ReactNode;
|
||||
isLoading?: boolean;
|
||||
type Props<T> =
|
||||
| {
|
||||
type: "text";
|
||||
value?: T;
|
||||
blink?: boolean;
|
||||
onValueChange: (value: string) => void;
|
||||
itemRenderer?: undefined;
|
||||
}
|
||||
| {
|
||||
type: "select";
|
||||
items: T[] | (() => T[]);
|
||||
itemRenderer: (v: { item: T }) => ReactNode;
|
||||
value?: T;
|
||||
blink?: boolean;
|
||||
onValueChange: (value: T) => void;
|
||||
}
|
||||
| {
|
||||
type: "multiple";
|
||||
items: T[] | (() => T[]);
|
||||
itemRenderer: (v: { item: T; onRemove?: () => void }) => ReactNode;
|
||||
value?: T[];
|
||||
blink?: boolean;
|
||||
onValueChange: (value: T[]) => void;
|
||||
};
|
||||
|
||||
type PropsField<Type, T> = Props<T> & {
|
||||
type: Type;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
padding: string;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export function EditableCell({
|
||||
options,
|
||||
children,
|
||||
isLoading,
|
||||
onValueChange,
|
||||
defaultValue,
|
||||
type,
|
||||
}: Props) {
|
||||
const td = useRef<HTMLTableCellElement>(null);
|
||||
const [height, setHeight] = useState(0);
|
||||
const [padding, setPadding] = useState("12px");
|
||||
function getKey<T>(item: T): string {
|
||||
if (
|
||||
item &&
|
||||
typeof item === "object" &&
|
||||
"id" in item &&
|
||||
typeof item.id === "string"
|
||||
) {
|
||||
return item.id.toString();
|
||||
}
|
||||
if (typeof item === "string" || typeof item === "number") {
|
||||
return item.toString();
|
||||
}
|
||||
if (item === undefined) {
|
||||
return "";
|
||||
}
|
||||
console.error("Cannot compute a key from item", item);
|
||||
return "";
|
||||
}
|
||||
|
||||
export function EditableCell<T>(props: Props<T>) {
|
||||
const [isOpen, setOpen] = useState(false);
|
||||
const [value, setValueState] = useState(props.value);
|
||||
const [height, setHeight] = useState<number | undefined>(undefined);
|
||||
const [padding, setPadding] = useState("12px");
|
||||
const td = useRef<HTMLTableCellElement>(null);
|
||||
const valueRef = useRef(value);
|
||||
const setValue = (value: T) => {
|
||||
valueRef.current = value;
|
||||
setValueState(value);
|
||||
};
|
||||
|
||||
// When opening the popover, remember the height and padding of the cell
|
||||
const onOpenChange = (open: boolean) => {
|
||||
if (open) {
|
||||
setOpen(open);
|
||||
setHeight(td.current?.offsetHeight ?? 0);
|
||||
setHeight(td.current?.offsetHeight ?? undefined);
|
||||
setPadding(getComputedStyle(td.current!).paddingLeft);
|
||||
} else {
|
||||
setHeight(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const onInputBlur: FocusEventHandler<HTMLInputElement> = (e) => {
|
||||
setOpen(false);
|
||||
onValueChange(e.target.value);
|
||||
};
|
||||
|
||||
const blurOnTab: KeyboardEventHandler<HTMLInputElement> = (e) => {
|
||||
if (e.key === "Tab") {
|
||||
setOpen(false);
|
||||
onValueChange(e.currentTarget.value);
|
||||
// Send the value when closing the popover
|
||||
if (!open && valueRef.current !== props.value) {
|
||||
// @ts-expect-error - cannot unpack value type
|
||||
props.onValueChange(valueRef.current);
|
||||
}
|
||||
setOpen(open);
|
||||
};
|
||||
|
||||
const [isOpen, setOpen] = useState(false);
|
||||
const fieldProps = {
|
||||
height,
|
||||
onValueChange: setValue,
|
||||
onOpenChange,
|
||||
padding,
|
||||
value,
|
||||
} as any;
|
||||
|
||||
const children = (() => {
|
||||
if (!value) {
|
||||
return "";
|
||||
}
|
||||
if (props.type === "select") {
|
||||
// @ts-expect-error TS cannot understand the link between props.type and value
|
||||
return props.itemRenderer({ item: value });
|
||||
}
|
||||
if (Array.isArray(value) && props.type === "multiple") {
|
||||
return <>{value.map((v) => props.itemRenderer({ item: v }))}</>;
|
||||
}
|
||||
return value as ReactNode;
|
||||
})();
|
||||
|
||||
return (
|
||||
<Popover.Root onOpenChange={onOpenChange} open={isOpen}>
|
||||
<Popover.Trigger asChild>
|
||||
<Cell ref={td} asChild>
|
||||
<button className="flex flex-row space-between gap-2 w-full items-center justify-start">
|
||||
{" "}
|
||||
{children ?? defaultValue}
|
||||
{isLoading && <Spinner size={12} />}
|
||||
{/* Keep the height of the cell when the popover is open, so that the popover doesn't jump when it opens/closes. */}
|
||||
<button
|
||||
className="flex flex-row justify-start hover:bg-level-2 flex-wrap gap-1 items-center relative"
|
||||
style={{ height }}
|
||||
>
|
||||
{children}
|
||||
{props.blink && (
|
||||
<div className="size-2 bg-txt-accent rounded-full top-1/2 right-3 absolute -translate-y-1/2 animate-pulse" />
|
||||
)}
|
||||
</button>
|
||||
</Cell>
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
side="bottom"
|
||||
align="start"
|
||||
sideOffset={height * -1}
|
||||
sideOffset={height ? height * -1 : 0}
|
||||
style={{
|
||||
height,
|
||||
paddingLeft: type === "select" ? 0 : padding,
|
||||
minHeight: height,
|
||||
}}
|
||||
className="border border-border-low bg-level-2 min-w-[200px] min-h-[57px] flex flex-col justify-center rounded-sm"
|
||||
className="border border-border-low bg-level-2 min-w-[200px] flex flex-col justify-center rounded-sm"
|
||||
>
|
||||
{type === "select" && (
|
||||
<>
|
||||
<Select
|
||||
onValueChange={onValueChange}
|
||||
onOpenChange={setOpen}
|
||||
defaultOpen
|
||||
variant="ghost"
|
||||
style={{
|
||||
height: height,
|
||||
}}
|
||||
placeholder={children}
|
||||
dropdownProps={{
|
||||
sideOffset: 0,
|
||||
className:
|
||||
"rounded-t-none border border-border-low",
|
||||
}}
|
||||
>
|
||||
{options}
|
||||
</Select>
|
||||
</>
|
||||
{props.type === "text" && (
|
||||
<Input {...props} {...fieldProps} />
|
||||
)}
|
||||
{type === "text" && (
|
||||
<input
|
||||
type="text"
|
||||
defaultValue={defaultValue?.toString() ?? ""}
|
||||
className="text-txt-primary text-sm outline-none"
|
||||
onKeyDown={blurOnTab}
|
||||
onBlur={onInputBlur}
|
||||
/>
|
||||
{props.type === "select" && (
|
||||
<Select {...props} {...fieldProps} />
|
||||
)}
|
||||
{props.type === "multiple" && (
|
||||
<Multiple {...props} {...fieldProps} />
|
||||
)}
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function Input(props: PropsField<"text", string>) {
|
||||
const blurOnTab: KeyboardEventHandler<HTMLInputElement> = (e) => {
|
||||
if (e.key === "Tab") {
|
||||
props.onOpenChange(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
defaultValue={props.value}
|
||||
onKeyDown={blurOnTab}
|
||||
className="text-sm text-txt-primary outline-none"
|
||||
onChange={(e) => props.onValueChange(e.currentTarget.value)}
|
||||
style={{ paddingLeft: props.padding }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Select<T>(props: PropsField<"select", T>) {
|
||||
const { __ } = useTranslate();
|
||||
const showSearch = false;
|
||||
return (
|
||||
<Command className="text-txt-primary absolute left-0 top-0 right-0 bg-level-2 border border-border-low rounded-b-sm">
|
||||
<div
|
||||
style={{ height: props.height, paddingLeft: props.padding }}
|
||||
className="flex flex-col justify-center"
|
||||
onClick={() => props.onOpenChange(false)}
|
||||
>
|
||||
{props.value ? props.itemRenderer({ item: props.value }) : ""}
|
||||
</div>
|
||||
{showSearch && (
|
||||
<Command.Input
|
||||
className="text-sm text-txt-secondary border-y border-border-low py-2 px-3 w-full focus:outline-txt-accent outline"
|
||||
placeholder={__("Search")}
|
||||
/>
|
||||
)}
|
||||
<Command.List>
|
||||
{Array.isArray(props.items) ? (
|
||||
props.items
|
||||
.filter((item) => item !== props.value)
|
||||
.map((item) => (
|
||||
<Command.Item
|
||||
key={getKey(item)}
|
||||
className="py-2 px-3 hover:bg-level-3"
|
||||
onSelect={() => {
|
||||
props.onValueChange(item);
|
||||
props.onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
{props.itemRenderer({ item })}
|
||||
</Command.Item>
|
||||
))
|
||||
) : (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="py-2 px-3 hover:bg-level-3">
|
||||
<Spinner />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<SelectItems
|
||||
value={props.value}
|
||||
itemRenderer={props.itemRenderer}
|
||||
items={props.items}
|
||||
onSelect={(item) => {
|
||||
props.onValueChange(item);
|
||||
props.onOpenChange(false);
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</Command.List>
|
||||
</Command>
|
||||
);
|
||||
}
|
||||
|
||||
function Multiple<T>(props: PropsField<"multiple", T>) {
|
||||
const { __ } = useTranslate();
|
||||
const showSearch = true;
|
||||
|
||||
const pushValue = (item: T) => {
|
||||
props.onValueChange([...(props.value ?? []), item]);
|
||||
};
|
||||
|
||||
const removeValue = (item: T) => {
|
||||
props.onValueChange(
|
||||
props.value!.filter((v) => getKey(v) !== getKey(item)),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Command className="text-txt-primary absolute left-0 top-0 right-0 bg-level-2 border border-border-low rounded-b-sm">
|
||||
{props.value && props.value.length > 0 && (
|
||||
<div
|
||||
className="flex flex-col gap-2 py-3"
|
||||
style={{
|
||||
paddingLeft: props.padding,
|
||||
}}
|
||||
>
|
||||
{props.value &&
|
||||
props.value.map((item) =>
|
||||
props.itemRenderer({
|
||||
item,
|
||||
onRemove: () => removeValue(item),
|
||||
}),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{showSearch && (
|
||||
<Command.Input
|
||||
className="text-sm text-txt-secondary border-y border-border-low py-2 px-3 w-full focus:outline-txt-accent outline"
|
||||
placeholder={__("Search")}
|
||||
/>
|
||||
)}
|
||||
<Command.List>
|
||||
{Array.isArray(props.items) ? (
|
||||
props.items
|
||||
.filter((item) => item !== props.value)
|
||||
.map((item, k) => (
|
||||
<Command.Item
|
||||
key={k}
|
||||
className="py-2 px-3 hover:bg-level-3"
|
||||
onSelect={() => {
|
||||
pushValue(item);
|
||||
}}
|
||||
>
|
||||
{props.itemRenderer({
|
||||
item,
|
||||
})}
|
||||
</Command.Item>
|
||||
))
|
||||
) : (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="py-2 px-3 hover:bg-level-3">
|
||||
<Spinner />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<SelectItems
|
||||
value={props.value}
|
||||
itemRenderer={props.itemRenderer}
|
||||
items={props.items}
|
||||
onSelect={(item) => {
|
||||
pushValue(item);
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</Command.List>
|
||||
</Command>
|
||||
);
|
||||
}
|
||||
/**
|
||||
* Resolve items with a suspense
|
||||
*/
|
||||
function SelectItems<T>(props: {
|
||||
value?: T | T[];
|
||||
itemRenderer: (v: { item: T }) => ReactNode;
|
||||
items: () => T[];
|
||||
onSelect: (item: T) => void;
|
||||
}) {
|
||||
const items = props.items();
|
||||
const keys = Array.isArray(props.value)
|
||||
? props.value.map(getKey)
|
||||
: [getKey(props.value)];
|
||||
return (
|
||||
<>
|
||||
{items
|
||||
.filter((item) => !keys.includes(getKey(item)))
|
||||
.map((item) => (
|
||||
<Command.Item
|
||||
key={getKey(item)}
|
||||
className="py-2 px-3 hover:bg-level-3"
|
||||
onSelect={() => props.onSelect(item)}
|
||||
>
|
||||
{props.itemRenderer({ item })}
|
||||
</Command.Item>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -38,7 +38,13 @@ export { InfiniteScrollTrigger } from "./Atoms/InfiniteScrollTrigger/InfiniteScr
|
||||
export { PriorityLevel } from "./Atoms/PriorityLevel/PriorityLevel.tsx";
|
||||
export { TaskStateIcon } from "./Atoms/Icons/TaskStateIcon";
|
||||
export { Checkbox } from "./Atoms/Checkbox/Checkbox";
|
||||
export { DataTable, Cell, CellHead, Row } from "./Atoms/DataTable/DataTable";
|
||||
export {
|
||||
DataTable,
|
||||
Cell,
|
||||
CellHead,
|
||||
Row,
|
||||
RowButton,
|
||||
} from "./Atoms/DataTable/DataTable";
|
||||
|
||||
// Molecules
|
||||
export {
|
||||
|
||||
Reference in New Issue
Block a user