Builder Setup
The builder is split into two independent layers:
| Layer | Component | Responsibility |
|---|---|---|
| State | BuilderWrapper / BuilderProvider | Form structure, configs, undo/redo history |
| Drag-and-drop | BuilderDndContext | DndContext, DragOverlay, drag-end handlers |
BuilderDndContext is optional — BuilderWrapper works perfectly without it. Use BuilderDndContext when you want the full DnD experience out of the box, or bring your own DndContext if you need custom sensors or drag behaviour.
Install dependencies
npm install @schema-forms-data/builder @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities
Component structure
BuilderWrapper ← state (BuilderProvider + TooltipProvider)
└── BuilderDndContext ← optional — DndContext + DragOverlay + handlers
├── Palette ← draggable field types and preset blocks
├── Canvas ← drop area (steps → containers → fields)
├── ConfigPanel ← configuration for the selected item
└── LivePreview ← live preview with FormRenderer
Standard usage
Wrap your own editor component with BuilderWrapper + BuilderDndContext. Your inner components access state via useBuilder() — they don't need to know anything about DnD:
// Page / route component
import { BuilderWrapper, BuilderDndContext } from "@schema-forms-data/builder";
export function BuilderPage({ schema, onSave, onBack }) {
const { showError } = useMessage();
return (
<BuilderWrapper schema={schema}>
<BuilderDndContext onError={showError}>
<BuilderEditor schema={schema} onSave={onSave} onBack={onBack} />
</BuilderDndContext>
</BuilderWrapper>
);
}
// BuilderEditor — save/undo/redo logic via useBuilder(), no DnD knowledge needed
import {
useBuilder,
builderToFormSchema,
validateBuilderIntegrity,
} from "@schema-forms-data/builder";
export function BuilderEditor({ schema, onSave, onBack }) {
const {
configs,
containers,
previewTemplateId,
canUndo,
canRedo,
undo,
redo,
} = useBuilder();
const [nome, setNome] = useState(schema?.nome ?? "");
const [isSaving, setIsSaving] = useState(false);
const handleSave = async () => {
const { errors } = validateBuilderIntegrity({ containers }, configs);
if (errors.length > 0) {
showError(errors.join("\n"));
return;
}
setIsSaving(true);
try {
const result = builderToFormSchema({ containers }, configs, {
nome,
status: "ativo",
template: previewTemplateId,
id: schema?.id,
});
await onSave(result);
} finally {
setIsSaving(false);
}
};
return (
<div style={{ display: "flex", flexDirection: "column", height: "100vh" }}>
<BuilderToolbar
nome={nome}
onNomeChange={setNome}
canUndo={canUndo}
canRedo={canRedo}
onUndo={undo}
onRedo={redo}
isSaving={isSaving}
onSave={handleSave}
onBack={onBack}
/>
<BuilderCanvas />
</div>
);
}
// BuilderCanvas — layout only, no logic
import {
Palette,
Canvas,
ConfigPanel,
LivePreview,
} from "@schema-forms-data/builder";
export function BuilderCanvas({
externalData,
}: {
externalData?: Record<string, unknown>;
}) {
return (
<div style={{ display: "flex", flex: 1, overflow: "hidden" }}>
<aside style={{ width: 200, overflowY: "auto" }}>
<Palette />
</aside>
<main style={{ flex: 1, overflowY: "auto" }}>
<Canvas />
</main>
<aside style={{ width: 280, overflowY: "auto" }}>
<ConfigPanel />
</aside>
<div style={{ width: 380, overflowY: "auto" }}>
<LivePreview eventoData={externalData} />
</div>
</div>
);
}
This structure keeps concerns separated:
| Component | Responsibility |
|---|---|
BuilderWrapper | Converts the schema, provides BuilderProvider context |
BuilderDndContext | All DnD — sensors, drag events, DragOverlay |
BuilderEditor | Save / validate / undo / redo via useBuilder() |
BuilderCanvas | Layout of Palette, Canvas, ConfigPanel, LivePreview |
Using your own DndContext
Replace BuilderDndContext entirely if you need custom sensors or drag behaviour. Palette, Canvas, and ConfigPanel work inside any DndContext as long as BuilderWrapper is an ancestor:
import {
DndContext,
DragOverlay,
PointerSensor,
useSensors,
useSensor,
} from "@dnd-kit/core";
import {
BuilderWrapper,
useBuilder,
Palette,
Canvas,
ConfigPanel,
} from "@schema-forms-data/builder";
function MyDndLayer({ children }: { children: React.ReactNode }) {
const { addItem, moveCanvasItem, reorderInContainer } = useBuilder();
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
);
return (
<DndContext
sensors={sensors}
onDragEnd={(event) => {
// your drag-end logic using addItem / moveCanvasItem / reorderInContainer
}}
>
{children}
<DragOverlay>{/* your ghost */}</DragOverlay>
</DndContext>
);
}
export function CustomBuilder({ schema }: { schema: FormSchema | null }) {
return (
<BuilderWrapper schema={schema}>
<MyDndLayer>
<Palette />
<Canvas />
<ConfigPanel />
</MyDndLayer>
</BuilderWrapper>
);
}
What BuilderDndContext handles internally
| Drag action | What happens |
|---|---|
| Field from Palette → Canvas | addItem(type, containerId) |
| Preset block from Palette → Step | addPresetBlock(block, stepId) |
| Field on Canvas → different position | reorderInContainer or moveCanvasItem |
Drag data shapes
Each draggable item sets data.current with one of these shapes:
| Shape | When | Payload |
|---|---|---|
isPaletteItem: true | Dragging a field type from Palette | { isPaletteItem: true, componentType: string } |
isPalettePreset: true | Dragging a preset block | { isPalettePreset: true, presetId: string } |
isCanvasItem: true | Reordering an item already on the canvas | { isCanvasItem: true, id: string, fromContainer: string } |
Why DragOverlay is required
The Canvas uses overflow: auto on several nested panels. Without a DragOverlay (rendered into a portal on document.body), the dragged ghost is clipped at scroll boundaries. Always render it as a sibling of DndContext, not inside it.
Drag ghost component
DragGhost is exported for use inside a custom DragOverlay:
import { DragGhost, useBuilder } from "@schema-forms-data/builder";
// inside your DragOverlay:
const { getConfig } = useBuilder();
<DragOverlay>
{activeData ? <DragGhost data={activeData} getConfig={getConfig} /> : null}
</DragOverlay>;
Load an existing schema
BuilderWrapper handles conversion automatically when you pass a schema prop. For lower-level access:
import {
formSchemaToBuilder,
BuilderProvider,
} from "@schema-forms-data/builder";
const { configs, dndState, templateId, stepConfig } =
formSchemaToBuilder(existingSchema);
<BuilderProvider
initialConfigs={configs}
initialContainers={dndState.containers}
initialTemplateId={templateId}
initialStepConfig={stepConfig}
>
<BuilderInner />
</BuilderProvider>;