"use client"; /** * PropertyEditor — dialog for creating / editing / deleting a database * property (docs/api/databases.md §3.4). * * - "add": pick a type + name, set the type-specific config, create. * - "edit": rename, tweak config (options, number format, date time, * relation target, rollup aggregate, formula expression, files limit…), * toggle visibility/required, delete. * * Config is per-type (PropertyConfig). Relation/rollup targets need the * workspace's other databases — pass `workspaceId` (from the DatabaseDto) * to enable that picker; it degrades gracefully without it. */ import { useMemo, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { Loader2, Plus, Save, Trash2, X } from "lucide-react"; import { cn } from "@/lib/utils"; import { apiFetch } from "@/lib/api"; import { toast } from "@/stores/ui-store"; /** Toast adapter: call sites use variant:"destructive"; core store uses kind. */ const notify = (t: { title: string; description?: string; variant?: string }) => toast(t.variant === "destructive" ? "error" : "success", t.title, t.description); import { DatabaseDto, DatabaseProperty, PropertyConfig, useCreateProperty, useDeleteProperty, useUpdateProperty } from "@/hooks/use-database"; import { FormulaEditor } from "@/components/database/formula-editor"; const TYPE_META: { type: string; label: string; icon: string; desc: string }[] = [ { type: "title", label: "Title", icon: "T", desc: "Main label (one per database)" }, { type: "text", label: "Text", icon: "A", desc: "Plain text" }, { type: "rich_text", label: "Rich text", icon: "¶", desc: "Formatted text with links" }, { type: "number", label: "Number", icon: "#", desc: "Numeric values" }, { type: "select", label: "Select", icon: "◉", desc: "Single choice from options" }, { type: "multi_select", label: "Multi-select", icon: "◌", desc: "Multiple choices" }, { type: "status", label: "Status", icon: "●", desc: "Progress status with colors" }, { type: "date", label: "Date", icon: "📅", desc: "Single date" }, { type: "date_range", label: "Date range", icon: "🗓", desc: "Start and end date" }, { type: "person", label: "Person", icon: "👤", desc: "Workspace member" }, { type: "email", label: "Email", icon: "@", desc: "E-mail address" }, { type: "phone", label: "Phone", icon: "✆", desc: "Phone number" }, { type: "url", label: "URL", icon: "🔗", desc: "Web link" }, { type: "checkbox", label: "Checkbox", icon: "☑", desc: "Yes / no" }, { type: "files", label: "Files", icon: "📎", desc: "Attached files" }, { type: "created_time", label: "Created time", icon: "🕐", desc: "Auto, set on creation" }, { type: "created_by", label: "Created by", icon: "👤", desc: "Auto, creator" }, { type: "last_edited_time", label: "Last edited", icon: "🕓", desc: "Auto, last edit" }, { type: "last_edited_by", label: "Edited by", icon: "👤", desc: "Auto, last editor" }, { type: "relation", label: "Relation", icon: "↔", desc: "Link to other records" }, { type: "rollup", label: "Rollup", icon: "Σ", desc: "Aggregate from relation" }, { type: "formula", label: "Formula", icon: "ƒ", desc: "Computed expression" }, { type: "button", label: "Button", icon: "⚡", desc: "Action button" }, { type: "unique_id", label: "Unique ID", icon: "ID", desc: "Auto, unique identifier" } ]; export const OPTION_COLORS = [ "gray", "brown", "orange", "yellow", "green", "blue", "purple", "pink", "red", "default" ] as const; const colorDot: Record = { gray: "#80848f", brown: "#9f6b53", orange: "#d9730d", yellow: "#cb912f", green: "#448361", blue: "#337ea9", purple: "#9065b0", pink: "#c14c8a", red: "#d44c47", default: "#80848f" }; export interface PropertyEditorProps { databaseId: string; properties: DatabaseProperty[]; /** When set (edit mode) — the property being edited. */ property?: DatabaseProperty; mode: "add" | "edit"; /** Enables the relation-target picker (from DatabaseDto.workspaceId). */ workspaceId?: string; onClose: () => void; } interface OptionDraft { id: string; name: string; color: string; } export function PropertyEditor({ databaseId, properties, property, mode, workspaceId, onClose }: PropertyEditorProps) { const createProperty = useCreateProperty(databaseId); const updateProperty = useUpdateProperty(databaseId); const deleteProperty = useDeleteProperty(databaseId); // Must be declared BEFORE the useState initializers below — a `const` read // in its temporal dead zone throws `ReferenceError: Cannot access 'cfg' // before initialization` on the FIRST render, which (with no error // boundary) unmounts the whole app tree when the dialog opens (verified in // the Phase-15 E2E: clicking "Add property" blanked the page). const cfg = property?.config; const [type, setType] = useState(property?.type ?? (mode === "add" ? "text" : "text")); const [name, setName] = useState(property?.name ?? ""); const [options, setOptions] = useState(() => { const existing = (property?.config.options ?? []) as OptionDraft[]; if (existing.length) return existing; if (mode === "add" && (type === "select" || type === "multi_select" || type === "status")) { return [{ id: `opt_${Math.random().toString(36).slice(2, 10)}`, name: "Option 1", color: "default" }]; } return []; }); const [confirmDelete, setConfirmDelete] = useState(false); const [formulaOpen, setFormulaOpen] = useState(false); // Number-format config held locally (applied on save; avoids PATCHing a // property that does not exist yet in add mode). const [numFormat, setNumFormat] = useState(cfg?.format ?? "number"); const [numCurrency, setNumCurrency] = useState(typeof cfg?.currency === "string" ? cfg.currency : "USD"); const [numPrecision, setNumPrecision] = useState(cfg?.precision ?? 2); const [includeTime, setIncludeTime] = useState(cfg?.includeTime ?? false); const [targetDatabaseId, setTargetDatabaseId] = useState(cfg?.targetDatabaseId ?? ""); const [cardinality, setCardinality] = useState(cfg?.cardinality ?? "many_to_many"); const [bidirectional, setBidirectional] = useState(cfg?.bidirectional ?? true); const [rollupProp, setRollupProp] = useState(cfg?.targetPropertyId ?? ""); const [rollupAggregate, setRollupAggregate] = useState(cfg?.aggregate ?? "COUNT"); const [maxFiles, setMaxFiles] = useState(cfg?.maxFiles ?? 5); const [checkboxDefault, setCheckboxDefault] = useState(cfg?.default === true); const isSelectLike = type === "select" || type === "multi_select" || type === "status"; const isNumber = type === "number"; const isDate = type === "date" || type === "date_range"; const isRelation = type === "relation"; const isRollup = type === "rollup"; const isFormula = type === "formula"; const isFiles = type === "files"; // Other databases in the workspace (relation targets). const otherDatabases = useQuery({ queryKey: ["databases-by-workspace", workspaceId], enabled: !!workspaceId && (isRelation || isRollup), queryFn: async () => { const res = await apiFetch(`/databases?workspaceId=${workspaceId}`); const d = res as { data?: DatabaseDto[] } | DatabaseDto[]; return Array.isArray(d) ? d : (d.data ?? []); } }); const relationProps = properties.filter((p) => p.type === "relation" && p.id !== property?.id); const busy = createProperty.isPending || updateProperty.isPending || deleteProperty.isPending; const buildConfig = (): PropertyConfig => { const c: PropertyConfig = { ...(cfg ?? {}) }; // Carry only the keys relevant to the (possibly changed) type. const keep: Record = {}; if (isSelectLike) keep.options = options; if (isNumber) { keep.format = numFormat; if (numFormat === "currency") keep.currency = numCurrency; keep.precision = numPrecision; } if (isDate) keep.includeTime = includeTime; if (isRelation) { keep.targetDatabaseId = targetDatabaseId || undefined; keep.cardinality = cardinality; keep.bidirectional = bidirectional; keep.reverseName = cfg?.reverseName; } if (isRollup) { keep.targetPropertyId = rollupProp || undefined; keep.aggregate = rollupAggregate; } if (isFormula) keep.expression = cfg?.expression ?? ""; if (isFiles) keep.maxFiles = maxFiles; if (type === "text") keep.maxLines = cfg?.maxLines ?? null; if (type === "checkbox") keep.default = checkboxDefault; return keep; }; const save = () => { if (!name.trim()) { notify({ title: "Name is required", variant: "destructive" }); return; } const payload = { name: name.trim(), config: buildConfig() }; if (mode === "edit" && property) { updateProperty.mutate( { propertyId: property.id, ...payload }, { onSuccess: () => onClose(), onError: (e) => notify({ title: "Save failed", description: (e as Error).message, variant: "destructive" }) } ); } else { createProperty.mutate( { ...payload, type, isVisible: true, required: false, default: null }, { onSuccess: () => onClose(), onError: (e) => notify({ title: "Create failed", description: (e as Error).message, variant: "destructive" }) } ); } }; // ---- option helpers --------------------------------------------------------- const addOption = () => { setOptions((o) => [...o, { id: `opt_${Math.random().toString(36).slice(2, 10)}`, name: "", color: "default" }]); }; const setOption = (id: string, patch: Partial) => setOptions((o) => o.map((x) => (x.id === id ? { ...x, ...patch } : x))); const typeDisabled = mode === "edit"; return (
{/* header */}
{mode === "add" ? "Add a property" : "Edit property"} {mode === "edit" && ( )}
{/* type picker */}
{typeDisabled ? (

{TYPE_META.find((t) => t.type === type)?.icon} {TYPE_META.find((t) => t.type === type)?.label}

) : (
{TYPE_META.map((t) => { const taken = t.type === "title" && properties.some((p) => p.type === "title"); return ( ); })}
)}
{/* name */}
setName(e.target.value)} onKeyDown={(e) => e.key === "Enter" && save()} />
{/* select-like options */} {isSelectLike && (
Options
    {options.map((o) => (
  • setOption(o.id, { color: OPTION_COLORS[(OPTION_COLORS.indexOf(o.color as (typeof OPTION_COLORS)[number]) + 1) % OPTION_COLORS.length] })} role="button" aria-label={`Option color: ${o.color}`} /> setOption(o.id, { name: e.target.value })} />
  • ))} {options.length === 0 &&
  • No options yet.
  • }
)} {/* number config */} {isNumber && (
{numFormat === "currency" && (
setNumCurrency(e.target.value)} />
)}
setNumPrecision(Math.max(0, Math.min(8, Number(e.target.value) || 0)))} />
)} {/* date config */} {isDate && ( )} {/* relation config */} {isRelation && (
)} {/* rollup config */} {isRollup && (
)} {/* formula config */} {isFormula && (
{cfg?.expression || "—"}
)} {/* files config */} {isFiles && (
setMaxFiles(Math.max(1, Number(e.target.value) || 1))} />
)} {/* checkbox config */} {type === "checkbox" && ( )} {/* edit-only toggles */} {mode === "edit" && property && (
)}
{/* footer */}
{/* delete confirm */} {confirmDelete && property && (

Delete “{property.name}”?

All values in this property are removed from every record. This cannot be undone.

)} {/* formula editor (nested) */} {formulaOpen && property && ( setFormulaOpen(false)} onSaved={() => { setFormulaOpen(false); onClose(); }} /> )}
); }