1"use client";
2
3
17import { useState } from "react";
18import { ClipboardCopy, ClipboardList, Inbox, Plus } from "lucide-react";
19import { toast } from "@/stores/ui-store";
20import { DatabaseProperty, DatabaseView, RecordDto, useUpdateView } from "@/hooks/use-database";
21import { cn } from "@/lib/utils";
22import {
23 useCreateForm,
24 useForm,
25 useListForms
26} from "@/hooks/use-forms";
27import { FormBuilderView } from "@/components/database/form-builder-view";
28import { FormResponsesView } from "@/components/form/form-responses-view";
29
30type FormTab = "builder" | "responses";
31
32export interface FormViewProps {
33 databaseId: string;
34 workspaceId?: string;
35 view: DatabaseView;
36 properties: DatabaseProperty[];
37 readOnly?: boolean;
38 onOpenRecord?: (record: RecordDto) => void;
39 onOpenRecordPage?: (record: RecordDto) => void;
40}
41
42export function FormView({
43 databaseId,
44 workspaceId,
45 view,
46 properties,
47 readOnly,
48 onOpenRecord,
49 onOpenRecordPage
50}: FormViewProps) {
51 const formId = (view.config.formId as string | undefined) ?? undefined;
52 const { data: form, isLoading: formLoading } = useForm(formId);
53
54 const [tab, setTab] = useState<FormTab>("builder");
55 const [newName, setNewName] = useState("");
56
57 const createForm = useCreateForm(databaseId);
58 const updateView = useUpdateView(databaseId);
59 const { data: existingForms } = useListForms(databaseId);
60
61 const linkForm = (id: string) =>
62 updateView.mutate(
63 { viewId: view.id, config: { ...(view.config ?? {}), formId: id } },
64 {
65 onError: (e) => toast("error", "Could not link the form", (e as Error).message)
66 }
67 );
68
69 const createAndLink = () => {
70 const name = newName.trim();
71 if (!name) return;
72 createForm.mutate(
73 { name },
74 {
75 onSuccess: (created) => {
76 setNewName("");
77 linkForm(created.id);
78 toast("success", "Form created", `Now editing "${created.name}" — add questions and save to publish.`);
79 },
80 onError: (e) => toast("error", "Create failed", (e as Error).message)
81 }
82 );
83 };
84
85 const copyLink = async () => {
86 if (!form) return;
87 const url = `${window.location.origin}/f/${form.slug}`;
88 try {
89 await navigator.clipboard.writeText(url);
90 toast("success", "Public link copied", url);
91 } catch {
92 toast("error", "Could not copy the link", url);
93 }
94 };
95
96
97 if (!formId) {
98 return (
99 <div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
100 <Inbox className="size-8 text-muted-foreground" aria-hidden />
101 <p className="text-sm text-muted-foreground">This form view has no linked form yet.</p>
102 {!readOnly && (
103 <div className="flex flex-col items-center gap-3">
104 <div className="flex items-center gap-2">
105 <input
106 value={newName}
107 onChange={(e) => setNewName(e.target.value)}
108 onKeyDown={(e) => e.key === "Enter" && createAndLink()}
109 placeholder="Form name, e.g. Feedback"
110 aria-label="New form name"
111 className="w-56 rounded-md border border-input bg-background px-2.5 py-1.5 text-sm outline-none focus-visible:ring-1 focus-visible:ring-ring"
112 />
113 <button
114 type="button"
115 data-testid="form-view-create"
116 className="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-sm text-primary-foreground hover:bg-primary/90 disabled:opacity-40"
117 disabled={createForm.isPending || !newName.trim()}
118 onClick={createAndLink}
119 >
120 <Plus className="size-4" aria-hidden /> Create form
121 </button>
122 </div>
123 {existingForms && existingForms.length > 0 && (
124 <div className="flex items-center gap-2 text-xs text-muted-foreground">
125 <span>or link an existing form:</span>
126 <select
127 className="rounded-md border border-input bg-background px-2 py-1 text-xs"
128 value=""
129 aria-label="Choose an existing form"
130 onChange={(e) => e.target.value && linkForm(e.target.value)}
131 >
132 <option value="">Choose…</option>
133 {existingForms.map((f) => (
134 <option key={f.id} value={f.id}>
135 {f.name} ({f.status})
136 </option>
137 ))}
138 </select>
139 </div>
140 )}
141 </div>
142 )}
143 </div>
144 );
145 }
146
147
148 return (
149 <div className="flex h-full min-h-0 flex-col">
150 <div className="flex flex-wrap items-center gap-1 border-b border-border px-2 py-1">
151 <TabButton active={tab === "builder"} onClick={() => setTab("builder")} label="Builder" testid="form-view-tab-builder" />
152 <TabButton active={tab === "responses"} onClick={() => setTab("responses")} label="Responses" testid="form-view-tab-responses" />
153 {form && (
154 <div className="ml-auto flex items-center gap-1.5 py-1">
155 <span className="inline-flex items-center gap-1 rounded-md bg-muted px-2 py-1 font-mono text-[11px] text-muted-foreground">
156 <ClipboardList className="size-3" aria-hidden />
157 /f/{form.slug}
158 </span>
159 <button
160 type="button"
161 data-testid="form-view-copy-link"
162 className="rounded-md p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground"
163 onClick={copyLink}
164 title="Copy public link"
165 aria-label="Copy public link"
166 >
167 <ClipboardCopy className="size-3.5" aria-hidden />
168 </button>
169 </div>
170 )}
171 </div>
172 <div className={cn("min-h-0 flex-1", tab === "builder" && "overflow-y-auto")}>
173 {formLoading || !form ? (
174 <div className="flex h-full items-center justify-center p-6 text-sm text-muted-foreground">Loading form…</div>
175 ) : tab === "builder" ? (
176 <FormBuilderView form={form} properties={properties} databaseId={databaseId} workspaceId={workspaceId} />
177 ) : (
178 <FormResponsesView
179 formId={form.id}
180 slug={form.slug}
181 databaseId={databaseId}
182 onOpenRecord={onOpenRecord}
183 onOpenRecordPage={onOpenRecordPage}
184 />
185 )}
186 </div>
187 </div>
188 );
189}
190
191function TabButton({
192 active,
193 onClick,
194 label,
195 testid
196}: {
197 active: boolean;
198 onClick: () => void;
199 label: string;
200 testid: string;
201}) {
202 return (
203 <button
204 type="button"
205 data-testid={testid}
206 className={cn(
207 "rounded-md px-3 py-1.5 text-sm transition-colors",
208 active ? "bg-muted font-medium text-foreground" : "text-muted-foreground hover:bg-muted/50"
209 )}
210 aria-pressed={active}
211 onClick={onClick}
212 >
213 {label}
214 </button>
215 );
216}
217