Track HiveGrid Cloud planning reference

This commit is contained in:
david committed 2026-06-26 20:48:01 +02:00
1 parent b3d5ea6764
commit e341c5938a
5784 files changed
+1104929 -3

No files matched your search

@@ -0,0 +1,86 @@
/**
* Center-canvas of the form-builder. Wraps a `DndContext` +
* `SortableContext` so child `<SortableField/>` rows can be reordered
* via pointer-drag or arrow-keys (via the bundled `KeyboardSensor`).
*
* The DndContext is intentionally kept inside the builder shell — the
* parent `FormBuilder` component owns the field-array state and feeds
* it down; this component only emits move events as new arrays so the
* parent can decide whether to debounce / persist.
*/
import type * as React from "react";
import { useTranslation } from "react-i18next";
import {
DndContext,
PointerSensor,
KeyboardSensor,
useSensor,
useSensors,
closestCenter,
type DragEndEvent,
} from "@dnd-kit/core";
import { SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy } from "@dnd-kit/sortable";
import type { FormField } from "@toolctrl/form-widget";
import { SortableField } from "./SortableField";
export type CanvasProps = {
fields: FormField[];
selectedKey: string | null;
onSelect: (key: string) => void;
onReorder: (next: FormField[]) => void;
onUpdate: (key: string, next: FormField) => void;
onDelete: (key: string) => void;
};
export function Canvas({
fields,
selectedKey,
onSelect,
onReorder,
onUpdate,
onDelete,
}: CanvasProps): React.JSX.Element {
const { t } = useTranslation();
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
function handleDragEnd(event: DragEndEvent): void {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = fields.findIndex((f) => f.key === active.id);
const newIndex = fields.findIndex((f) => f.key === over.id);
if (oldIndex < 0 || newIndex < 0) return;
const next = fields.slice();
const [removed] = next.splice(oldIndex, 1);
next.splice(newIndex, 0, removed);
onReorder(next);
}
return (
<section className="flex-1 min-w-0 border rounded bg-gray-50 p-3" aria-label={t("settings.forms.builder.canvas.ariaLabel")}>
<h3 className="text-sm font-semibold uppercase tracking-wide text-gray-600 mb-2">{t("settings.forms.builder.canvas.title")}</h3>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={fields.map((f) => f.key)} strategy={verticalListSortingStrategy}>
<div className="space-y-2">
{fields.length === 0 ? (
<p className="text-sm text-gray-500 italic">{t("settings.forms.builder.canvas.empty")}</p>
) : (
fields.map((field) => (
<SortableField
key={field.key}
field={field}
isSelected={field.key === selectedKey}
onSelect={() => onSelect(field.key)}
onUpdate={(next) => onUpdate(field.key, next)}
onDelete={() => onDelete(field.key)}
/>
))
)}
</div>
</SortableContext>
</DndContext>
</section>
);
}
@@ -0,0 +1,79 @@
/**
* Left-rail field-type palette for the form-widget drag-drop builder
* (Phase 05 #58 follow-up). Each tile supports two interaction modes:
*
* 1. Click → calls `onAdd(type)`, which the parent <FormBuilder/>
* appends to the canvas using the palette's default field shape.
* 2. Drag → exposes a `@dnd-kit` draggable so the user can drop the
* tile directly onto a position on the sortable canvas.
*
* Keeping click+drag both functional is an explicit a11y requirement —
* the keyboard sensor on the canvas handles reorder, and the click
* path is the no-pointer "add field" affordance.
*/
import type * as React from "react";
import { useDraggable } from "@dnd-kit/core";
import { useTranslation } from "react-i18next";
import { paletteItems } from "./palette";
import type { BuilderFieldType } from "./types";
export type FieldPaletteProps = {
onAdd: (type: BuilderFieldType) => void;
};
function DraggablePaletteTile({
type,
labelKey,
helpKey,
onAdd,
}: {
type: BuilderFieldType;
labelKey: string;
helpKey: string;
onAdd: (type: BuilderFieldType) => void;
}): React.JSX.Element {
const { t } = useTranslation();
const label = t(labelKey);
const help = t(helpKey);
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id: `palette:${type}`,
data: { source: "palette", type },
});
return (
<button
ref={setNodeRef}
type="button"
className={`text-left border rounded p-2 hover:bg-gray-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 ${isDragging ? "opacity-50" : ""}`}
aria-label={t("settings.forms.builder.palette.addField", { label })}
onClick={() => onAdd(type)}
{...listeners}
{...attributes}
>
<div className="font-medium text-sm">{label}</div>
<div className="text-xs text-gray-500">{help}</div>
</button>
);
}
export function FieldPalette({ onAdd }: FieldPaletteProps): React.JSX.Element {
const { t } = useTranslation();
return (
<aside className="w-full shrink-0 border rounded p-3 space-y-2 bg-white lg:w-56" aria-label={t("settings.forms.builder.palette.ariaLabel")}>
<h3 className="text-sm font-semibold uppercase tracking-wide text-gray-600">{t("settings.forms.builder.palette.title")}</h3>
<div className="grid gap-2">
{paletteItems.map((item) => (
<DraggablePaletteTile
key={item.type}
type={item.type}
labelKey={item.labelKey}
helpKey={item.helpKey}
onAdd={onAdd}
/>
))}
</div>
<p className="text-[11px] text-gray-500 leading-snug pt-2 border-t">
{t("settings.forms.builder.palette.instructions")}
</p>
</aside>
);
}
@@ -0,0 +1,207 @@
/**
* Right-rail field-properties editor for the form-builder. Renders the
* full editable surface for the currently-selected field — label, key,
* required, placeholder, help-text, length validation and per-option
* editing for select/radio types.
*
* The component is a controlled view: every input change calls back
* through `onChange` with a fresh field object so the parent
* `<FormBuilder/>` can persist + dispatch the canvas update.
*/
import type * as React from "react";
import { useTranslation } from "react-i18next";
import type { FormField } from "@toolctrl/form-widget";
export type FieldPropertiesProps = {
field: FormField | null;
onChange: (next: FormField) => void;
};
function NumericInput({
fieldId,
value,
onChange,
}: {
fieldId: string;
value: number | undefined;
onChange: (next: number | undefined) => void;
}): React.JSX.Element {
return (
<input
id={fieldId}
type="number"
min={0}
className="border rounded px-2 py-1 w-full"
value={value ?? ""}
onChange={(e) => {
const raw = e.target.value.trim();
if (raw === "") return onChange(undefined);
const n = Number.parseInt(raw, 10);
onChange(Number.isFinite(n) ? n : undefined);
}}
/>
);
}
function OptionsEditor({
options,
onChange,
}: {
options: { value: string; label: string }[];
onChange: (next: { value: string; label: string }[]) => void;
}): React.JSX.Element {
const { t } = useTranslation();
return (
<div className="space-y-2">
<div className="grid grid-cols-[1fr_1fr_auto] gap-2 text-xs uppercase tracking-wide text-gray-500">
<span>{t("settings.forms.builder.properties.options.value")}</span>
<span>{t("settings.forms.builder.properties.options.label")}</span>
<span aria-hidden="true">{" "}</span>
</div>
{options.map((opt, idx) => (
<div key={idx} className="grid grid-cols-[1fr_1fr_auto] gap-2">
<input
className="border rounded px-2 py-1 text-sm"
value={opt.value}
onChange={(e) => {
const next = options.slice();
next[idx] = { ...opt, value: e.target.value };
onChange(next);
}}
aria-label={t("settings.forms.builder.properties.options.valueAria", { number: idx + 1 })}
/>
<input
className="border rounded px-2 py-1 text-sm"
value={opt.label}
onChange={(e) => {
const next = options.slice();
next[idx] = { ...opt, label: e.target.value };
onChange(next);
}}
aria-label={t("settings.forms.builder.properties.options.labelAria", { number: idx + 1 })}
/>
<button
type="button"
className="text-red-600 px-2 text-xs"
onClick={() => onChange(options.filter((_, i) => i !== idx))}
aria-label={t("settings.forms.builder.properties.options.removeAria", { number: idx + 1 })}
>
{"✕"}
</button>
</div>
))}
<button
type="button"
className="text-sm border rounded px-2 py-1"
onClick={() =>
onChange([
...options,
{
value: `opt_${options.length + 1}`,
label: t("settings.forms.builder.properties.options.defaultLabel", { number: options.length + 1 }),
},
])
}
>
{t("settings.forms.builder.properties.options.add")}
</button>
</div>
);
}
export function FieldProperties({ field, onChange }: FieldPropertiesProps): React.JSX.Element {
const { t } = useTranslation();
if (!field) {
return (
<aside className="w-full shrink-0 border rounded p-3 bg-white lg:w-72" aria-label={t("settings.forms.builder.properties.ariaLabel")}>
<h3 className="text-sm font-semibold uppercase tracking-wide text-gray-600">{t("settings.forms.builder.properties.title")}</h3>
<p className="text-sm text-gray-500 mt-2">{t("settings.forms.builder.properties.empty")}</p>
</aside>
);
}
const supportsOptions = field.type === "select" || field.type === "radio";
const supportsPlaceholder = field.type === "text" || field.type === "email" || field.type === "number" || field.type === "textarea" || field.type === "select";
const supportsLength = field.type === "text" || field.type === "email" || field.type === "textarea";
return (
<aside className="w-full shrink-0 border rounded p-3 bg-white space-y-3 lg:w-72" aria-label={t("settings.forms.builder.properties.ariaLabel")}>
<header>
<h3 className="text-sm font-semibold uppercase tracking-wide text-gray-600">{t("settings.forms.builder.properties.title")}</h3>
<p className="text-[11px] text-gray-500">{field.type}</p>
</header>
<label className="block text-sm">
<span className="block text-xs uppercase tracking-wide text-gray-500">{t("settings.forms.builder.properties.label")}</span>
<input
className="border rounded px-2 py-1 w-full"
value={field.label}
onChange={(e) => onChange({ ...field, label: e.target.value })}
/>
</label>
<label className="block text-sm">
<span className="block text-xs uppercase tracking-wide text-gray-500">{t("settings.forms.builder.properties.fieldKey")}</span>
<input
className="border rounded px-2 py-1 w-full font-mono text-xs"
value={field.key}
onChange={(e) => onChange({ ...field, key: e.target.value })}
/>
<span className="text-[10px] text-gray-500">
{t("settings.forms.builder.properties.fieldKeyHelp")}
</span>
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={field.required}
onChange={(e) => onChange({ ...field, required: e.target.checked })}
/>
<span>{t("settings.forms.builder.properties.required")}</span>
</label>
{supportsPlaceholder ? (
<label className="block text-sm">
<span className="block text-xs uppercase tracking-wide text-gray-500">{t("settings.forms.builder.properties.placeholder")}</span>
<input
className="border rounded px-2 py-1 w-full"
value={field.placeholder ?? ""}
onChange={(e) => onChange({ ...field, placeholder: e.target.value || undefined })}
/>
</label>
) : null}
<label className="block text-sm">
<span className="block text-xs uppercase tracking-wide text-gray-500">{t("settings.forms.builder.properties.helpText")}</span>
<input
className="border rounded px-2 py-1 w-full"
value={field.helpText ?? ""}
onChange={(e) => onChange({ ...field, helpText: e.target.value || undefined })}
/>
</label>
{supportsLength ? (
<div className="grid grid-cols-2 gap-2">
<label className="block text-sm">
<span className="block text-xs uppercase tracking-wide text-gray-500">{t("settings.forms.builder.properties.minLength")}</span>
<NumericInput
fieldId={`${field.key}-min`}
value={field.minLength}
onChange={(next) => onChange({ ...field, minLength: next })}
/>
</label>
<label className="block text-sm">
<span className="block text-xs uppercase tracking-wide text-gray-500">{t("settings.forms.builder.properties.maxLength")}</span>
<NumericInput
fieldId={`${field.key}-max`}
value={field.maxLength}
onChange={(next) => onChange({ ...field, maxLength: next })}
/>
</label>
</div>
) : null}
{supportsOptions ? (
<div>
<span className="block text-xs uppercase tracking-wide text-gray-500 mb-1">{t("settings.forms.builder.properties.options.title")}</span>
<OptionsEditor
options={field.options ?? []}
onChange={(next) => onChange({ ...field, options: next })}
/>
</div>
) : null}
</aside>
);
}
@@ -0,0 +1,100 @@
import "@testing-library/jest-dom";
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { FormDefinition, FormField, FormTheme } from "@toolctrl/form-widget";
import { FormBuilder } from "./FormBuilder";
import type { FormSettingsValue } from "./SettingsTab";
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) =>
({
"settings.forms.builder.tabs.ariaLabel": "Form-builder tabs",
"settings.forms.builder.tabs.builder": "Builder",
"settings.forms.builder.tabs.settings": "Settings",
"settings.forms.builder.tabs.preview": "Preview",
})[key] ?? key,
}),
}));
vi.mock("./FieldPalette", () => ({
FieldPalette: () => <div data-testid="field-palette" />,
}));
vi.mock("./Canvas", () => ({
Canvas: ({ fields }: { fields: FormField[] }) => <div data-testid="form-canvas">{fields.length}</div>,
}));
vi.mock("./FieldProperties", () => ({
FieldProperties: ({ field }: { field: FormField | null }) => <div data-testid="field-properties">{field?.key ?? "none"}</div>,
}));
vi.mock("./SettingsTab", () => ({
SettingsTab: () => <div data-testid="settings-tab" />,
}));
vi.mock("./PreviewTab", () => ({
PreviewTab: () => <div data-testid="preview-tab" />,
}));
const definition: FormDefinition = {
title: "Contact",
submitLabel: "Submit",
successMessage: "Thanks",
fields: [{ key: "email", type: "email", label: "Email", required: true }],
};
const theme: FormTheme = {};
const settings: FormSettingsValue = {
allowedOrigins: [],
notifyEmails: [],
webhookUrl: "",
targetType: "generic",
fieldMapping: {},
};
function renderBuilder() {
return render(
<FormBuilder
initialDefinition={definition}
initialTheme={theme}
initialSettings={settings}
onChange={vi.fn()}
/>,
);
}
describe("FormBuilder", () => {
it("keeps the tab strip scrollable with touch-sized non-wrapping tabs", () => {
renderBuilder();
const tablist = screen.getByRole("tablist", { name: "Form-builder tabs" });
expect(tablist).toHaveClass("min-w-0", "overflow-x-auto");
for (const tab of screen.getAllByRole("tab")) {
expect(tab).toHaveClass("min-h-10", "shrink-0", "whitespace-nowrap");
expect(tab).toHaveAttribute("aria-controls");
}
});
it("links each selected tab to its visible panel", () => {
renderBuilder();
const builderTab = screen.getByRole("tab", { name: "Builder" });
const builderPanel = screen.getByRole("tabpanel");
expect(builderTab).toHaveAttribute("aria-selected", "true");
expect(builderPanel).toHaveAttribute("id", builderTab.getAttribute("aria-controls"));
expect(builderPanel).toHaveAttribute("aria-labelledby", builderTab.id);
const settingsTab = screen.getByRole("tab", { name: "Settings" });
fireEvent.click(settingsTab);
const settingsPanel = screen.getByRole("tabpanel");
expect(settingsTab).toHaveAttribute("aria-selected", "true");
expect(settingsPanel).toHaveAttribute("id", settingsTab.getAttribute("aria-controls"));
expect(settingsPanel).toHaveAttribute("aria-labelledby", settingsTab.id);
expect(screen.getByTestId("settings-tab")).toBeInTheDocument();
});
});
@@ -0,0 +1,204 @@
/**
* Top-level FormBuilder shell — composes the palette / canvas /
* properties / settings / preview into a tabbed surface and owns the
* editable definition state.
*
* Mounts inside the `forms.$id.tsx` admin route which is responsible
* for the loader (Form + tenant), the action (save → Prisma), and
* surrounding chrome (page title, embed snippet, submissions inbox).
*/
import type * as React from "react";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import type { FormDefinition, FormField, FormTheme } from "@toolctrl/form-widget";
import { FieldPalette } from "./FieldPalette";
import { Canvas } from "./Canvas";
import { FieldProperties } from "./FieldProperties";
import { SettingsTab, type FormSettingsValue } from "./SettingsTab";
import { PreviewTab } from "./PreviewTab";
import { paletteItems } from "./palette";
import { uniqueKey, type BuilderFieldType } from "./types";
export type FormBuilderProps = {
initialDefinition: FormDefinition;
initialTheme: FormTheme;
initialSettings: FormSettingsValue;
onChange: (snapshot: {
definition: FormDefinition;
theme: FormTheme;
settings: FormSettingsValue;
}) => void;
};
type TabKey = "builder" | "settings" | "preview";
const TAB_DEFS: { key: TabKey; labelKey: string }[] = [
{ key: "builder", labelKey: "settings.forms.builder.tabs.builder" },
{ key: "settings", labelKey: "settings.forms.builder.tabs.settings" },
{ key: "preview", labelKey: "settings.forms.builder.tabs.preview" },
];
function getTabId(key: TabKey): string {
return `form-builder-tab-${key}`;
}
function getPanelId(key: TabKey): string {
return `form-builder-panel-${key}`;
}
function localizeDefaultField(field: FormField, t: ReturnType<typeof useTranslation>["t"]): FormField {
const localized = {
...field,
label: t(`settings.forms.builder.defaults.${field.type}.label`, { defaultValue: field.label }),
};
if ("options" in localized && localized.options) {
return {
...localized,
options: localized.options.map((option) => ({
...option,
label: t(`settings.forms.builder.defaults.options.${option.value}`, { defaultValue: option.label }),
})),
};
}
return localized;
}
export function FormBuilder({
initialDefinition,
initialTheme,
initialSettings,
onChange,
}: FormBuilderProps): React.JSX.Element {
const { t } = useTranslation();
const [tab, setTab] = useState<TabKey>("builder");
const [definition, setDefinition] = useState<FormDefinition>(initialDefinition);
const [theme] = useState<FormTheme>(initialTheme);
const [settings, setSettings] = useState<FormSettingsValue>(initialSettings);
const [selectedKey, setSelectedKey] = useState<string | null>(
initialDefinition.fields[0]?.key ?? null,
);
const fieldKeys = useMemo(() => new Set(definition.fields.map((f) => f.key)), [definition.fields]);
function emit(next: { definition?: FormDefinition; theme?: FormTheme; settings?: FormSettingsValue }): void {
const snapshot = {
definition: next.definition ?? definition,
theme: next.theme ?? theme,
settings: next.settings ?? settings,
};
onChange(snapshot);
}
function setDef(next: FormDefinition): void {
setDefinition(next);
emit({ definition: next });
}
function setSettingsLocal(next: FormSettingsValue): void {
setSettings(next);
emit({ settings: next });
}
function addField(type: BuilderFieldType): void {
const palette = paletteItems.find((p) => p.type === type);
if (!palette) return;
const base = localizeDefaultField(palette.defaults(), t);
const key = uniqueKey(base.key, fieldKeys);
const next: FormDefinition = {
...definition,
fields: [...definition.fields, { ...base, key }],
};
setDef(next);
setSelectedKey(key);
}
function updateField(key: string, replacement: FormField): void {
const renamed = replacement.key !== key;
if (renamed && fieldKeys.has(replacement.key)) {
// Reject silent key-collision; keep the prior key.
replacement = { ...replacement, key };
}
const next: FormDefinition = {
...definition,
fields: definition.fields.map((f) => (f.key === key ? replacement : f)),
};
setDef(next);
if (renamed) setSelectedKey(replacement.key);
}
function reorderFields(next: FormField[]): void {
setDef({ ...definition, fields: next });
}
function deleteField(key: string): void {
const next: FormDefinition = {
...definition,
fields: definition.fields.filter((f) => f.key !== key),
};
setDef(next);
if (selectedKey === key) {
setSelectedKey(next.fields[0]?.key ?? null);
}
}
const selectedField = definition.fields.find((f) => f.key === selectedKey) ?? null;
return (
<div className="space-y-3">
<div
role="tablist"
aria-label={t("settings.forms.builder.tabs.ariaLabel")}
className="flex min-w-0 gap-2 overflow-x-auto border-b"
>
{TAB_DEFS.map((def) => (
<button
id={getTabId(def.key)}
key={def.key}
type="button"
role="tab"
aria-selected={tab === def.key}
aria-controls={getPanelId(def.key)}
className={`-mb-px min-h-10 shrink-0 whitespace-nowrap border-b-2 px-3 py-2 text-sm transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 ${
tab === def.key ? "border-blue-600 text-blue-700" : "border-transparent text-gray-600 hover:text-gray-900"
}`}
onClick={() => setTab(def.key)}
>
{t(def.labelKey)}
</button>
))}
</div>
{tab === "builder" ? (
<div
id={getPanelId("builder")}
role="tabpanel"
aria-labelledby={getTabId("builder")}
className="flex flex-col items-stretch gap-3 lg:flex-row lg:items-start"
>
<FieldPalette onAdd={addField} />
<Canvas
fields={definition.fields}
selectedKey={selectedKey}
onSelect={setSelectedKey}
onReorder={reorderFields}
onUpdate={updateField}
onDelete={deleteField}
/>
<FieldProperties
field={selectedField}
onChange={(next) => selectedField && updateField(selectedField.key, next)}
/>
</div>
) : null}
{tab === "settings" ? (
<div id={getPanelId("settings")} role="tabpanel" aria-labelledby={getTabId("settings")}>
<SettingsTab value={settings} fields={definition.fields} onChange={setSettingsLocal} />
</div>
) : null}
{tab === "preview" ? (
<div id={getPanelId("preview")} role="tabpanel" aria-labelledby={getTabId("preview")}>
<PreviewTab definition={definition} theme={theme} />
</div>
) : null}
</div>
);
}
@@ -0,0 +1,225 @@
/**
* Preview tab for the form-widget builder. Renders the in-flight
* definition using the SAME field-rendering pipeline + inline CSS as
* the embed route at `/embed/forms/$formKey`. That is intentional:
* what the customer sees in their iframe must match what the operator
* sees in the admin preview.
*
* The component is deliberately self-contained — it is rendered
* outside the sandboxed iframe so no postMessage / parent-resize
* concerns apply, and form-submission is disabled (preview is for
* visual inspection only).
*/
import type * as React from "react";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { HONEYPOT_FIELD_KEY, type FormDefinition, type FormField, type FormTheme } from "@toolctrl/form-widget";
export type PreviewTabProps = {
definition: FormDefinition;
theme: FormTheme;
};
function themeCssVars(theme: FormTheme): React.CSSProperties {
const vars: Record<string, string> = {};
if (theme.primaryColor) vars["--tcform-primary"] = theme.primaryColor;
if (theme.backgroundColor) vars["--tcform-bg"] = theme.backgroundColor;
if (theme.textColor) vars["--tcform-text"] = theme.textColor;
if (theme.fontFamily) vars["--tcform-font"] = theme.fontFamily;
if (theme.borderRadius) vars["--tcform-radius"] = theme.borderRadius;
return vars as React.CSSProperties;
}
function PreviewField({
field,
value,
onChange,
}: {
field: FormField;
value: string | boolean;
onChange: (next: string | boolean) => void;
}): React.JSX.Element | null {
const inputId = `tcform-preview-${field.key}`;
if (field.type === "honeypot") {
return (
<input
type="text"
name={field.key}
tabIndex={-1}
aria-hidden="true"
autoComplete="off"
value={typeof value === "string" ? value : ""}
onChange={(e) => onChange(e.target.value)}
style={{ position: "absolute", left: "-9999px", width: 1, height: 1, opacity: 0 }}
/>
);
}
const labelEl = (
<label htmlFor={inputId} className="tcform-label">
{field.label}
{field.required ? <span aria-hidden="true">{" *"}</span> : null}
</label>
);
const helpEl = field.helpText ? (
<div className="tcform-help" id={`${inputId}-help`}>
{field.helpText}
</div>
) : null;
if (field.type === "textarea") {
return (
<div className="tcform-row">
{labelEl}
<textarea
id={inputId}
name={field.key}
required={field.required}
placeholder={field.placeholder}
maxLength={field.maxLength}
minLength={field.minLength}
aria-describedby={helpEl ? `${inputId}-help` : undefined}
value={typeof value === "string" ? value : ""}
onChange={(e) => onChange(e.target.value)}
rows={4}
/>
{helpEl}
</div>
);
}
if (field.type === "select") {
const options = field.options ?? [];
return (
<div className="tcform-row">
{labelEl}
<select
id={inputId}
name={field.key}
required={field.required}
value={typeof value === "string" ? value : ""}
onChange={(e) => onChange(e.target.value)}
>
<option value="">{field.placeholder ?? ""}</option>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
{helpEl}
</div>
);
}
if (field.type === "radio") {
const options = field.options ?? [];
return (
<fieldset className="tcform-row tcform-radio-group" aria-describedby={helpEl ? `${inputId}-help` : undefined}>
<legend>{field.label}</legend>
{options.map((opt) => (
<label key={opt.value} className="tcform-radio">
<input
type="radio"
name={field.key}
value={opt.value}
checked={value === opt.value}
onChange={() => onChange(opt.value)}
required={field.required}
/>
<span>{opt.label}</span>
</label>
))}
{helpEl}
</fieldset>
);
}
if (field.type === "checkbox") {
return (
<div className="tcform-row tcform-checkbox">
<label className="tcform-label">
<input
type="checkbox"
name={field.key}
checked={value === true}
onChange={(e) => onChange(e.target.checked)}
required={field.required}
/>
<span>{field.label}</span>
</label>
{helpEl}
</div>
);
}
const inputType = field.type === "email" ? "email" : field.type === "number" ? "number" : "text";
return (
<div className="tcform-row">
{labelEl}
<input
id={inputId}
type={inputType}
name={field.key}
required={field.required}
placeholder={field.placeholder}
maxLength={field.maxLength}
minLength={field.minLength}
aria-describedby={helpEl ? `${inputId}-help` : undefined}
value={typeof value === "string" ? value : ""}
onChange={(e) => onChange(e.target.value)}
/>
{helpEl}
</div>
);
}
export function PreviewTab({ definition, theme }: PreviewTabProps): React.JSX.Element {
const { t } = useTranslation();
const styleVars = useMemo(() => themeCssVars(theme), [theme]);
const [values, setValues] = useState<Record<string, string | boolean>>(() => {
const init: Record<string, string | boolean> = {};
for (const f of definition.fields) {
init[f.key] = f.type === "checkbox" ? false : "";
}
return init;
});
return (
<div className="border rounded bg-white p-3 max-w-xl">
<p className="text-[11px] text-gray-500 mb-2">
{t(
"settings.forms.builder.preview.description",
"Preview renders with the same CSS variables and field components as the embedded form route. Submission is disabled.",
)}
</p>
<div className="tcform-root" style={styleVars}>
<style>{`
.tcform-root { font-family: var(--tcform-font, system-ui, sans-serif); background: var(--tcform-bg, #fff); color: var(--tcform-text, #111); padding: 16px; box-sizing: border-box; border: 1px dashed #e5e7eb; border-radius: 6px; }
.tcform-root h1 { font-size: 1.25rem; margin: 0 0 0.25em; }
.tcform-root p.tcform-description { margin: 0 0 1em; opacity: 0.8; }
.tcform-row { display: block; margin-bottom: 12px; }
.tcform-root .tcform-label { display: block; font-weight: 500; margin-bottom: 4px; }
.tcform-root input[type=text], .tcform-root input[type=email], .tcform-root input[type=number], .tcform-root textarea, .tcform-root select { width: 100%; padding: 8px 10px; border: 1px solid #ccc; border-radius: var(--tcform-radius, 6px); font: inherit; color: inherit; background: #fff; box-sizing: border-box; }
.tcform-root textarea { min-height: 100px; resize: vertical; }
.tcform-root .tcform-help { font-size: 0.85em; opacity: 0.7; margin-top: 4px; }
.tcform-root button { background: var(--tcform-primary, #2563eb); color: #fff; border: 0; padding: 10px 16px; border-radius: var(--tcform-radius, 6px); cursor: pointer; font: inherit; }
`}</style>
<h1>{definition.title}</h1>
{definition.description ? <p className="tcform-description">{definition.description}</p> : null}
<form
onSubmit={(e) => {
e.preventDefault();
}}
>
{definition.fields.map((field) => (
<PreviewField
key={field.key}
field={field}
value={values[field.key] ?? (field.type === "checkbox" ? false : "")}
onChange={(next) => setValues((prev) => ({ ...prev, [field.key]: next }))}
/>
))}
<input type="hidden" name="__hp_key" value={HONEYPOT_FIELD_KEY} />
<button type="button" disabled>
{definition.submitLabel}
</button>
</form>
</div>
</div>
);
}
@@ -0,0 +1,145 @@
/**
* Settings tab for the form-builder. Surfaces non-definition settings
* the parent `Form` row persists alongside the JSON definition:
*
* - allowedOrigins → array of hostnames the submit API accepts
* - notifyEmails → mail recipients for new submissions
* - webhookUrl → optional outbound webhook
* - targetType → routing target (generic FormSubmission, CRM
* contact, or entity-row)
* - fieldMapping → field → target-column mapping for non-generic
* target types
*
* Save semantics live with the parent route action — this is a
* controlled view that emits onChange callbacks.
*/
import type * as React from "react";
import { useTranslation } from "react-i18next";
import type { FormField } from "@toolctrl/form-widget";
export type FormSettingsValue = {
allowedOrigins: string[];
notifyEmails: string[];
webhookUrl: string;
targetType: string;
fieldMapping: Record<string, string>;
};
export type SettingsTabProps = {
value: FormSettingsValue;
fields: FormField[];
onChange: (next: FormSettingsValue) => void;
};
const TARGET_OPTIONS = [
{ value: "generic", labelKey: "settings.forms.builder.settings.targets.generic" },
{ value: "crm-contact", labelKey: "settings.forms.builder.settings.targets.crmContact" },
{ value: "entity-row", labelKey: "settings.forms.builder.settings.targets.entityRow" },
];
function joinLines(values: string[]): string {
return values.join("\n");
}
function parseLines(raw: string): string[] {
return raw
.split(/\n+/u)
.map((s) => s.trim())
.filter((s) => s.length > 0);
}
function parseCsvOrLines(raw: string): string[] {
return raw
.split(/[\n,]+/u)
.map((s) => s.trim())
.filter((s) => s.length > 0);
}
export function SettingsTab({ value, fields, onChange }: SettingsTabProps): React.JSX.Element {
const { t } = useTranslation();
const showMapping = value.targetType === "crm-contact" || value.targetType === "entity-row";
return (
<div className="space-y-4 max-w-2xl">
<label className="block">
<span className="block text-xs uppercase tracking-wide text-gray-500">
{t("settings.forms.builder.settings.allowedOrigins")}
</span>
<textarea
rows={3}
className="border rounded px-2 py-1 w-full font-mono text-xs"
value={joinLines(value.allowedOrigins)}
onChange={(e) => onChange({ ...value, allowedOrigins: parseLines(e.target.value) })}
/>
</label>
<label className="block">
<span className="block text-xs uppercase tracking-wide text-gray-500">
{t("settings.forms.builder.settings.notifyEmails")}
</span>
<textarea
rows={2}
className="border rounded px-2 py-1 w-full font-mono text-xs"
value={joinLines(value.notifyEmails)}
onChange={(e) => onChange({ ...value, notifyEmails: parseCsvOrLines(e.target.value) })}
/>
</label>
<label className="block">
<span className="block text-xs uppercase tracking-wide text-gray-500">
{t("settings.forms.builder.settings.webhookUrl")}
</span>
<input
className="border rounded px-2 py-1 w-full font-mono text-xs"
value={value.webhookUrl}
onChange={(e) => onChange({ ...value, webhookUrl: e.target.value })}
/>
</label>
<label className="block">
<span className="block text-xs uppercase tracking-wide text-gray-500">{t("settings.forms.builder.settings.targetType")}</span>
<select
className="border rounded px-2 py-1 w-full"
value={value.targetType}
onChange={(e) => onChange({ ...value, targetType: e.target.value })}
>
{TARGET_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{t(opt.labelKey)}
</option>
))}
</select>
</label>
{showMapping ? (
<div>
<span className="block text-xs uppercase tracking-wide text-gray-500 mb-1">{t("settings.forms.builder.settings.fieldMapping")}</span>
<p className="text-[11px] text-gray-500 mb-2">
{t("settings.forms.builder.settings.fieldMappingDescription")}
</p>
<div className="space-y-1">
{fields.length === 0 ? (
<p className="text-sm text-gray-500 italic">{t("settings.forms.builder.settings.noFields")}</p>
) : (
fields
.filter((f) => f.type !== "honeypot")
.map((f) => (
<div key={f.key} className="grid grid-cols-[1fr_auto_1fr] items-center gap-2">
<code className="text-xs text-gray-600">{f.key}</code>
<span aria-hidden="true">{"→"}</span>
<input
className="border rounded px-2 py-1 text-sm"
placeholder={f.key}
value={value.fieldMapping[f.key] ?? ""}
onChange={(e) =>
onChange({
...value,
fieldMapping: { ...value.fieldMapping, [f.key]: e.target.value },
})
}
aria-label={t("settings.forms.builder.settings.targetColumnAria", { fieldKey: f.key })}
/>
</div>
))
)}
</div>
</div>
) : null}
</div>
);
}
@@ -0,0 +1,74 @@
/**
* Sortable canvas row for the form-builder. Renders a single
* `FormField` with a drag-handle (keyboard + pointer), an inline
* label editor, a "select" affordance to open the right-rail
* properties panel, and a delete button.
*
* Wraps `useSortable` from `@dnd-kit/sortable`. The drag-handle is the
* explicit listener target so that focused inputs (e.g. the inline
* label edit) don't capture pointer-down + start a drag.
*/
import type * as React from "react";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { useTranslation } from "react-i18next";
import type { FormField } from "@toolctrl/form-widget";
export type SortableFieldProps = {
field: FormField;
isSelected: boolean;
onSelect: () => void;
onUpdate: (next: FormField) => void;
onDelete: () => void;
};
export function SortableField({ field, isSelected, onSelect, onUpdate, onDelete }: SortableFieldProps): React.JSX.Element {
const { t } = useTranslation();
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: field.key });
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.6 : 1,
};
return (
<div ref={setNodeRef} style={style} className={`flex items-start gap-2 rounded border bg-white p-2 ${isSelected ? "ring-2 ring-blue-500" : ""}`}>
<button
type="button"
className="inline-flex min-h-10 min-w-10 cursor-grab items-center justify-center px-2 py-2 text-gray-400 hover:text-gray-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
aria-label={t("settings.forms.builder.field.reorderAria", { label: field.label })}
{...listeners}
{...attributes}
>
<span aria-hidden="true" className="text-lg leading-none select-none">
{"☰"}
</span>
</button>
<button
type="button"
onClick={onSelect}
className="min-w-0 flex-1 rounded px-1 text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
aria-label={t("settings.forms.builder.field.selectAria", { label: field.label })}
>
<div className="flex items-center gap-2 text-[10px] tracking-wide text-gray-500 uppercase">
<span>{field.type}</span>
{field.required ? <span>{t("settings.forms.builder.field.required")}</span> : null}
<code className="text-gray-400">{field.key}</code>
</div>
</button>
<input
className="flex-1 border-b border-transparent px-1 py-0.5 text-sm focus:border-blue-500 focus:outline-none"
value={field.label}
onChange={(e) => onUpdate({ ...field, label: e.target.value })}
aria-label={t("settings.forms.builder.field.labelAria", { fieldKey: field.key })}
/>
<button
type="button"
onClick={onDelete}
className="inline-flex min-h-10 min-w-10 items-center justify-center rounded px-2 py-2 text-xs text-red-600 hover:bg-red-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500"
aria-label={t("settings.forms.builder.field.deleteAria", { label: field.label })}
>
{"✕"}
</button>
</div>
);
}
@@ -0,0 +1,93 @@
/**
* Static palette definition for the form-builder. Each entry exposes a
* factory that returns a fresh `FormField` shape (matching the
* `@toolctrl/form-widget` zod schema) so the canvas can append without
* having to deep-clone a frozen prototype.
*
* The palette deliberately exposes a superset of `FormFieldType` —
* `heading` + `paragraph` are presentational items the renderer treats
* as `text` fields with `helpText` carrying the body. They keep parity
* with the original spec ("Heading, Paragraph" callouts in the
* drag-drop UX) without expanding the storage schema.
*/
import { HONEYPOT_FIELD_KEY } from "@toolctrl/form-widget";
import type { PaletteItem } from "./types";
export const paletteDefaultLanguage = "en";
function defaultLabelKey(type: string): string {
return `settings.forms.builder.defaults.${type}.label`;
}
function defaultOptionLabelKey(value: string): string {
return `settings.forms.builder.defaults.options.${value}`;
}
export const paletteItems: PaletteItem[] = [
{
type: "text",
labelKey: "settings.forms.builder.palette.text.label",
helpKey: "settings.forms.builder.palette.text.help",
defaults: () => ({ key: "text_field", type: "text", label: defaultLabelKey("text"), required: false }),
},
{
type: "email",
labelKey: "settings.forms.builder.palette.email.label",
helpKey: "settings.forms.builder.palette.email.help",
defaults: () => ({ key: "email", type: "email", label: defaultLabelKey("email"), required: true }),
},
{
type: "textarea",
labelKey: "settings.forms.builder.palette.textarea.label",
helpKey: "settings.forms.builder.palette.textarea.help",
defaults: () => ({ key: "message", type: "textarea", label: defaultLabelKey("textarea"), required: false }),
},
{
type: "select",
labelKey: "settings.forms.builder.palette.select.label",
helpKey: "settings.forms.builder.palette.select.help",
defaults: () => ({
key: "choice",
type: "select",
label: defaultLabelKey("select"),
required: false,
options: [
{ value: "option_a", label: defaultOptionLabelKey("option_a") },
{ value: "option_b", label: defaultOptionLabelKey("option_b") },
],
}),
},
{
type: "checkbox",
labelKey: "settings.forms.builder.palette.checkbox.label",
helpKey: "settings.forms.builder.palette.checkbox.help",
defaults: () => ({ key: "consent", type: "checkbox", label: defaultLabelKey("checkbox"), required: false }),
},
{
type: "radio",
labelKey: "settings.forms.builder.palette.radio.label",
helpKey: "settings.forms.builder.palette.radio.help",
defaults: () => ({
key: "pick",
type: "radio",
label: defaultLabelKey("radio"),
required: false,
options: [
{ value: "option_a", label: defaultOptionLabelKey("option_a") },
{ value: "option_b", label: defaultOptionLabelKey("option_b") },
],
}),
},
{
type: "number",
labelKey: "settings.forms.builder.palette.number.label",
helpKey: "settings.forms.builder.palette.number.help",
defaults: () => ({ key: "amount", type: "number", label: defaultLabelKey("number"), required: false }),
},
{
type: "honeypot",
labelKey: "settings.forms.builder.palette.honeypot.label",
helpKey: "settings.forms.builder.palette.honeypot.help",
defaults: () => ({ key: HONEYPOT_FIELD_KEY, type: "honeypot", label: defaultLabelKey("honeypot"), required: false }),
},
];
@@ -0,0 +1,78 @@
/**
* Unit tests for the form-builder helper utilities. The drag-drop +
* keyboard-reorder integration tests live in a separate Playwright
* suite (deferred follow-up under form-widget.md); this file covers
* the pure helpers + the palette factory contracts.
*/
import { describe, expect, it } from "vitest";
import { uniqueKey } from "./types";
import { paletteItems } from "./palette";
import { FormFieldSchema } from "@toolctrl/form-widget";
import enTranslations from "../../../../public/locales/en/translations.json";
import deTranslations from "../../../../public/locales/de/translations.json";
function lookupTranslation(source: unknown, key: string): unknown {
return key.split(".").reduce<unknown>((current, segment) => {
if (current && typeof current === "object" && segment in current) {
return (current as Record<string, unknown>)[segment];
}
return undefined;
}, source);
}
describe("uniqueKey", () => {
it("returns the base when it is unused", () => {
expect(uniqueKey("email", new Set())).toBe("email");
});
it("appends a numeric suffix on collision", () => {
expect(uniqueKey("email", new Set(["email"]))).toBe("email_2");
expect(uniqueKey("email", new Set(["email", "email_2"]))).toBe("email_3");
});
it("normalizes invalid identifiers", () => {
expect(uniqueKey("123-bad key!", new Set())).toBe("field_123_bad_key_");
});
it("keeps a valid leading letter", () => {
expect(uniqueKey("user_name", new Set())).toBe("user_name");
});
});
describe("palette defaults", () => {
it("produces FormField shapes that satisfy the canonical zod schema", () => {
for (const item of paletteItems) {
const parsed = FormFieldSchema.safeParse(item.defaults());
expect(parsed.success, `${item.type} default failed: ${parsed.success ? "" : parsed.error.message}`).toBe(true);
}
});
it("issues fresh objects on every call (no shared prototype)", () => {
for (const item of paletteItems) {
const a = item.defaults();
const b = item.defaults();
expect(a).not.toBe(b);
}
});
it("uses locale-backed keys for inserted default labels and option labels", () => {
for (const item of paletteItems) {
const field = item.defaults();
const labelKey = `settings.forms.builder.defaults.${field.type}.label`;
expect(field.label).toBe(labelKey);
expect(lookupTranslation(enTranslations, field.label)).toEqual(expect.any(String));
expect(lookupTranslation(deTranslations, field.label)).toEqual(expect.any(String));
if ("options" in field && field.options) {
for (const option of field.options) {
const optionKey = `settings.forms.builder.defaults.options.${option.value}`;
expect(option.label).toBe(optionKey);
expect(lookupTranslation(enTranslations, option.label)).toEqual(expect.any(String));
expect(lookupTranslation(deTranslations, option.label)).toEqual(expect.any(String));
}
}
}
});
});
@@ -0,0 +1,45 @@
/**
* Shared types for the form-widget drag-drop builder (Phase 05 #58
* follow-up). The runtime contract still flows through the canonical
* `@toolctrl/form-widget` zod schemas — these helpers exist to make the
* UI components terse and to keep palette definitions in one place.
*/
import type { FormField, FormFieldType, FormDefinition, FormTheme } from "@toolctrl/form-widget";
export type BuilderFieldType = FormFieldType | "heading" | "paragraph";
export type PaletteItem = {
/** Field-type id; matches FormFieldType for actual form fields. */
type: BuilderFieldType;
/** Translation key for the operator-visible name. */
labelKey: string;
/** Translation key for the short one-liner shown in the palette tile. */
helpKey: string;
/** Default `FormField` shape used when this palette item is dropped. */
defaults: () => FormField;
};
export type BuilderFormShape = {
definition: FormDefinition;
theme: FormTheme;
/** UI-only settings — persisted on the parent `Form` row, not in the JSON. */
settings: {
allowedOrigins: string[];
notifyEmails: string[];
webhookUrl: string;
targetType: string;
name: string;
};
};
/** Produce a stable but unique key from a base + existing keys. */
export function uniqueKey(base: string, existing: Set<string>): string {
const safeBase = base.replace(/[^a-zA-Z0-9_]/g, "_").replace(/^_+/, "");
const fallback = safeBase.length > 0 && /^[a-zA-Z]/.test(safeBase) ? safeBase : `field_${safeBase}`;
if (!existing.has(fallback)) return fallback;
for (let i = 2; i < 1000; i += 1) {
const candidate = `${fallback}_${i}`;
if (!existing.has(candidate)) return candidate;
}
return `${fallback}_${Date.now()}`;
}