Ref EditableCell signature
Signed-off-by: Jonathan <contact@grafikart.fr>
This commit is contained in:
@@ -46,7 +46,6 @@ function PeopleSelectWithQuery(
|
|||||||
const people = usePeople(organizationId, { excludeContractEnded: true });
|
const people = usePeople(organizationId, { excludeContractEnded: true });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name={name}
|
name={name}
|
||||||
@@ -74,47 +73,5 @@ function PeopleSelectWithQuery(
|
|||||||
</Select>
|
</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>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,13 +121,17 @@ export const deleteAssetMutation = graphql`
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
export const useDeleteAsset = (
|
export const useDeleteAsset = (
|
||||||
asset: { id?: string; name?: string },
|
asset?: { id?: string; name?: string },
|
||||||
connectionId: string
|
connectionId?: string,
|
||||||
) => {
|
) => {
|
||||||
const [mutate] = useMutation(deleteAssetMutation);
|
const [mutate] = useMutation(deleteAssetMutation);
|
||||||
const confirm = useConfirm();
|
const confirm = useConfirm();
|
||||||
const { __ } = useTranslate();
|
const { __ } = useTranslate();
|
||||||
|
|
||||||
|
if (!asset) {
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (!asset.id || !asset.name) {
|
if (!asset.id || !asset.name) {
|
||||||
return alert(__("Failed to delete asset: missing id or name"));
|
return alert(__("Failed to delete asset: missing id or name"));
|
||||||
@@ -145,20 +149,21 @@ export const useDeleteAsset = (
|
|||||||
{
|
{
|
||||||
message: sprintf(
|
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) => {
|
export const useCreateAsset = (connectionId: string) => {
|
||||||
const [mutate] = useMutation(createAssetMutation);
|
const [mutate, isMutating] = useMutation(createAssetMutation);
|
||||||
const { __ } = useTranslate();
|
const { __ } = useTranslate();
|
||||||
|
|
||||||
return (input: {
|
return [
|
||||||
|
(input: {
|
||||||
name: string;
|
name: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
assetType: string;
|
assetType: string;
|
||||||
@@ -177,7 +182,9 @@ export const useCreateAsset = (connectionId: string) => {
|
|||||||
return alert(__("Failed to create asset: organization is required"));
|
return alert(__("Failed to create asset: organization is required"));
|
||||||
}
|
}
|
||||||
if (!input.dataTypesStored) {
|
if (!input.dataTypesStored) {
|
||||||
return alert(__("Failed to create asset: data types stored is required"));
|
return alert(
|
||||||
|
__("Failed to create asset: data types stored is required"),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return promisifyMutation(mutate)({
|
return promisifyMutation(mutate)({
|
||||||
@@ -194,7 +201,9 @@ export const useCreateAsset = (connectionId: string) => {
|
|||||||
connections: [connectionId],
|
connections: [connectionId],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
},
|
||||||
|
isMutating,
|
||||||
|
] as const;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useUpdateAsset = () => {
|
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 {
|
import {
|
||||||
Button,
|
|
||||||
IconPlusLarge,
|
|
||||||
PageHeader,
|
|
||||||
Thead,
|
|
||||||
Tbody,
|
|
||||||
Tr,
|
|
||||||
Th,
|
|
||||||
Td,
|
|
||||||
Badge,
|
|
||||||
ActionDropdown,
|
ActionDropdown,
|
||||||
DropdownItem,
|
|
||||||
IconTrashCan,
|
|
||||||
Avatar,
|
Avatar,
|
||||||
EditableCell,
|
Badge,
|
||||||
Select,
|
Button,
|
||||||
Option,
|
|
||||||
DataTable,
|
|
||||||
CellHead,
|
|
||||||
Cell,
|
Cell,
|
||||||
|
CellHead,
|
||||||
|
DataTable,
|
||||||
|
DropdownItem,
|
||||||
|
EditableCell,
|
||||||
|
IconCheckmark1,
|
||||||
|
IconCrossLargeX,
|
||||||
|
IconPlusLarge,
|
||||||
|
IconTrashCan,
|
||||||
|
PageHeader,
|
||||||
Row,
|
Row,
|
||||||
|
RowButton,
|
||||||
|
Spinner,
|
||||||
} from "@probo/ui";
|
} from "@probo/ui";
|
||||||
import { useTranslate } from "@probo/i18n";
|
import { useTranslate } from "@probo/i18n";
|
||||||
import { usePageTitle } from "@probo/hooks";
|
import { usePageTitle, useToggle } from "@probo/hooks";
|
||||||
import {
|
import {
|
||||||
graphql,
|
graphql,
|
||||||
|
type PreloadedQuery,
|
||||||
usePaginationFragment,
|
usePaginationFragment,
|
||||||
usePreloadedQuery,
|
usePreloadedQuery,
|
||||||
type PreloadedQuery,
|
|
||||||
useMutation,
|
|
||||||
} from "react-relay";
|
} from "react-relay";
|
||||||
import { useOrganizationId } from "/hooks/useOrganizationId";
|
import { useOrganizationId } from "/hooks/useOrganizationId";
|
||||||
import { useParams } from "react-router";
|
import { useParams } from "react-router";
|
||||||
import { CreateAssetDialog } from "./dialogs/CreateAssetDialog";
|
import { CreateAssetDialog } from "./dialogs/CreateAssetDialog";
|
||||||
import {
|
import {
|
||||||
useDeleteAsset,
|
|
||||||
assetsQuery,
|
assetsQuery,
|
||||||
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 } from "@probo/helpers";
|
import { faviconUrl, getAssetTypeVariant } from "@probo/helpers";
|
||||||
import type { NodeOf } from "/types";
|
import type { NodeOf } from "/types";
|
||||||
import { getAssetTypeVariant } from "@probo/helpers";
|
|
||||||
import type {
|
import type {
|
||||||
AssetsPageFragment$data,
|
AssetsPageFragment$data,
|
||||||
AssetsPageFragment$key,
|
AssetsPageFragment$key,
|
||||||
} from "./__generated__/AssetsPageFragment.graphql";
|
} from "./__generated__/AssetsPageFragment.graphql";
|
||||||
import { SortableTable } from "/components/SortableTable";
|
|
||||||
import { SnapshotBanner } from "/components/SnapshotBanner";
|
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 { Authorized } from "/permissions";
|
||||||
import { isAuthorized } from "/permissions";
|
import { isAuthorized } from "/permissions";
|
||||||
import { PeopleSelectOptions } from "/components/form/PeopleSelectField.tsx";
|
|
||||||
|
|
||||||
const paginatedAssetsFragment = graphql`
|
const paginatedAssetsFragment = graphql`
|
||||||
fragment AssetsPageFragment on Organization
|
fragment AssetsPageFragment on Organization
|
||||||
@@ -125,6 +129,8 @@ export default function AssetsPage(props: Props) {
|
|||||||
isAuthorized(organizationId, "Asset", "updateAsset") ||
|
isAuthorized(organizationId, "Asset", "updateAsset") ||
|
||||||
isAuthorized(organizationId, "Asset", "deleteAsset")
|
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">
|
||||||
@@ -146,130 +152,190 @@ export default function AssetsPage(props: Props) {
|
|||||||
</Authorized>
|
</Authorized>
|
||||||
)}
|
)}
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
<DataTable columns={["1fr", "1fr", "1fr", "1fr", "1fr", "56px"]}>
|
<DataTable
|
||||||
|
columns={[...Array.from({ length: 6 }).map(() => "1fr"), "56px"]}
|
||||||
|
>
|
||||||
<Row>
|
<Row>
|
||||||
<CellHead>{__("Name")}</CellHead>
|
<CellHead>{__("Name")}</CellHead>
|
||||||
<CellHead>{__("Type")}</CellHead>
|
<CellHead>{__("Type")}</CellHead>
|
||||||
|
<CellHead>{__("Data Types stored")}</CellHead>
|
||||||
<CellHead>{__("Amount")}</CellHead>
|
<CellHead>{__("Amount")}</CellHead>
|
||||||
<CellHead>{__("Owner")}</CellHead>
|
<CellHead>{__("Owner")}</CellHead>
|
||||||
<CellHead>{__("Vendors")}</CellHead>
|
<CellHead>{__("Vendors")}</CellHead>
|
||||||
<CellHead></CellHead>
|
<CellHead></CellHead>
|
||||||
</Row>
|
</Row>
|
||||||
{assets.map((entry) => (
|
{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>
|
</DataTable>
|
||||||
</div>
|
</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({
|
function AssetRow({
|
||||||
entry,
|
entry,
|
||||||
connectionId,
|
connectionId,
|
||||||
|
onUpdate,
|
||||||
|
onSubmit,
|
||||||
|
errors,
|
||||||
|
loading,
|
||||||
}: {
|
}: {
|
||||||
entry: AssetEntry;
|
entry?: AssetEntry;
|
||||||
connectionId: string;
|
connectionId?: string;
|
||||||
|
onUpdate: MutationFieldUpdate<UpdateAssetInput>;
|
||||||
|
onSubmit?: () => void;
|
||||||
|
errors?: Record<string, string>;
|
||||||
|
loading?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const organizationId = useOrganizationId();
|
const organizationId = useOrganizationId();
|
||||||
const { __ } = useTranslate();
|
const { __ } = useTranslate();
|
||||||
const { snapshotId } = useParams<{ snapshotId?: string }>();
|
const { snapshotId } = useParams<{ snapshotId?: string }>();
|
||||||
const isSnapshotMode = Boolean(snapshotId);
|
const isSnapshotMode = Boolean(snapshotId);
|
||||||
const deleteAsset = useDeleteAsset(entry, connectionId);
|
const deleteAsset = useDeleteAsset(entry, connectionId);
|
||||||
const vendors = entry.vendors?.edges.map((edge) => edge.node) ?? [];
|
const isOk = Object.keys(errors ?? {}).length === 0;
|
||||||
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Row>
|
<Row>
|
||||||
<EditableCell
|
<EditableCell
|
||||||
type="text"
|
type="text"
|
||||||
defaultValue={entry.name}
|
value={entry?.name ?? ""}
|
||||||
onValueChange={updater("name")}
|
onValueChange={(v) => onUpdate("name", v)}
|
||||||
|
blink={Boolean(errors?.name)}
|
||||||
/>
|
/>
|
||||||
<EditableCell
|
<EditableCell
|
||||||
type="select"
|
type="select"
|
||||||
isLoading={isLoading}
|
items={["VIRTUAL", "PHYSICAL"]}
|
||||||
onValueChange={updater("assetType")}
|
value={entry?.assetType ?? "VIRTUAL"}
|
||||||
options={
|
itemRenderer={({ item }) => (
|
||||||
<>
|
<Badge variant={getAssetTypeVariant(item ?? "VIRTUAL")}>
|
||||||
<Option value="VIRTUAL">
|
{item === "PHYSICAL" ? __("Physical") : __("Virtual")}
|
||||||
<Badge variant={getAssetTypeVariant("VIRTUAL")}>
|
|
||||||
{__("Virtual")}
|
|
||||||
</Badge>
|
</Badge>
|
||||||
</Option>
|
)}
|
||||||
<Option value="PHYSICAL">
|
onValueChange={(v) => onUpdate("assetType", v)}
|
||||||
<Badge variant={getAssetTypeVariant("PHYSICAL")}>
|
blink={Boolean(errors?.assetType)}
|
||||||
{__("Physical")}
|
/>
|
||||||
</Badge>
|
|
||||||
</Option>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Badge variant={getAssetTypeVariant(entry.assetType)}>
|
|
||||||
{entry.assetType === "PHYSICAL" ? __("Physical") : __("Virtual")}
|
|
||||||
</Badge>
|
|
||||||
</EditableCell>
|
|
||||||
<EditableCell
|
<EditableCell
|
||||||
type="text"
|
type="text"
|
||||||
defaultValue={entry.amount}
|
value={entry?.dataTypesStored ?? ""}
|
||||||
onValueChange={updater("amount")}
|
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
|
<EditableCell
|
||||||
type="select"
|
type="select"
|
||||||
isLoading={isLoading}
|
items={() => usePeople(organizationId, { excludeContractEnded: true })}
|
||||||
onValueChange={updater("owner")}
|
value={entry?.owner}
|
||||||
options={<PeopleSelectOptions organizationId={organizationId} />}
|
itemRenderer={({ item }) => (
|
||||||
>
|
<div className="flex gap-2">
|
||||||
{entry.owner?.fullName ?? __("Unassigned")}
|
<Avatar name={item.fullName} />
|
||||||
</EditableCell>
|
{item.fullName}
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</div>
|
</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">
|
<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>
|
<ActionDropdown>
|
||||||
<DropdownItem
|
<DropdownItem
|
||||||
onClick={deleteAsset}
|
onClick={deleteAsset}
|
||||||
@@ -284,3 +350,34 @@ function AssetRow({
|
|||||||
</Row>
|
</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 ref = useDialogRef();
|
||||||
const createAsset = useCreateAsset(connection);
|
const [createAsset] = useCreateAsset(connection);
|
||||||
|
|
||||||
const onSubmit = handleSubmit(async (data) => {
|
const onSubmit = handleSubmit(async (data) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -12,11 +12,9 @@ export const assetRoutes = [
|
|||||||
queryLoader: (params: Record<string, string>) =>
|
queryLoader: (params: Record<string, string>) =>
|
||||||
loadQuery(relayEnvironment, assetsQuery, {
|
loadQuery(relayEnvironment, assetsQuery, {
|
||||||
organizationId: params.organizationId,
|
organizationId: params.organizationId,
|
||||||
snapshotId: null
|
snapshotId: null,
|
||||||
}),
|
}),
|
||||||
Component: lazy(
|
Component: lazy(() => import("/pages/organizations/assets/AssetsPage")),
|
||||||
() => import("/pages/organizations/assets/AssetsPage")
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "snapshots/:snapshotId/assets",
|
path: "snapshots/:snapshotId/assets",
|
||||||
@@ -24,20 +22,9 @@ export const assetRoutes = [
|
|||||||
queryLoader: (params: Record<string, string>) =>
|
queryLoader: (params: Record<string, string>) =>
|
||||||
loadQuery(relayEnvironment, assetsQuery, {
|
loadQuery(relayEnvironment, assetsQuery, {
|
||||||
organizationId: params.organizationId,
|
organizationId: params.organizationId,
|
||||||
snapshotId: params.snapshotId
|
snapshotId: params.snapshotId,
|
||||||
}),
|
}),
|
||||||
Component: lazy(
|
Component: lazy(() => import("/pages/organizations/assets/AssetsPage")),
|
||||||
() => 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")
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "snapshots/:snapshotId/assets/:assetId",
|
path: "snapshots/:snapshotId/assets/:assetId",
|
||||||
@@ -45,7 +32,7 @@ export const assetRoutes = [
|
|||||||
queryLoader: (params: Record<string, string>) =>
|
queryLoader: (params: Record<string, string>) =>
|
||||||
loadQuery(relayEnvironment, assetNodeQuery, { assetId: params.assetId }),
|
loadQuery(relayEnvironment, assetNodeQuery, { assetId: params.assetId }),
|
||||||
Component: lazy(
|
Component: lazy(
|
||||||
() => import("/pages/organizations/assets/AssetDetailsPage")
|
() => import("/pages/organizations/assets/AssetDetailsPage"),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
] satisfies AppRoute[];
|
] satisfies AppRoute[];
|
||||||
|
|||||||
17
package-lock.json
generated
17
package-lock.json
generated
@@ -5756,6 +5756,22 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/color-convert": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
@@ -12884,6 +12900,7 @@
|
|||||||
"@radix-ui/react-tabs": "^1.1.12",
|
"@radix-ui/react-tabs": "^1.1.12",
|
||||||
"@tailwindcss/vite": "^4.1.7",
|
"@tailwindcss/vite": "^4.1.7",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
"react-dropzone": "^14.3.8",
|
"react-dropzone": "^14.3.8",
|
||||||
"react-intersection-observer": "^9.16.0",
|
"react-intersection-observer": "^9.16.0",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
"react-dropzone": "^14.3.8",
|
"react-dropzone": "^14.3.8",
|
||||||
"react-intersection-observer": "^9.16.0",
|
"react-intersection-observer": "^9.16.0",
|
||||||
"react-markdown": "^10.1.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 { Card } from "../Card/Card";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { type AsChildProps, Slot } from "../Slot.tsx";
|
import { type AsChildProps, Slot } from "../Slot.tsx";
|
||||||
|
import { IconPlusLarge } from "../Icons";
|
||||||
|
|
||||||
export function DataTable({
|
export function DataTable({
|
||||||
children,
|
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 {
|
import {
|
||||||
createContext,
|
createContext,
|
||||||
useContext,
|
|
||||||
type FC,
|
type FC,
|
||||||
type HTMLAttributes,
|
type HTMLAttributes,
|
||||||
type PropsWithChildren,
|
type PropsWithChildren,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
type ThHTMLAttributes,
|
type ThHTMLAttributes,
|
||||||
type ComponentPropsWithRef,
|
useContext,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { Card } from "../Card/Card";
|
import { Card } from "../Card/Card";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
|
|||||||
@@ -1,112 +1,338 @@
|
|||||||
|
import * as Popover from "@radix-ui/react-popover";
|
||||||
import {
|
import {
|
||||||
type FocusEventHandler,
|
|
||||||
type KeyboardEventHandler,
|
type KeyboardEventHandler,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
|
Suspense,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} 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 { 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 Props<T> =
|
||||||
type: "text" | "select";
|
| {
|
||||||
|
type: "text";
|
||||||
|
value?: T;
|
||||||
|
blink?: boolean;
|
||||||
onValueChange: (value: string) => void;
|
onValueChange: (value: string) => void;
|
||||||
defaultValue?: ReactNode;
|
itemRenderer?: undefined;
|
||||||
children?: ReactNode;
|
}
|
||||||
options?: ReactNode;
|
| {
|
||||||
isLoading?: boolean;
|
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;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function EditableCell({
|
type PropsField<Type, T> = Props<T> & {
|
||||||
options,
|
type: Type;
|
||||||
children,
|
onOpenChange: (open: boolean) => void;
|
||||||
isLoading,
|
padding: string;
|
||||||
onValueChange,
|
height: number;
|
||||||
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) => {
|
const onOpenChange = (open: boolean) => {
|
||||||
if (open) {
|
if (open) {
|
||||||
setOpen(open);
|
setHeight(td.current?.offsetHeight ?? undefined);
|
||||||
setHeight(td.current?.offsetHeight ?? 0);
|
|
||||||
setPadding(getComputedStyle(td.current!).paddingLeft);
|
setPadding(getComputedStyle(td.current!).paddingLeft);
|
||||||
|
} else {
|
||||||
|
setHeight(undefined);
|
||||||
}
|
}
|
||||||
};
|
// Send the value when closing the popover
|
||||||
|
if (!open && valueRef.current !== props.value) {
|
||||||
const onInputBlur: FocusEventHandler<HTMLInputElement> = (e) => {
|
// @ts-expect-error - cannot unpack value type
|
||||||
setOpen(false);
|
props.onValueChange(valueRef.current);
|
||||||
onValueChange(e.target.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const blurOnTab: KeyboardEventHandler<HTMLInputElement> = (e) => {
|
|
||||||
if (e.key === "Tab") {
|
|
||||||
setOpen(false);
|
|
||||||
onValueChange(e.currentTarget.value);
|
|
||||||
}
|
}
|
||||||
|
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 (
|
return (
|
||||||
<Popover.Root onOpenChange={onOpenChange} open={isOpen}>
|
<Popover.Root onOpenChange={onOpenChange} open={isOpen}>
|
||||||
<Popover.Trigger asChild>
|
<Popover.Trigger asChild>
|
||||||
<Cell ref={td} asChild>
|
<Cell ref={td} asChild>
|
||||||
<button className="flex flex-row space-between gap-2 w-full items-center justify-start">
|
{/* Keep the height of the cell when the popover is open, so that the popover doesn't jump when it opens/closes. */}
|
||||||
{" "}
|
<button
|
||||||
{children ?? defaultValue}
|
className="flex flex-row justify-start hover:bg-level-2 flex-wrap gap-1 items-center relative"
|
||||||
{isLoading && <Spinner size={12} />}
|
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>
|
</button>
|
||||||
</Cell>
|
</Cell>
|
||||||
</Popover.Trigger>
|
</Popover.Trigger>
|
||||||
|
|
||||||
<Popover.Portal>
|
<Popover.Portal>
|
||||||
<Popover.Content
|
<Popover.Content
|
||||||
side="bottom"
|
side="bottom"
|
||||||
align="start"
|
align="start"
|
||||||
sideOffset={height * -1}
|
sideOffset={height ? height * -1 : 0}
|
||||||
style={{
|
style={{
|
||||||
height,
|
minHeight: height,
|
||||||
paddingLeft: type === "select" ? 0 : padding,
|
|
||||||
}}
|
}}
|
||||||
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" && (
|
{props.type === "text" && (
|
||||||
<>
|
<Input {...props} {...fieldProps} />
|
||||||
<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>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
{type === "text" && (
|
{props.type === "select" && (
|
||||||
<input
|
<Select {...props} {...fieldProps} />
|
||||||
type="text"
|
)}
|
||||||
defaultValue={defaultValue?.toString() ?? ""}
|
{props.type === "multiple" && (
|
||||||
className="text-txt-primary text-sm outline-none"
|
<Multiple {...props} {...fieldProps} />
|
||||||
onKeyDown={blurOnTab}
|
|
||||||
onBlur={onInputBlur}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</Popover.Content>
|
</Popover.Content>
|
||||||
</Popover.Portal>
|
</Popover.Portal>
|
||||||
</Popover.Root>
|
</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 { PriorityLevel } from "./Atoms/PriorityLevel/PriorityLevel.tsx";
|
||||||
export { TaskStateIcon } from "./Atoms/Icons/TaskStateIcon";
|
export { TaskStateIcon } from "./Atoms/Icons/TaskStateIcon";
|
||||||
export { Checkbox } from "./Atoms/Checkbox/Checkbox";
|
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
|
// Molecules
|
||||||
export {
|
export {
|
||||||
|
|||||||
Reference in New Issue
Block a user