1"use client";
2
3
16import { useMemo, useState } from "react";
17import { useQuery } from "@tanstack/react-query";
18import { Loader2, Plus, Save, Trash2, X } from "lucide-react";
19import { cn } from "@/lib/utils";
20import { apiFetch } from "@/lib/api";
21import { toast } from "@/stores/ui-store";
22
23
24const notify = (t: { title: string; description?: string; variant?: string }) =>
25 toast(t.variant === "destructive" ? "error" : "success", t.title, t.description);
26import {
27 DatabaseDto,
28 DatabaseProperty,
29 PropertyConfig,
30 useCreateProperty,
31 useDeleteProperty,
32 useUpdateProperty
33} from "@/hooks/use-database";
34import { FormulaEditor } from "@/components/database/formula-editor";
35
36const TYPE_META: { type: string; label: string; icon: string; desc: string }[] = [
37 { type: "title", label: "Title", icon: "T", desc: "Main label (one per database)" },
38 { type: "text", label: "Text", icon: "A", desc: "Plain text" },
39 { type: "rich_text", label: "Rich text", icon: "¶", desc: "Formatted text with links" },
40 { type: "number", label: "Number", icon: "#", desc: "Numeric values" },
41 { type: "select", label: "Select", icon: "◉", desc: "Single choice from options" },
42 { type: "multi_select", label: "Multi-select", icon: "◌", desc: "Multiple choices" },
43 { type: "status", label: "Status", icon: "●", desc: "Progress status with colors" },
44 { type: "date", label: "Date", icon: "📅", desc: "Single date" },
45 { type: "date_range", label: "Date range", icon: "🗓", desc: "Start and end date" },
46 { type: "person", label: "Person", icon: "👤", desc: "Workspace member" },
47 { type: "email", label: "Email", icon: "@", desc: "E-mail address" },
48 { type: "phone", label: "Phone", icon: "✆", desc: "Phone number" },
49 { type: "url", label: "URL", icon: "🔗", desc: "Web link" },
50 { type: "checkbox", label: "Checkbox", icon: "☑", desc: "Yes / no" },
51 { type: "files", label: "Files", icon: "📎", desc: "Attached files" },
52 { type: "created_time", label: "Created time", icon: "🕐", desc: "Auto, set on creation" },
53 { type: "created_by", label: "Created by", icon: "👤", desc: "Auto, creator" },
54 { type: "last_edited_time", label: "Last edited", icon: "🕓", desc: "Auto, last edit" },
55 { type: "last_edited_by", label: "Edited by", icon: "👤", desc: "Auto, last editor" },
56 { type: "relation", label: "Relation", icon: "↔", desc: "Link to other records" },
57 { type: "rollup", label: "Rollup", icon: "Σ", desc: "Aggregate from relation" },
58 { type: "formula", label: "Formula", icon: "ƒ", desc: "Computed expression" },
59 { type: "button", label: "Button", icon: "⚡", desc: "Action button" },
60 { type: "unique_id", label: "Unique ID", icon: "ID", desc: "Auto, unique identifier" }
61];
62
63export const OPTION_COLORS = [
64 "gray",
65 "brown",
66 "orange",
67 "yellow",
68 "green",
69 "blue",
70 "purple",
71 "pink",
72 "red",
73 "default"
74] as const;
75
76const colorDot: Record<string, string> = {
77 gray: "#80848f",
78 brown: "#9f6b53",
79 orange: "#d9730d",
80 yellow: "#cb912f",
81 green: "#448361",
82 blue: "#337ea9",
83 purple: "#9065b0",
84 pink: "#c14c8a",
85 red: "#d44c47",
86 default: "#80848f"
87};
88
89export interface PropertyEditorProps {
90 databaseId: string;
91 properties: DatabaseProperty[];
92
93 property?: DatabaseProperty;
94 mode: "add" | "edit";
95
96 workspaceId?: string;
97 onClose: () => void;
98}
99
100interface OptionDraft {
101 id: string;
102 name: string;
103 color: string;
104}
105
106export function PropertyEditor({ databaseId, properties, property, mode, workspaceId, onClose }: PropertyEditorProps) {
107 const createProperty = useCreateProperty(databaseId);
108 const updateProperty = useUpdateProperty(databaseId);
109 const deleteProperty = useDeleteProperty(databaseId);
110
111
112
113
114
115
116 const cfg = property?.config;
117
118 const [type, setType] = useState(property?.type ?? (mode === "add" ? "text" : "text"));
119 const [name, setName] = useState(property?.name ?? "");
120 const [options, setOptions] = useState<OptionDraft[]>(() => {
121 const existing = (property?.config.options ?? []) as OptionDraft[];
122 if (existing.length) return existing;
123 if (mode === "add" && (type === "select" || type === "multi_select" || type === "status")) {
124 return [{ id: `opt_${Math.random().toString(36).slice(2, 10)}`, name: "Option 1", color: "default" }];
125 }
126 return [];
127 });
128 const [confirmDelete, setConfirmDelete] = useState(false);
129 const [formulaOpen, setFormulaOpen] = useState(false);
130
131
132 const [numFormat, setNumFormat] = useState<PropertyConfig["format"]>(cfg?.format ?? "number");
133 const [numCurrency, setNumCurrency] = useState<string>(typeof cfg?.currency === "string" ? cfg.currency : "USD");
134 const [numPrecision, setNumPrecision] = useState<number>(cfg?.precision ?? 2);
135 const [includeTime, setIncludeTime] = useState<boolean>(cfg?.includeTime ?? false);
136 const [targetDatabaseId, setTargetDatabaseId] = useState<string>(cfg?.targetDatabaseId ?? "");
137 const [cardinality, setCardinality] = useState<PropertyConfig["cardinality"]>(cfg?.cardinality ?? "many_to_many");
138 const [bidirectional, setBidirectional] = useState<boolean>(cfg?.bidirectional ?? true);
139 const [rollupProp, setRollupProp] = useState<string>(cfg?.targetPropertyId ?? "");
140 const [rollupAggregate, setRollupAggregate] = useState<PropertyConfig["aggregate"]>(cfg?.aggregate ?? "COUNT");
141 const [maxFiles, setMaxFiles] = useState<number>(cfg?.maxFiles ?? 5);
142 const [checkboxDefault, setCheckboxDefault] = useState<boolean>(cfg?.default === true);
143
144 const isSelectLike = type === "select" || type === "multi_select" || type === "status";
145 const isNumber = type === "number";
146 const isDate = type === "date" || type === "date_range";
147 const isRelation = type === "relation";
148 const isRollup = type === "rollup";
149 const isFormula = type === "formula";
150 const isFiles = type === "files";
151
152
153 const otherDatabases = useQuery({
154 queryKey: ["databases-by-workspace", workspaceId],
155 enabled: !!workspaceId && (isRelation || isRollup),
156 queryFn: async () => {
157 const res = await apiFetch(`/databases?workspaceId=${workspaceId}`);
158 const d = res as { data?: DatabaseDto[] } | DatabaseDto[];
159 return Array.isArray(d) ? d : (d.data ?? []);
160 }
161 });
162
163 const relationProps = properties.filter((p) => p.type === "relation" && p.id !== property?.id);
164
165 const busy = createProperty.isPending || updateProperty.isPending || deleteProperty.isPending;
166
167 const buildConfig = (): PropertyConfig => {
168 const c: PropertyConfig = { ...(cfg ?? {}) };
169
170 const keep: Record<string, unknown> = {};
171 if (isSelectLike) keep.options = options;
172 if (isNumber) {
173 keep.format = numFormat;
174 if (numFormat === "currency") keep.currency = numCurrency;
175 keep.precision = numPrecision;
176 }
177 if (isDate) keep.includeTime = includeTime;
178 if (isRelation) {
179 keep.targetDatabaseId = targetDatabaseId || undefined;
180 keep.cardinality = cardinality;
181 keep.bidirectional = bidirectional;
182 keep.reverseName = cfg?.reverseName;
183 }
184 if (isRollup) {
185 keep.targetPropertyId = rollupProp || undefined;
186 keep.aggregate = rollupAggregate;
187 }
188 if (isFormula) keep.expression = cfg?.expression ?? "";
189 if (isFiles) keep.maxFiles = maxFiles;
190 if (type === "text") keep.maxLines = cfg?.maxLines ?? null;
191 if (type === "checkbox") keep.default = checkboxDefault;
192 return keep;
193 };
194
195 const save = () => {
196 if (!name.trim()) {
197 notify({ title: "Name is required", variant: "destructive" });
198 return;
199 }
200 const payload = { name: name.trim(), config: buildConfig() };
201 if (mode === "edit" && property) {
202 updateProperty.mutate(
203 { propertyId: property.id, ...payload },
204 {
205 onSuccess: () => onClose(),
206 onError: (e) => notify({ title: "Save failed", description: (e as Error).message, variant: "destructive" })
207 }
208 );
209 } else {
210 createProperty.mutate(
211 { ...payload, type, isVisible: true, required: false, default: null },
212 {
213 onSuccess: () => onClose(),
214 onError: (e) => notify({ title: "Create failed", description: (e as Error).message, variant: "destructive" })
215 }
216 );
217 }
218 };
219
220
221 const addOption = () => {
222 setOptions((o) => [...o, { id: `opt_${Math.random().toString(36).slice(2, 10)}`, name: "", color: "default" }]);
223 };
224 const setOption = (id: string, patch: Partial<OptionDraft>) =>
225 setOptions((o) => o.map((x) => (x.id === id ? { ...x, ...patch } : x)));
226
227 const typeDisabled = mode === "edit";
228
229 return (
230 <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" role="dialog" aria-modal="true" aria-label="Property editor">
231 <div className="flex max-h-[85vh] w-full max-w-lg flex-col overflow-hidden rounded-lg border bg-background shadow-xl">
232 {}
233 <div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
234 <span className="text-sm font-medium">{mode === "add" ? "Add a property" : "Edit property"}</span>
235 {mode === "edit" && (
236 <button
237 type="button"
238 className="ml-auto inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs text-destructive hover:bg-destructive/10"
239 onClick={() => setConfirmDelete(true)}
240 >
241 <Trash2 className="size-3.5" /> Delete
242 </button>
243 )}
244 <button type="button" className={cn("rounded p-1 text-muted-foreground hover:bg-muted", mode !== "edit" && "ml-auto")} onClick={onClose} aria-label="Close">
245 <X className="size-4" />
246 </button>
247 </div>
248
249 <div className="min-h-0 flex-1 space-y-3 overflow-y-auto p-4">
250 {}
251 <div>
252 <label className="mb-1 block text-xs text-muted-foreground">Type</label>
253 {typeDisabled ? (
254 <p className="rounded-md border border-border bg-muted/40 px-2.5 py-1.5 text-sm">
255 {TYPE_META.find((t) => t.type === type)?.icon} {TYPE_META.find((t) => t.type === type)?.label}
256 </p>
257 ) : (
258 <div className="grid grid-cols-4 gap-1">
259 {TYPE_META.map((t) => {
260 const taken = t.type === "title" && properties.some((p) => p.type === "title");
261 return (
262 <button
263 key={t.type}
264 type="button"
265 disabled={taken}
266 title={taken ? "Database already has a title" : t.desc}
267 className={cn(
268 "flex items-center gap-1.5 rounded-md border px-2 py-1.5 text-left text-xs hover:bg-muted",
269 type === t.type && "border-primary bg-primary/10 text-primary",
270 taken && "cursor-not-allowed opacity-40"
271 )}
272 onClick={() => setType(t.type)}
273 >
274 <span className="w-4 text-center" aria-hidden>
275 {t.icon}
276 </span>
277 <span className="truncate">{t.label}</span>
278 </button>
279 );
280 })}
281 </div>
282 )}
283 </div>
284
285 {}
286 <div>
287 <label className="mb-1 block text-xs text-muted-foreground">Name</label>
288 <input
289 className="w-full 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"
290 placeholder="e.g. Priority"
291 value={name}
292 onChange={(e) => setName(e.target.value)}
293 onKeyDown={(e) => e.key === "Enter" && save()}
294 />
295 </div>
296
297 {}
298 {isSelectLike && (
299 <div>
300 <div className="mb-1 flex items-center justify-between">
301 <span className="text-xs text-muted-foreground">Options</span>
302 <button type="button" className="inline-flex items-center gap-0.5 text-xs text-primary hover:underline" onClick={addOption}>
303 <Plus className="size-3" /> Add option
304 </button>
305 </div>
306 <ul className="space-y-1">
307 {options.map((o) => (
308 <li key={o.id} className="flex items-center gap-1.5">
309 <span
310 className="size-3 shrink-0 cursor-pointer rounded-full border border-border"
311 style={{ backgroundColor: colorDot[o.color] }}
312 title={`Color: ${o.color}`}
313 onClick={() => setOption(o.id, { color: OPTION_COLORS[(OPTION_COLORS.indexOf(o.color as (typeof OPTION_COLORS)[number]) + 1) % OPTION_COLORS.length] })}
314 role="button"
315 aria-label={`Option color: ${o.color}`}
316 />
317 <input
318 className="min-w-0 flex-1 rounded border border-input bg-background px-2 py-1 text-xs outline-none focus-visible:ring-1 focus-visible:ring-ring"
319 value={o.name}
320 placeholder="Option name"
321 onChange={(e) => setOption(o.id, { name: e.target.value })}
322 />
323 <button
324 type="button"
325 className="rounded p-0.5 text-muted-foreground hover:bg-muted"
326 onClick={() => setOptions((list) => list.filter((x) => x.id !== o.id))}
327 aria-label="Remove option"
328 >
329 <X className="size-3.5" />
330 </button>
331 </li>
332 ))}
333 {options.length === 0 && <li className="text-xs text-muted-foreground">No options yet.</li>}
334 </ul>
335 </div>
336 )}
337
338 {}
339 {isNumber && (
340 <div className="flex items-center gap-2">
341 <div>
342 <label className="mb-1 block text-xs text-muted-foreground">Format</label>
343 <select
344 className="rounded-md border border-input bg-background px-2 py-1 text-sm outline-none"
345 value={numFormat}
346 onChange={(e) => setNumFormat(e.target.value as PropertyConfig["format"])}
347 >
348 {["number", "decimal", "currency", "percent", "scientific"].map((f) => (
349 <option key={f} value={f}>
350 {f}
351 </option>
352 ))}
353 </select>
354 </div>
355 {numFormat === "currency" && (
356 <div>
357 <label className="mb-1 block text-xs text-muted-foreground">Currency</label>
358 <input
359 className="w-20 rounded-md border border-input bg-background px-2 py-1 text-sm outline-none"
360 value={numCurrency}
361 onChange={(e) => setNumCurrency(e.target.value)}
362 />
363 </div>
364 )}
365 <div>
366 <label className="mb-1 block text-xs text-muted-foreground">Precision</label>
367 <input
368 type="number"
369 min={0}
370 max={8}
371 className="w-20 rounded-md border border-input bg-background px-2 py-1 text-sm outline-none"
372 value={numPrecision}
373 onChange={(e) => setNumPrecision(Math.max(0, Math.min(8, Number(e.target.value) || 0)))}
374 />
375 </div>
376 </div>
377 )}
378
379 {}
380 {isDate && (
381 <label className="flex items-center gap-2 text-sm">
382 <input
383 type="checkbox"
384 className="size-3.5 accent-[oklch(var(--primary))]"
385 checked={includeTime}
386 onChange={(e) => setIncludeTime(e.target.checked)}
387 />
388 Include time
389 </label>
390 )}
391
392 {}
393 {isRelation && (
394 <div className="space-y-2">
395 <div>
396 <label className="mb-1 block text-xs text-muted-foreground">Link to database</label>
397 <select
398 className="w-full rounded-md border border-input bg-background px-2 py-1.5 text-sm outline-none"
399 value={targetDatabaseId}
400 onChange={(e) => setTargetDatabaseId(e.target.value)}
401 >
402 <option value="">
403 {otherDatabases.isLoading ? "Loading…" : otherDatabases.error ? "Databases unavailable" : "Select a database…"}
404 </option>
405 {(otherDatabases.data ?? [])
406 .filter((d) => d.id !== databaseId)
407 .map((d) => (
408 <option key={d.id} value={d.id}>
409 {d.icon ? `${d.icon} ` : ""}
410 {d.title}
411 </option>
412 ))}
413 </select>
414 </div>
415 <div className="flex items-center gap-4">
416 <div>
417 <label className="mb-1 block text-xs text-muted-foreground">Cardinality</label>
418 <select
419 className="rounded-md border border-input bg-background px-2 py-1 text-sm outline-none"
420 value={cardinality}
421 onChange={(e) => setCardinality(e.target.value as PropertyConfig["cardinality"])}
422 >
423 {["one_to_one", "one_to_many", "many_to_many"].map((c) => (
424 <option key={c} value={c}>
425 {c}
426 </option>
427 ))}
428 </select>
429 </div>
430 <label className="mt-4 flex items-center gap-2 text-sm">
431 <input
432 type="checkbox"
433 className="size-3.5 accent-[oklch(var(--primary))]"
434 checked={bidirectional}
435 onChange={(e) => setBidirectional(e.target.checked)}
436 />
437 Reverse relation
438 </label>
439 </div>
440 </div>
441 )}
442
443 {}
444 {isRollup && (
445 <div className="space-y-2">
446 <div>
447 <label className="mb-1 block text-xs text-muted-foreground">Relation</label>
448 <select
449 className="w-full rounded-md border border-input bg-background px-2 py-1.5 text-sm outline-none"
450 value={rollupProp}
451 onChange={(e) => setRollupProp(e.target.value)}
452 >
453 <option value="">Select a relation…</option>
454 {relationProps.map((r) => (
455 <option key={r.id} value={r.id}>
456 {r.name}
457 </option>
458 ))}
459 </select>
460 </div>
461 <div>
462 <label className="mb-1 block text-xs text-muted-foreground">Aggregate</label>
463 <select
464 className="w-full rounded-md border border-input bg-background px-2 py-1.5 text-sm outline-none"
465 value={rollupAggregate}
466 onChange={(e) => setRollupAggregate(e.target.value as PropertyConfig["aggregate"])}
467 >
468 {["COUNT", "SUM", "AVERAGE", "MIN", "MAX", "PERCENT", "UNIQUE"].map((a) => (
469 <option key={a} value={a}>
470 {a}
471 </option>
472 ))}
473 </select>
474 </div>
475 </div>
476 )}
477
478 {}
479 {isFormula && (
480 <div className="space-y-1.5">
481 <label className="block text-xs text-muted-foreground">Expression</label>
482 <div className="flex items-center gap-2">
483 <code className="min-w-0 flex-1 truncate rounded-md border border-input bg-muted/40 px-2 py-1.5 text-xs">
484 {cfg?.expression || "—"}
485 </code>
486 <button
487 type="button"
488 className="rounded-md border px-2.5 py-1 text-xs hover:bg-muted"
489 onClick={() => setFormulaOpen(true)}
490 >
491 Edit
492 </button>
493 </div>
494 </div>
495 )}
496
497 {}
498 {isFiles && (
499 <div>
500 <label className="mb-1 block text-xs text-muted-foreground">Max files</label>
501 <input
502 type="number"
503 min={1}
504 className="w-24 rounded-md border border-input bg-background px-2 py-1 text-sm outline-none"
505 value={maxFiles}
506 onChange={(e) => setMaxFiles(Math.max(1, Number(e.target.value) || 1))}
507 />
508 </div>
509 )}
510
511 {}
512 {type === "checkbox" && (
513 <label className="flex items-center gap-2 text-sm">
514 <input
515 type="checkbox"
516 className="size-3.5 accent-[oklch(var(--primary))]"
517 checked={checkboxDefault}
518 onChange={(e) => setCheckboxDefault(e.target.checked)}
519 />
520 Checked by default
521 </label>
522 )}
523
524 {}
525 {mode === "edit" && property && (
526 <div className="flex items-center gap-4 border-t border-border pt-3">
527 <label className="flex items-center gap-2 text-sm">
528 <input
529 type="checkbox"
530 className="size-3.5 accent-[oklch(var(--primary))]"
531 checked={property.isVisible}
532 onChange={(e) => updateProperty.mutate({ propertyId: property.id, isVisible: e.target.checked })}
533 />
534 Visible in views
535 </label>
536 <label className="flex items-center gap-2 text-sm">
537 <input
538 type="checkbox"
539 className="size-3.5 accent-[oklch(var(--primary))]"
540 checked={property.required}
541 onChange={(e) => updateProperty.mutate({ propertyId: property.id, required: e.target.checked })}
542 />
543 Required
544 </label>
545 </div>
546 )}
547 </div>
548
549 {}
550 <div className="flex items-center justify-end gap-2 border-t border-border px-4 py-2.5">
551 <button type="button" className="rounded-md border px-3 py-1.5 text-sm hover:bg-muted" onClick={onClose}>
552 Cancel
553 </button>
554 <button
555 type="button"
556 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"
557 onClick={save}
558 disabled={busy || !name.trim()}
559 >
560 {busy && <Loader2 className="size-3.5 animate-spin" />}
561 <Save className="size-3.5" />
562 {mode === "add" ? "Add property" : "Save"}
563 </button>
564 </div>
565 </div>
566
567 {}
568 {confirmDelete && property && (
569 <div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 p-4">
570 <div className="w-full max-w-sm rounded-lg border bg-background p-4 shadow-lg">
571 <h3 className="text-sm font-medium">Delete “{property.name}”?</h3>
572 <p className="mt-1 text-xs text-muted-foreground">
573 All values in this property are removed from every record. This cannot be undone.
574 </p>
575 <div className="mt-3 flex justify-end gap-2">
576 <button type="button" className="rounded border px-3 py-1 text-sm hover:bg-muted" onClick={() => setConfirmDelete(false)}>
577 Cancel
578 </button>
579 <button
580 type="button"
581 className="rounded bg-destructive px-3 py-1 text-sm text-destructive-foreground hover:bg-destructive/90 disabled:opacity-40"
582 disabled={deleteProperty.isPending}
583 onClick={() => {
584 deleteProperty.mutate(property.id, {
585 onSuccess: () => {
586 notify({ title: "Property deleted" });
587 onClose();
588 },
589 onError: (e) => notify({ title: "Delete failed", description: (e as Error).message, variant: "destructive" })
590 });
591 }}
592 >
593 {deleteProperty.isPending ? <Loader2 className="size-3.5 animate-spin" /> : "Delete"}
594 </button>
595 </div>
596 </div>
597 </div>
598 )}
599
600 {}
601 {formulaOpen && property && (
602 <FormulaEditor
603 databaseId={databaseId}
604 property={property}
605 initialExpression={cfg?.expression ?? ""}
606 sampleRecordIds={[]}
607 onClose={() => setFormulaOpen(false)}
608 onSaved={() => {
609 setFormulaOpen(false);
610 onClose();
611 }}
612 />
613 )}
614 </div>
615 );
616}
617