metis-desk/modules/assets/src/web/catalog-template-form.tsx
2026-10-08 16:43:34 +02:00

193 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@repo/shadcn-ui/components/button";
import { Checkbox } from "@repo/shadcn-ui/components/checkbox";
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";
import { CatalogError } from "./catalog-error";
import { CatalogFieldCreateDialog } from "./catalog-field-create-dialog";
import { CatalogHelp } from "./catalog-help";
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 && (
<CatalogFieldCreateDialog
csrfToken={csrfToken}
onOpenChange={(open) => {
if (!open) {
setCreatingField(false);
}
}}
onSaved={(fieldId) => {
add(fieldId);
setCreatingField(false);
}}
scope={scope}
/>
)}
</>
);
}