1"use client";
2
3
16import { useMemo, useState } from "react";
17import { useRouter } from "next/navigation";
18import { useForm } from "react-hook-form";
19import { zodResolver } from "@hookform/resolvers/zod";
20import { useQueryClient } from "@tanstack/react-query";
21import { z } from "zod";
22import { Dialog, DialogFooter } from "./dialog";
23import { Input } from "@/components/ui/form";
24import { templateApi } from "@/lib/api";
25import { toast } from "@/stores/ui-store";
26import type { TemplateInfo } from "@/lib/types";
27
28
29interface TemplateVar {
30 name: string;
31 label: string;
32 type: "text" | "number" | "date" | "select";
33 options?: string[];
34 required?: boolean;
35 defaultValue?: string;
36}
37
38const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
39
40
41function buildSchema(vars: TemplateVar[]): z.ZodType<Record<string, string>> {
42 const fields: Record<string, z.ZodTypeAny> = {};
43 for (const v of vars) {
44 switch (v.type) {
45 case "number":
46 fields[v.name] = v.required
47 ? z.string().min(1, "Required").refine((s) => Number.isFinite(Number(s)), "Must be a number")
48 : z
49 .string()
50 .refine((s) => s === "" || Number.isFinite(Number(s)), "Must be a number");
51 break;
52 case "date":
53 fields[v.name] = v.required
54 ? z.string().min(1, "Required").refine((s) => DATE_RE.test(s), "Must be a date (YYYY-MM-DD)")
55 : z
56 .string()
57 .refine((s) => s === "" || DATE_RE.test(s), "Must be a date (YYYY-MM-DD)");
58 break;
59 case "select": {
60 const opts = v.options ?? [];
61 fields[v.name] = v.required
62 ? z.enum(opts as [string, ...string[]], { message: "Pick an option" })
63 : z.enum(["", ...opts] as [string, ...string[]], { message: "Pick an option" });
64 break;
65 }
66 case "text":
67 default:
68 fields[v.name] = v.required
69 ? z.string().min(1, "Required").max(2000, "Too long")
70 : z.string().max(2000, "Too long");
71 break;
72 }
73 }
74 return z.object(fields) as z.ZodType<Record<string, string>>;
75}
76
77
78function parseVars(raw: unknown): TemplateVar[] | null {
79 if (!Array.isArray(raw)) return raw === null || raw === undefined ? [] : null;
80 const out: TemplateVar[] = [];
81 for (const v of raw) {
82 if (typeof v !== "object" || v === null) return null;
83 const o = v as TemplateVar;
84 const okSelect = o.type !== "select" || (Array.isArray(o.options) && o.options.length > 0);
85 if (
86 typeof o.name === "string" &&
87 typeof o.label === "string" &&
88 ["text", "number", "date", "select"].includes(o.type) &&
89 okSelect
90 ) {
91 out.push(o);
92 } else {
93 return null;
94 }
95 }
96 return out;
97}
98
99export function TemplateVariableDialog({
100 template,
101 workspaceId,
102 workspaceSlug,
103 onClose
104}: {
105 template: TemplateInfo;
106 workspaceId: string;
107 workspaceSlug: string;
108 onClose: () => void;
109}) {
110 const router = useRouter();
111 const qc = useQueryClient();
112 const vars = useMemo(() => parseVars(template.variables), [template]);
113 const schema = useMemo(() => buildSchema(vars ?? []), [vars]);
114 const defaults = useMemo(() => {
115 const d: Record<string, string> = {};
116 for (const v of vars ?? []) d[v.name] = v.defaultValue ?? "";
117 return d;
118 }, [vars]);
119
120 const [busy, setBusy] = useState(false);
121 const {
122 register,
123 handleSubmit,
124 formState: { errors }
125 } = useForm<Record<string, string>>({ resolver: zodResolver(schema), defaultValues: defaults, mode: "onTouched" });
126
127 const onSubmit = (values: Record<string, string>) => {
128 setBusy(true);
129 templateApi
130 .apply(template.id, { workspaceId, title: template.name, values })
131 .then((page) => {
132 setBusy(false);
133 toast("success", `Template “${template.name}” applied`, "Opening the new page…");
134 qc.invalidateQueries({ queryKey: ["workspaces", workspaceId, "templates"] });
135 qc.invalidateQueries({ queryKey: ["workspaces", workspaceId, "page-tree"] });
136 router.push(`/w/${workspaceSlug}/page/${page.id}`);
137 })
138 .catch((e: unknown) => {
139 setBusy(false);
140 toast("error", "Could not apply template", e instanceof Error ? e.message : undefined);
141 });
142 };
143
144 return (
145 <Dialog
146 open
147 title={`Use “${template.name}”`}
148 description="Fill in the template's variables — they replace the {{tokens}} in the page."
149 onClose={() => !busy && onClose()}
150 footer={
151 <DialogFooter
152 onCancel={() => !busy && onClose()}
153 onAction={() => void handleSubmit(onSubmit)()}
154 actionLabel="Create page"
155 busy={busy}
156 />
157 }
158 >
159 {vars === null ? (
160 <p className="text-sm text-muted-foreground">
161 This template's variables are malformed, so it can't be used with a form.
162 </p>
163 ) : vars.length === 0 ? (
164 <p className="text-sm text-muted-foreground">This template has no variables.</p>
165 ) : (
166 <form onSubmit={(e) => { e.preventDefault(); void handleSubmit(onSubmit)(); }} className="space-y-4" noValidate>
167 {vars.map((v) => {
168 const error = errors[v.name]?.message;
169 return (
170 <div key={v.name} className="space-y-1">
171 <label htmlFor={`tpl-var-${v.name}`} className="block text-sm font-medium">
172 {v.label}
173 {v.required ? <span className="ml-0.5 text-red-600" aria-hidden>*</span> : <span className="ml-1 text-xs text-muted-foreground">optional</span>}
174 </label>
175 {v.type === "select" ? (
176 <select
177 id={`tpl-var-${v.name}`}
178 className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
179 {...register(v.name)}
180 >
181 {!v.required && <option value="">—</option>}
182 {(v.options ?? []).map((o) => (
183 <option key={o} value={o}>{o}</option>
184 ))}
185 </select>
186 ) : (
187 <Input
188 id={`tpl-var-${v.name}`}
189 type={v.type === "date" ? "date" : v.type === "number" ? "text" : "text"}
190 inputMode={v.type === "number" ? "decimal" : undefined}
191 placeholder={v.type === "date" ? "YYYY-MM-DD" : v.type === "number" ? "e.g. 3" : ""}
192 {...register(v.name)}
193 />
194 )}
195 {error ? (
196 <p className="text-xs text-red-600" data-testid={`tpl-var-error-${v.name}`}>{error}</p>
197 ) : null}
198 </div>
199 );
200 })}
201 {}
202 <button type="submit" className="hidden" aria-hidden tabIndex={-1}>Create</button>
203 </form>
204 )}
205 </Dialog>
206 );
207}
208