199 lines
7.2 KiB
TypeScript
199 lines
7.2 KiB
TypeScript
|
|
import { zodResolver } from "@hookform/resolvers/zod";
|
|||
|
|
import { Button } from "@repo/shadcn-ui/components/button";
|
|||
|
|
import { Checkbox } from "@repo/shadcn-ui/components/checkbox";
|
|||
|
|
import { FormActions, FormDialog } 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 type { CatalogSaved } from "./catalog-form";
|
|||
|
|
import { CatalogError, CatalogFieldForm, useCatalogSave } from "./catalog-form";
|
|||
|
|
import { CatalogHelp } from "./catalog-help";
|
|||
|
|
|
|||
|
|
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 && (
|
|||
|
|
<FormDialog
|
|||
|
|
cancelLabel={t("cancel")}
|
|||
|
|
formActions
|
|||
|
|
onOpenChange={(open) => {
|
|||
|
|
if (!open) {
|
|||
|
|
setCreatingField(false);
|
|||
|
|
}
|
|||
|
|
}}
|
|||
|
|
open
|
|||
|
|
title={t("newField")}
|
|||
|
|
>
|
|||
|
|
<p className="text-sm text-muted-foreground">{t("quickNotice")}</p>
|
|||
|
|
<CatalogFieldForm
|
|||
|
|
csrfToken={csrfToken}
|
|||
|
|
onSaved={(fieldId) => {
|
|||
|
|
add(fieldId);
|
|||
|
|
setCreatingField(false);
|
|||
|
|
}}
|
|||
|
|
scope={scope}
|
|||
|
|
/>
|
|||
|
|
</FormDialog>
|
|||
|
|
)}
|
|||
|
|
</>
|
|||
|
|
);
|
|||
|
|
}
|