metis-desk/modules/assets/src/web/catalog-template-form.tsx

193 lines
7.1 KiB
TypeScript
Raw Normal View History

import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@repo/shadcn-ui/components/button";
import { Checkbox } from "@repo/shadcn-ui/components/checkbox";
2026-10-08 14:43:34 +00:00
import { FormActions } from "@repo/shadcn-ui/components/form-dialog";
import { Input } from "@repo/shadcn-ui/components/input";
import { Label } from "@repo/shadcn-ui/components/label";
import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
import { Textarea } from "@repo/shadcn-ui/components/textarea";
import { useId, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import type { z } from "zod/v4";
import type { CatalogItemDTO } from "../common";
import { AssetDefinitionSchema } from "../common";
2026-10-08 12:14:01 +00:00
import { CatalogError } from "./catalog-error";
2026-10-08 14:43:34 +00:00
import { CatalogFieldCreateDialog } from "./catalog-field-create-dialog";
import { CatalogHelp } from "./catalog-help";
2026-10-08 12:14:01 +00:00
import type { CatalogSaved } from "./use-catalog-save";
import { useCatalogSave } from "./use-catalog-save";
const TemplateFormSchema = AssetDefinitionSchema.options[1];
type TemplateFormValues = z.infer<typeof TemplateFormSchema>;
export function CatalogTemplateForm({
scope,
csrfToken,
item,
copy,
items,
onSaved,
allowQuickCreate = true,
}: {
scope: string;
csrfToken: string;
item?: CatalogItemDTO | undefined;
copy?: CatalogItemDTO | undefined;
items: readonly CatalogItemDTO[];
onSaved: CatalogSaved;
allowQuickCreate?: boolean;
}) {
const { t } = useTranslation("assets");
const id = useId();
const [creatingField, setCreatingField] = useState(false);
const source = item ?? copy;
const current = source?.values.kind === "template" ? source.values : undefined;
const form = useForm<TemplateFormValues>({
resolver: zodResolver(TemplateFormSchema),
defaultValues: {
kind: "template",
name: copy ? t("copyName", { name: copy.values.name }) : (current?.name ?? ""),
description: current?.description ?? "",
fields: current?.fields.map((field) => ({ ...field })) ?? [],
},
});
const mutation = useCatalogSave(scope, csrfToken, onSaved, item?.id);
const selected = form.watch("fields");
const fields = items.filter((entry) => entry.values.kind === "field");
const add = (fieldId: string) => {
if (fieldId && !selected.some((field) => field.fieldId === fieldId)) {
form.setValue("fields", [...selected, { fieldId, required: false }], {
shouldDirty: true,
shouldValidate: true,
});
}
};
return (
<>
<form className="space-y-4" onSubmit={form.handleSubmit((values) => mutation.mutate(values))}>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor={`${id}-name`}>{t("name")}</Label>
<Input id={`${id}-name`} maxLength={100} required {...form.register("name")} />
</div>
<div className="space-y-2">
<Label htmlFor={`${id}-description`}>{t("description")}</Label>
<Textarea
id={`${id}-description`}
maxLength={1000}
rows={2}
{...form.register("description")}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor={`${id}-field`}>{t("selectField")}</Label>
<SelectWithAction
action={
allowQuickCreate
? { label: t("addField"), onClick: () => setCreatingField(true) }
: undefined
}
disabled={mutation.isPending}
id={`${id}-field`}
label={t("selectField")}
onValueChange={add}
options={fields
.filter(
(entry) =>
entry.effectiveActive && !selected.some((field) => field.fieldId === entry.id)
)
.map((entry) => ({ value: entry.id, label: entry.values.name }))}
placeholder={t("choose")}
value=""
/>
</div>
{!selected.length && <p className="text-sm text-muted-foreground">{t("noFields")}</p>}
<div className="divide-y rounded-md border">
{selected.map((binding) => {
const field = fields.find((entry) => entry.id === binding.fieldId);
return (
<div className="flex items-center justify-between gap-3 p-3" key={binding.fieldId}>
<div>
<p className="text-sm font-medium">{field?.values.name ?? binding.fieldId}</p>
{field && !field.effectiveActive && (
<p className="text-xs text-muted-foreground">{t("disabledField")}</p>
)}
</div>
<div className="flex items-center gap-2">
<Checkbox
checked={binding.required}
disabled={mutation.isPending}
id={`${id}-required-${binding.fieldId}`}
onCheckedChange={(checked) =>
form.setValue(
"fields",
selected.map((entry) =>
entry.fieldId === binding.fieldId
? { ...entry, required: checked === true }
: entry
),
{ shouldDirty: true }
)
}
/>
<Label htmlFor={`${id}-required-${binding.fieldId}`}>
{t("required")}
<span className="sr-only">
{": "}
{field?.values.name ?? ""}
</span>
</Label>
<Button
aria-label={t("remove", { name: field?.values.name ?? "" })}
disabled={mutation.isPending}
onClick={() =>
form.setValue(
"fields",
selected.filter((entry) => entry.fieldId !== binding.fieldId),
{ shouldDirty: true }
)
}
size="sm"
type="button"
variant="ghost"
>
×
</Button>
</div>
</div>
);
})}
</div>
<CatalogError
error={mutation.error}
invalid={Object.keys(form.formState.errors).length > 0}
/>
<CatalogHelp kind="template" scope={scope} />
<FormActions>
<Button disabled={mutation.isPending} type="submit">
{t(mutation.isPending ? "saving" : "save")}
</Button>
</FormActions>
</form>
{creatingField && (
2026-10-08 14:43:34 +00:00
<CatalogFieldCreateDialog
csrfToken={csrfToken}
onOpenChange={(open) => {
if (!open) {
setCreatingField(false);
}
}}
2026-10-08 14:43:34 +00:00
onSaved={(fieldId) => {
add(fieldId);
setCreatingField(false);
}}
scope={scope}
/>
)}
</>
);
}