mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-05 17:30:50 +00:00
322 lines
9.6 KiB
TypeScript
322 lines
9.6 KiB
TypeScript
/**
|
|
* 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<string, unknown> = {};
|
|
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 (
|
|
<div className="space-y-1">
|
|
<label className="text-[9px] font-medium text-neutral-500">{label}</label>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DefaultField({
|
|
draft,
|
|
onChange,
|
|
}: {
|
|
draft: DeclarationDraft;
|
|
onChange: (defaultRaw: string) => void;
|
|
}) {
|
|
if (draft.type === "boolean") {
|
|
return (
|
|
<select
|
|
value={draft.defaultRaw === "true" ? "true" : "false"}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
className={VARIABLES_INPUT_CLASS}
|
|
>
|
|
<option value="false">false</option>
|
|
<option value="true">true</option>
|
|
</select>
|
|
);
|
|
}
|
|
return (
|
|
<input
|
|
type="text"
|
|
value={draft.defaultRaw}
|
|
onChange={(e) => 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<DeclarationDraft>(initial);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const editingExisting = initial.id.length > 0;
|
|
const set = (patch: Partial<DeclarationDraft>) => setDraft((d) => ({ ...d, ...patch }));
|
|
|
|
const submit = () => {
|
|
const result = declarationFromDraft(draft);
|
|
if (typeof result === "string") {
|
|
setError(result);
|
|
return;
|
|
}
|
|
setError(null);
|
|
onSubmit(result);
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-2 rounded-lg border border-neutral-800 bg-neutral-900/60 p-2">
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<Field label="ID">
|
|
<input
|
|
type="text"
|
|
value={draft.id}
|
|
disabled={editingExisting}
|
|
onChange={(e) => set({ id: e.target.value })}
|
|
placeholder="title"
|
|
className={`${VARIABLES_INPUT_CLASS} font-mono disabled:opacity-50`}
|
|
/>
|
|
</Field>
|
|
<Field label="Label">
|
|
<input
|
|
type="text"
|
|
value={draft.label}
|
|
onChange={(e) => set({ label: e.target.value })}
|
|
placeholder="Title"
|
|
className={VARIABLES_INPUT_CLASS}
|
|
/>
|
|
</Field>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<Field label="Type">
|
|
<select
|
|
value={draft.type}
|
|
onChange={(e) => {
|
|
const type = VARIABLE_TYPES.find((t) => t === e.target.value);
|
|
if (type) set({ type });
|
|
}}
|
|
className={VARIABLES_INPUT_CLASS}
|
|
>
|
|
{VARIABLE_TYPES.map((t) => (
|
|
<option key={t} value={t}>
|
|
{t}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</Field>
|
|
<Field label="Default">
|
|
<DefaultField draft={draft} onChange={(defaultRaw) => set({ defaultRaw })} />
|
|
</Field>
|
|
</div>
|
|
{draft.type === "number" && (
|
|
<div className="grid grid-cols-3 gap-2">
|
|
{(["min", "max", "step"] as const).map((key) => (
|
|
<Field key={key} label={key}>
|
|
<input
|
|
type="text"
|
|
value={draft[key]}
|
|
onChange={(e) => set({ [key]: e.target.value })}
|
|
className={`${VARIABLES_INPUT_CLASS} tabular-nums`}
|
|
/>
|
|
</Field>
|
|
))}
|
|
</div>
|
|
)}
|
|
{draft.type === "enum" && (
|
|
<Field label="Options (one per line, value:Label)">
|
|
<textarea
|
|
value={draft.optionsRaw}
|
|
onChange={(e) => set({ optionsRaw: e.target.value })}
|
|
rows={3}
|
|
className={`${VARIABLES_INPUT_CLASS} resize-y font-mono`}
|
|
/>
|
|
</Field>
|
|
)}
|
|
<Field label="Description (optional)">
|
|
<input
|
|
type="text"
|
|
value={draft.description}
|
|
onChange={(e) => set({ description: e.target.value })}
|
|
className={VARIABLES_INPUT_CLASS}
|
|
/>
|
|
</Field>
|
|
{error && <p className="text-[10px] text-red-400">{error}</p>}
|
|
<div className="flex items-center justify-end gap-2 pt-1">
|
|
<button
|
|
type="button"
|
|
onClick={onCancel}
|
|
className="h-6 rounded px-2 text-[10px] text-neutral-500 hover:text-neutral-300"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={submit}
|
|
className="h-6 rounded bg-neutral-800 px-2 text-[10px] font-medium text-neutral-200 hover:bg-neutral-700"
|
|
>
|
|
{submitLabel}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|