"use client"; /** * T-142 — the template-variable form (RHF + a zod schema built from the * template's typed `variables`). Opens when the /templates directory "Use"s a * template that declares variables; on submit it calls `templateApi.apply` * with `values` (the server validates AGAINST the same shared schema in * `packages/shared/src/template-vars.ts` — required/number/date/select rules * mirror `resolveTemplateValues` exactly, so a client-passed form always * server-passes) and lands on the instantiated page. * * The form values stay STRINGS (the server coerces: `Number(raw)` for * numbers, the ISO regex for dates, options-inclusion for selects), so the * dialog never ships a type the substitution engine doesn't expect. */ import { useMemo, useState } from "react"; import { useRouter } from "next/navigation"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { useQueryClient } from "@tanstack/react-query"; import { z } from "zod"; import { Dialog, DialogFooter } from "./dialog"; import { Input } from "@/components/ui/form"; import { templateApi } from "@/lib/api"; import { toast } from "@/stores/ui-store"; import type { TemplateInfo } from "@/lib/types"; /** The wire shape of `Template.variables` (packages/shared template-vars). */ interface TemplateVar { name: string; label: string; type: "text" | "number" | "date" | "select"; options?: string[]; required?: boolean; defaultValue?: string; } const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; /** Build the zod schema from the variable list (one rule per field, all string-typed). */ function buildSchema(vars: TemplateVar[]): z.ZodType> { const fields: Record = {}; for (const v of vars) { switch (v.type) { case "number": fields[v.name] = v.required ? z.string().min(1, "Required").refine((s) => Number.isFinite(Number(s)), "Must be a number") : z .string() .refine((s) => s === "" || Number.isFinite(Number(s)), "Must be a number"); break; case "date": fields[v.name] = v.required ? z.string().min(1, "Required").refine((s) => DATE_RE.test(s), "Must be a date (YYYY-MM-DD)") : z .string() .refine((s) => s === "" || DATE_RE.test(s), "Must be a date (YYYY-MM-DD)"); break; case "select": { const opts = v.options ?? []; fields[v.name] = v.required ? z.enum(opts as [string, ...string[]], { message: "Pick an option" }) : z.enum(["", ...opts] as [string, ...string[]], { message: "Pick an option" }); break; } case "text": default: fields[v.name] = v.required ? z.string().min(1, "Required").max(2000, "Too long") : z.string().max(2000, "Too long"); break; } } return z.object(fields) as z.ZodType>; } /** Parse `Template.variables` defensively (it's a Json column — null/garbage is possible). */ function parseVars(raw: unknown): TemplateVar[] | null { if (!Array.isArray(raw)) return raw === null || raw === undefined ? [] : null; const out: TemplateVar[] = []; for (const v of raw) { if (typeof v !== "object" || v === null) return null; const o = v as TemplateVar; const okSelect = o.type !== "select" || (Array.isArray(o.options) && o.options.length > 0); if ( typeof o.name === "string" && typeof o.label === "string" && ["text", "number", "date", "select"].includes(o.type) && okSelect ) { out.push(o); } else { return null; // malformed → refuse the form (server would reject it anyway) } } return out; } export function TemplateVariableDialog({ template, workspaceId, workspaceSlug, onClose }: { template: TemplateInfo; workspaceId: string; workspaceSlug: string; onClose: () => void; }) { const router = useRouter(); const qc = useQueryClient(); const vars = useMemo(() => parseVars(template.variables), [template]); const schema = useMemo(() => buildSchema(vars ?? []), [vars]); const defaults = useMemo(() => { const d: Record = {}; for (const v of vars ?? []) d[v.name] = v.defaultValue ?? ""; return d; }, [vars]); const [busy, setBusy] = useState(false); const { register, handleSubmit, formState: { errors } } = useForm>({ resolver: zodResolver(schema), defaultValues: defaults, mode: "onTouched" }); const onSubmit = (values: Record) => { setBusy(true); templateApi .apply(template.id, { workspaceId, title: template.name, values }) .then((page) => { setBusy(false); toast("success", `Template “${template.name}” applied`, "Opening the new page…"); qc.invalidateQueries({ queryKey: ["workspaces", workspaceId, "templates"] }); qc.invalidateQueries({ queryKey: ["workspaces", workspaceId, "page-tree"] }); router.push(`/w/${workspaceSlug}/page/${page.id}`); }) .catch((e: unknown) => { setBusy(false); toast("error", "Could not apply template", e instanceof Error ? e.message : undefined); }); }; return ( !busy && onClose()} footer={ !busy && onClose()} onAction={() => void handleSubmit(onSubmit)()} actionLabel="Create page" busy={busy} /> } > {vars === null ? (

This template's variables are malformed, so it can't be used with a form.

) : vars.length === 0 ? (

This template has no variables.

) : (
{ e.preventDefault(); void handleSubmit(onSubmit)(); }} className="space-y-4" noValidate> {vars.map((v) => { const error = errors[v.name]?.message; return (
{v.type === "select" ? ( ) : ( )} {error ? (

{error}

) : null}
); })} {/* Hidden submit so Enter/keyboard works inside the form. */}
)}
); }