/** * Add/edit form for a variable declaration. Builds a typed * CompositionVariable from free-text drafts; structural validation beyond * the field-level checks here is the SDK's job (can() on dispatch). */ import { useState } from "react"; import type { CompositionVariable, CompositionVariableType } from "@hyperframes/sdk"; import { VARIABLES_INPUT_CLASS } from "./VariablesValueControls"; const VARIABLE_TYPES: CompositionVariableType[] = [ "string", "number", "color", "boolean", "enum", "font", "image", ]; export interface DeclarationDraft { id: string; label: string; type: CompositionVariableType; defaultRaw: string; description: string; min: string; max: string; step: string; optionsRaw: string; } export const EMPTY_DRAFT: DeclarationDraft = { id: "", label: "", type: "string", defaultRaw: "", description: "", min: "", max: "", step: "", optionsRaw: "", }; // Per-type field mapping — one ternary per optional field. // fallow-ignore-next-line complexity export function draftFromDeclaration(decl: CompositionVariable): DeclarationDraft { const numeric = decl.type === "number" ? decl : null; return { ...EMPTY_DRAFT, id: decl.id, label: decl.label, type: decl.type, defaultRaw: String(decl.default), description: decl.description ?? "", min: numeric?.min !== undefined ? String(numeric.min) : "", max: numeric?.max !== undefined ? String(numeric.max) : "", step: numeric?.step !== undefined ? String(numeric.step) : "", optionsRaw: decl.type === "enum" ? decl.options.map((o) => `${o.value}:${o.label}`).join("\n") : "", }; } function numberDeclFromDraft( base: { id: string; label: string; description?: string }, draft: DeclarationDraft, ): CompositionVariable | string { const value = Number(draft.defaultRaw); if (!Number.isFinite(value)) return "Default must be a number."; const constraint = (key: "min" | "max" | "step") => { const raw = draft[key].trim(); if (!raw) return {}; const parsed = Number(raw); return Number.isFinite(parsed) ? { [key]: parsed } : {}; }; return { ...base, type: "number", default: value, ...constraint("min"), ...constraint("max"), ...constraint("step"), }; } // fallow-ignore-next-line complexity function enumDeclFromDraft( base: { id: string; label: string; description?: string }, draft: DeclarationDraft, ): CompositionVariable | string { const options = draft.optionsRaw .split("\n") .map((line) => line.trim()) .filter(Boolean) .map((line) => { const [value, ...rest] = line.split(":"); const v = (value ?? "").trim(); return { value: v, label: rest.join(":").trim() || v }; }) .filter((o) => o.value.length > 0); if (options.length === 0) return "Enum needs at least one option (one per line, value:Label)."; const value = draft.defaultRaw.trim() || (options[0]?.value ?? ""); if (!options.some((o) => o.value === value)) return "Default must be one of the options."; return { ...base, type: "enum", default: value, options }; } /** * Fields the form actually models. On an unchanged-type edit, every OTHER key * of the original declaration (font source/default_name/default_source, * brandRole, placeholder, maxLength, unit, …) must ride through untouched — * updateVariableDeclaration replaces wholesale, so dropping them here would * silently strip schema metadata on every Edit + Save. */ const FORM_OWNED_KEYS = new Set([ "id", "label", "type", "default", "description", "min", "max", "step", "options", ]); export function mergeDeclarationEdit( original: CompositionVariable, edited: CompositionVariable, ): CompositionVariable { // Type changed → old type-specific metadata no longer applies. if (original.type !== edited.type) return edited; const passthrough: Record = {}; for (const [key, value] of Object.entries(original)) { if (!FORM_OWNED_KEYS.has(key)) passthrough[key] = value; } // Both sides are same-type declarations and edited owns every form key, so // the merge preserves the declared shape. return { ...passthrough, ...edited }; } /** Build a typed declaration from the form draft; string on validation error. */ // fallow-ignore-next-line complexity export function declarationFromDraft(draft: DeclarationDraft): CompositionVariable | string { const id = draft.id.trim(); if (!id) return "Variable id is required."; const label = draft.label.trim() || id; const description = draft.description.trim() || undefined; const base = { id, label, ...(description ? { description } : {}) }; switch (draft.type) { case "number": return numberDeclFromDraft(base, draft); case "boolean": return { ...base, type: "boolean", default: draft.defaultRaw.trim() === "true" }; case "enum": return enumDeclFromDraft(base, draft); default: // string / color / font / image — string default, verbatim. return { ...base, type: draft.type, default: draft.defaultRaw }; } } function Field({ label, children }: { label: string; children: React.ReactNode }) { return (
{children}
); } function DefaultField({ draft, onChange, }: { draft: DeclarationDraft; onChange: (defaultRaw: string) => void; }) { if (draft.type === "boolean") { return ( ); } return ( onChange(e.target.value)} className={VARIABLES_INPUT_CLASS} /> ); } export function DeclarationForm({ initial, submitLabel, onSubmit, onCancel, }: { initial: DeclarationDraft; submitLabel: string; onSubmit: (decl: CompositionVariable) => void; onCancel: () => void; }) { const [draft, setDraft] = useState(initial); const [error, setError] = useState(null); const editingExisting = initial.id.length > 0; const set = (patch: Partial) => setDraft((d) => ({ ...d, ...patch })); const submit = () => { const result = declarationFromDraft(draft); if (typeof result === "string") { setError(result); return; } setError(null); onSubmit(result); }; return (
set({ id: e.target.value })} placeholder="title" className={`${VARIABLES_INPUT_CLASS} font-mono disabled:opacity-50`} /> set({ label: e.target.value })} placeholder="Title" className={VARIABLES_INPUT_CLASS} />
set({ defaultRaw })} />
{draft.type === "number" && (
{(["min", "max", "step"] as const).map((key) => ( set({ [key]: e.target.value })} className={`${VARIABLES_INPUT_CLASS} tabular-nums`} /> ))}
)} {draft.type === "enum" && (