Skip to content

Instantly share code, notes, and snippets.

@fschutt
Created November 12, 2025 09:03
Show Gist options
  • Select an option

  • Save fschutt/8fc918728d1be795a28c9ccdc14315aa to your computer and use it in GitHub Desktop.

Select an option

Save fschutt/8fc918728d1be795a28c9ccdc14315aa to your computer and use it in GitHub Desktop.
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dynamisches ERP Formular</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div id="app"></div>
<script type="module">
import { createSignal, createEffect } from 'https://cdn.skypack.dev/solid-js';
import { createStore } from 'https://cdn.skypack.dev/solid-js/store';
const INITIAL_STATE = {
formTitle: "LEISTUNG FÜR ADEL, ALINA",
constants: {
services: [
{ id: "service_001", displayName: "Stuga FLS" },
{ id: "service_002", displayName: "Therapie" },
{ id: "service_003", displayName: "Beratung" },
{ id: "service_004", displayName: "Hausbesuch" }
],
activities: [
{ id: "activity_001", displayName: "Verwaltung" },
{ id: "activity_002", displayName: "Betreuung" },
{ id: "activity_003", displayName: "Dokumentation" }
],
locations: [
{ id: "location_001", displayName: "Büro" },
{ id: "location_002", displayName: "Klient Zuhause" },
{ id: "location_003", displayName: "Praxis" }
]
},
fields: [
{
key: "system.mitarbeiter",
type: "text",
label: "Mitarbeiter",
value: "Bredemeier, Syenja",
valueType: "raw",
required: true,
readonly: false,
visible: true,
isSystem: true,
order: 0
},
{
key: "system.klient",
type: "text",
label: "Klient",
value: "Adel, Alina",
valueType: "raw",
required: true,
readonly: false,
visible: true,
isSystem: true,
order: 1
},
{
key: "system.beginn",
type: "datetime-local",
label: "Beginn",
value: "2025-10-08T08:00",
valueType: "raw",
required: true,
readonly: false,
visible: true,
isSystem: true,
order: 2
},
{
key: "system.ende",
type: "datetime-local",
label: "Ende",
value: "2025-10-08T09:00",
valueType: "raw",
required: true,
readonly: false,
visible: true,
isSystem: true,
order: 3
},
{
key: "system.zeitraum",
type: "daterange",
label: "Zeitraum",
value: { start: "2025-10-08T08:00", end: "2025-10-08T09:00" },
valueType: "raw",
required: false,
readonly: false,
visible: false,
isSystem: true,
order: 4
},
{
key: "system.leistung",
type: "select",
label: "Leistung",
value: "service_001",
valueType: "constant",
constantKey: "services",
options: ["service_001", "service_002", "service_003", "service_004"],
required: true,
readonly: false,
visible: true,
isSystem: true,
order: 5
},
{
key: "system.stunden",
type: "number",
label: "Stunden",
value: 1.0,
valueType: "raw",
required: false,
readonly: true,
visible: true,
isSystem: true,
order: 6
},
{
key: "system.sitzungslaenge",
type: "number",
label: "Sitzungslänge (min)",
value: 60,
valueType: "raw",
required: false,
readonly: false,
visible: true,
isSystem: true,
order: 7,
visibleWhen: {
field: "system.leistung",
operator: "equals",
value: "service_002"
}
},
{
key: "system.anzahl_sitzungen",
type: "number",
label: "Anzahl Sitzungen",
value: 1,
valueType: "raw",
required: false,
readonly: true,
visible: true,
isSystem: true,
order: 8,
visibleWhen: {
field: "system.leistung",
operator: "equals",
value: "service_002"
}
},
{
key: "system.taetigkeit",
type: "select",
label: "Tätigkeit",
value: "",
valueType: "constant",
constantKey: "activities",
options: ["activity_001", "activity_002", "activity_003"],
required: false,
readonly: false,
visible: false,
isSystem: true,
order: 9,
visibleWhen: {
field: "system.leistung",
operator: "equals",
value: "service_003"
}
},
{
key: "system.einsatzort",
type: "select",
label: "Einsatzort",
value: "",
valueType: "constant",
constantKey: "locations",
options: ["location_001", "location_002", "location_003"],
required: false,
readonly: false,
visible: true,
isSystem: true,
order: 10
},
{
key: "system.pause",
type: "number",
label: "Pause",
value: 0.0,
valueType: "raw",
required: false,
readonly: true,
visible: true,
isSystem: true,
order: 11
},
{
key: "system.pause_automatisch",
type: "bool",
label: "automatisch",
value: true,
valueType: "raw",
required: false,
readonly: false,
visible: true,
isSystem: true,
order: 12
},
{
key: "system.kommentar",
type: "textarea",
label: "Kommentar",
value: "",
valueType: "raw",
required: false,
readonly: false,
visible: true,
isSystem: true,
order: 13
}
]
};
// SolidJS Reactive State
const [state, setState] = createStore(JSON.parse(JSON.stringify(INITIAL_STATE)));
const [draggedIndex, setDraggedIndex] = createSignal(null);
const [showJSON, setShowJSON] = createSignal(true);
const [showAdvanced, setShowAdvanced] = createSignal(false);
const [newFieldLabel, setNewFieldLabel] = createSignal("");
const [newFieldType, setNewFieldType] = createSignal("text");
const [newFieldDefault, setNewFieldDefault] = createSignal("");
const [parentField, setParentField] = createSignal("");
const [selectedValidators, setSelectedValidators] = createSignal([]);
const [enumValues, setEnumValues] = createSignal([{ id: "", displayName: "", indent: 0 }]);
const VALIDATORS = [
{ id: "readonly", label: "Readonly" },
{ id: "required", label: "Required" },
{ id: "non-negative", label: "Non-negative" },
{ id: "email", label: "Valid Email" },
{ id: "phone", label: "Valid Phone" },
{ id: "url", label: "Valid URL" },
{ id: "min-length", label: "Min Length" },
{ id: "max-length", label: "Max Length" }
];
// Hilfsfunktionen
const sortedFields = () => [...state.fields].sort((a, b) => a.order - b.order);
const evaluateVisibility = (field) => {
if (!field.visibleWhen) return true;
const sourceField = state.fields.find(f => f.key === field.visibleWhen.field);
if (!sourceField) return true;
switch (field.visibleWhen.operator) {
case "equals":
return sourceField.value === field.visibleWhen.value;
case "notEquals":
return sourceField.value !== field.visibleWhen.value;
case "contains":
return String(sourceField.value).includes(field.visibleWhen.value);
default:
return true;
}
};
const validateField = (field) => {
if (!field.validators) return true;
for (const validator of field.validators) {
switch (validator) {
case "non-negative":
if (field.type === "number" && field.value < 0) return false;
break;
case "email":
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (field.type === "text" && field.value && !emailRegex.test(field.value)) return false;
break;
case "phone":
const phoneRegex = /^[\d\s\-\+\(\)]+$/;
if (field.type === "text" && field.value && !phoneRegex.test(field.value)) return false;
break;
case "url":
try {
if (field.value) new URL(field.value);
} catch {
return false;
}
break;
}
}
return true;
};
const getFieldHierarchy = (key) => {
const parts = key.split('.');
if (parts[0] === 'user' && parts.length > 2) {
return parts.slice(1, -1).join(' › ') + ' › ';
}
return '';
};
const getFieldName = (key) => {
const parts = key.split('.');
return parts[parts.length - 1];
};
const calculateHours = () => {
const beginnField = state.fields.find(f => f.key === 'system.beginn');
const endeField = state.fields.find(f => f.key === 'system.ende');
if (beginnField?.value && endeField?.value) {
const beginn = new Date(beginnField.value);
const ende = new Date(endeField.value);
const diff = (ende - beginn) / (1000 * 60 * 60);
return Math.max(0, Number(diff.toFixed(2)));
}
return 0;
};
const calculateSessionCount = () => {
const stundenField = state.fields.find(f => f.key === 'system.stunden');
const sitzungslaengeField = state.fields.find(f => f.key === 'system.sitzungslaenge');
if (stundenField?.value && sitzungslaengeField?.value && sitzungslaengeField.value > 0) {
const totalMinutes = stundenField.value * 60;
const sessionLength = sitzungslaengeField.value;
const sessions = Math.floor(totalMinutes / sessionLength);
return Math.max(0, sessions);
}
return 0;
};
const updateFormTitle = () => {
const klientField = state.fields.find(f => f.key === 'system.klient');
if (klientField?.value) {
setState("formTitle", `LEISTUNG FÜR ${klientField.value.toUpperCase()}`);
const headerTitle = document.querySelector('.form-header h1');
if (headerTitle) {
headerTitle.textContent = state.formTitle;
}
}
};
const updateFieldValue = (key, value) => {
const fieldIndex = state.fields.findIndex(f => f.key === key);
if (fieldIndex !== -1) {
setState("fields", fieldIndex, "value", value);
console.log('Field updated:', {
key,
value,
fullState: JSON.parse(JSON.stringify(state))
});
// Abhängigkeit: Stunden neu berechnen
if (key === 'system.beginn' || key === 'system.ende') {
const stundenIndex = state.fields.findIndex(f => f.key === 'system.stunden');
if (stundenIndex !== -1) {
const newHours = calculateHours();
setState("fields", stundenIndex, "value", newHours);
// Auch Anzahl Sitzungen neu berechnen
const anzahlIndex = state.fields.findIndex(f => f.key === 'system.anzahl_sitzungen');
if (anzahlIndex !== -1) {
setState("fields", anzahlIndex, "value", calculateSessionCount());
}
}
}
// Abhängigkeit: Anzahl Sitzungen neu berechnen wenn Sitzungslänge sich ändert
if (key === 'system.sitzungslaenge') {
const anzahlIndex = state.fields.findIndex(f => f.key === 'system.anzahl_sitzungen');
if (anzahlIndex !== -1) {
const newCount = calculateSessionCount();
setState("fields", anzahlIndex, "value", newCount);
console.log('Session count recalculated:', {
newSessionLength: value,
totalHours: state.fields.find(f => f.key === 'system.stunden')?.value,
newSessionCount: newCount
});
}
}
// Formular-Titel aktualisieren wenn Klient sich ändert
if (key === 'system.klient') {
updateFormTitle();
}
// Visibility Dependencies neu evaluieren
state.fields.forEach((field, idx) => {
if (field.visibleWhen && field.visibleWhen.field === key) {
const newVisibility = evaluateVisibility(field);
setState("fields", idx, "visible", newVisibility);
console.log('Visibility dependency triggered:', {
targetField: field.key,
sourceField: key,
sourceValue: value,
newVisibility,
condition: field.visibleWhen
});
}
});
}
};
const handleDragStart = (index) => {
setDraggedIndex(index);
// Add dragging class to all rows
document.querySelectorAll('.field-row').forEach((row, idx) => {
if (idx === index) {
row.classList.add('dragging');
}
});
};
const handleDragOver = (index) => {
const dragIndex = draggedIndex();
if (dragIndex === null || dragIndex === index) return;
// Remove drag-over classes from all rows
document.querySelectorAll('.field-row').forEach(row => {
row.classList.remove('drag-over', 'drag-over-nested');
});
// Add drag-over class to target row
const rows = document.querySelectorAll('.field-row');
if (rows[index]) {
rows[index].classList.add('drag-over');
}
};
const handleDragOverNested = (index, event) => {
event.preventDefault();
event.stopPropagation();
const dragIndex = draggedIndex();
if (dragIndex === null || dragIndex === index) return;
// Remove drag-over classes from all rows
document.querySelectorAll('.field-row').forEach(row => {
row.classList.remove('drag-over', 'drag-over-nested');
});
// Add nested drag-over class to target row
const rows = document.querySelectorAll('.field-row');
if (rows[index]) {
rows[index].classList.add('drag-over-nested');
}
};
const handleDrop = (dropIndex, isNested = false) => {
const dragIndex = draggedIndex();
if (dragIndex === null || dragIndex === dropIndex) {
cleanupDragClasses();
return;
}
const fields = sortedFields();
const draggedField = fields[dragIndex];
const droppedField = fields[dropIndex];
// Get all fields that depend on the dragged field
const getDependentFields = (parentKey) => {
return state.fields.filter(f => f.visibleWhen && f.visibleWhen.field === parentKey);
};
const draggedDependents = getDependentFields(draggedField.key);
// Calculate new order values
const dragFieldIndex = state.fields.findIndex(f => f.key === draggedField.key);
const dropFieldIndex = state.fields.findIndex(f => f.key === droppedField.key);
if (isNested) {
// Set parent relationship
setState("fields", dragFieldIndex, "parentField", droppedField.key);
setState("fields", dragFieldIndex, "order", droppedField.order + 0.5);
console.log('Field nested under parent:', {
draggedKey: draggedField.key,
parentKey: droppedField.key
});
} else {
// Swap order values (normal drop)
const tempOrder = draggedField.order;
setState("fields", dragFieldIndex, "order", droppedField.order);
setState("fields", dropFieldIndex, "order", tempOrder);
// Clear parent relationship if dropping at root level
setState("fields", dragFieldIndex, "parentField", undefined);
}
// Move dependents to follow parent
if (draggedDependents.length > 0) {
const newParentOrder = isNested ? droppedField.order + 0.5 : droppedField.order;
draggedDependents.forEach((dependent, idx) => {
const depIndex = state.fields.findIndex(f => f.key === dependent.key);
setState("fields", depIndex, "order", newParentOrder + 0.1 * (idx + 1));
});
// Renormalize all orders to be integers
const sorted = sortedFields();
sorted.forEach((field, idx) => {
const fieldIndex = state.fields.findIndex(f => f.key === field.key);
setState("fields", fieldIndex, "order", idx);
});
}
console.log('Field reordered:', {
draggedKey: draggedField.key,
droppedKey: droppedField.key,
isNested,
dependents: draggedDependents.map(d => d.key)
});
setDraggedIndex(null);
cleanupDragClasses();
};
const handleDragEnd = () => {
cleanupDragClasses();
setDraggedIndex(null);
};
const cleanupDragClasses = () => {
document.querySelectorAll('.field-row').forEach(row => {
row.classList.remove('dragging', 'drag-over', 'drag-over-nested');
});
};
const deleteField = (key) => {
const field = state.fields.find(f => f.key === key);
if (field && !field.isSystem) {
setState("fields", state.fields.filter(f => f.key !== key));
}
};
// Generate key from label (e.g. "Mein Feld" -> "mein_feld")
const generateKeyFromLabel = (label) => {
return label
.toLowerCase()
.replace(/ä/g, 'ae')
.replace(/ö/g, 'oe')
.replace(/ü/g, 'ue')
.replace(/ß/g, 'ss')
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '');
};
// Generate UUID v4
const generateUUID = () => {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
};
const addCustomField = () => {
const label = newFieldLabel().trim();
const type = newFieldType();
const defaultValue = newFieldDefault();
if (!label) {
alert("Bitte Feldname ausfüllen");
return;
}
// Generate key from label
const generatedKey = generateKeyFromLabel(label);
// Build full key (no parent hierarchy from dropdown anymore)
const fullKey = `user.${generatedKey}`;
if (state.fields.find(f => f.key === fullKey)) {
alert("Ein Feld mit diesem Key existiert bereits");
return;
}
// Get default value based on type
let finalValue;
if (type === "bool") {
finalValue = defaultValue === "true" || defaultValue === true;
} else if (type === "number") {
finalValue = parseFloat(defaultValue) || 0;
} else if (type === "datetime-local") {
// Default to now
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0');
finalValue = `${year}-${month}-${day}T${hours}:${minutes}`;
} else if (type === "daterange") {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0');
const nowStr = `${year}-${month}-${day}T${hours}:${minutes}`;
finalValue = { start: nowStr, end: nowStr };
} else if (type === "select") {
// For select, get first enum value ID
const firstEnum = enumValues()[0];
finalValue = firstEnum?.id || "";
} else {
finalValue = defaultValue || "";
}
const newField = {
key: fullKey,
type,
label,
value: finalValue,
valueType: type === "select" ? "constant" : "raw",
constantKey: type === "select" ? fullKey : undefined,
required: selectedValidators().includes("required"),
readonly: selectedValidators().includes("readonly"),
visible: true,
isSystem: false,
order: state.fields.length,
options: type === "select" ? enumValues().map(e => e.id) : undefined,
validators: selectedValidators().filter(v => v !== "readonly" && v !== "required")
};
// If select type, add constants
if (type === "select") {
const constantsArray = enumValues().map(ev => ({
id: ev.id,
displayName: ev.displayName,
indent: ev.indent
}));
setState("constants", fullKey, constantsArray);
}
setState("fields", [...state.fields, newField]);
console.log('Custom field added:', newField);
// Reset form
setNewFieldLabel("");
setNewFieldType("text");
setNewFieldDefault("");
setParentField("");
setSelectedValidators([]);
setEnumValues([{ id: "", displayName: "", indent: 0 }]);
setShowAdvanced(false);
// Trigger UI update
renderAddFieldForm();
};
// DOM Rendering Funktionen
function createFieldInput(field) {
const container = document.createElement('div');
container.className = 'field-input';
const isValid = validateField(field);
let input;
switch (field.type) {
case "text":
input = document.createElement('input');
input.type = 'text';
input.value = field.value || '';
input.readOnly = field.readonly;
if (!isValid) input.classList.add('invalid');
input.addEventListener('change', (e) => {
updateFieldValue(field.key, e.target.value);
});
break;
case "number":
input = document.createElement('input');
input.type = 'number';
input.value = field.value || 0;
input.readOnly = field.readonly;
input.step = '0.01';
if (!isValid) input.classList.add('invalid');
input.addEventListener('change', (e) => {
updateFieldValue(field.key, parseFloat(e.target.value) || 0);
});
break;
case "datetime-local":
input = document.createElement('input');
input.type = 'datetime-local';
input.value = field.value || '';
input.readOnly = field.readonly;
input.addEventListener('change', (e) => {
updateFieldValue(field.key, e.target.value);
});
break;
case "daterange":
// Create a wrapper for two datetime inputs side by side
const rangeWrapper = document.createElement('div');
rangeWrapper.style.display = 'grid';
rangeWrapper.style.gridTemplateColumns = '1fr 1fr';
rangeWrapper.style.gap = '8px';
const startInput = document.createElement('input');
startInput.type = 'datetime-local';
startInput.value = field.value?.start || '';
startInput.readOnly = field.readonly;
startInput.placeholder = 'Von';
startInput.addEventListener('change', (e) => {
const newValue = { ...(field.value || {}), start: e.target.value };
updateFieldValue(field.key, newValue);
});
const endInput = document.createElement('input');
endInput.type = 'datetime-local';
endInput.value = field.value?.end || '';
endInput.readOnly = field.readonly;
endInput.placeholder = 'Bis';
endInput.addEventListener('change', (e) => {
const newValue = { ...(field.value || {}), end: e.target.value };
updateFieldValue(field.key, newValue);
});
rangeWrapper.appendChild(startInput);
rangeWrapper.appendChild(endInput);
container.appendChild(rangeWrapper);
return container;
case "select":
input = document.createElement('select');
input.disabled = field.readonly;
const emptyOption = document.createElement('option');
emptyOption.value = '';
input.appendChild(emptyOption);
(field.options || []).forEach(opt => {
const option = document.createElement('option');
option.value = opt;
// If this field uses constants, look up the display name
if (field.valueType === "constant" && field.constantKey && state.constants[field.constantKey]) {
const constantEntry = state.constants[field.constantKey].find(c => c.id === opt);
option.textContent = constantEntry ? constantEntry.displayName : opt;
} else {
option.textContent = opt;
}
if (opt === field.value) option.selected = true;
input.appendChild(option);
});
input.addEventListener('change', (e) => updateFieldValue(field.key, e.target.value));
break;
case "bool":
input = document.createElement('div');
input.className = 'toggle-switch' + (field.value ? ' active' : '');
if (!field.readonly) {
input.addEventListener('click', () => updateFieldValue(field.key, !field.value));
}
container.appendChild(input);
return container;
case "textarea":
input = document.createElement('textarea');
input.value = field.value || '';
input.readOnly = field.readonly;
input.rows = 3;
input.addEventListener('change', (e) => {
updateFieldValue(field.key, e.target.value);
});
break;
}
if (input) container.appendChild(input);
return container;
}
function createFieldRow(field, index) {
const row = document.createElement('div');
row.className = 'field-row';
// Add dependent class if field has visibleWhen dependency or parentField
if (field.visibleWhen || field.parentField) {
row.classList.add('dependent');
}
row.draggable = true;
row.addEventListener('dragstart', () => handleDragStart(index));
// Normal drop area (left 70%)
row.addEventListener('dragover', (e) => {
e.preventDefault();
const rect = row.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const isRightSide = mouseX > rect.width * 0.7;
if (isRightSide) {
handleDragOverNested(index, e);
} else {
handleDragOver(index);
}
});
row.addEventListener('drop', (e) => {
e.preventDefault();
const rect = row.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const isRightSide = mouseX > rect.width * 0.7;
handleDrop(index, isRightSide);
});
row.addEventListener('dragend', handleDragEnd);
// Drag Handle
const handle = document.createElement('div');
handle.className = 'drag-handle';
handle.textContent = '⋮⋮';
// Label
const label = document.createElement('div');
label.className = 'field-label' + (field.required ? ' required' : '');
const hierarchy = getFieldHierarchy(field.key);
if (hierarchy) {
const hierarchySpan = document.createElement('span');
hierarchySpan.className = 'hierarchy';
hierarchySpan.textContent = hierarchy;
label.appendChild(hierarchySpan);
}
const labelText = document.createTextNode(field.label);
label.appendChild(labelText);
if (!field.isSystem) {
const badge = document.createElement('span');
badge.className = 'custom-badge';
badge.textContent = 'CUSTOM';
label.appendChild(badge);
}
// Input Wrapper
const wrapper = document.createElement('div');
wrapper.className = 'field-input-wrapper';
wrapper.appendChild(createFieldInput(field));
// Clear Button
if (!field.readonly && !field.required && field.value) {
const clearBtn = document.createElement('button');
clearBtn.className = 'clear-btn';
clearBtn.textContent = '✕';
clearBtn.title = 'Leeren';
clearBtn.addEventListener('click', () => updateFieldValue(field.key, field.type === "bool" ? false : ""));
wrapper.appendChild(clearBtn);
}
// Delete Button (nur für Custom Fields)
if (!field.isSystem) {
const deleteBtn = document.createElement('button');
deleteBtn.className = 'clear-btn';
deleteBtn.textContent = '🗑';
deleteBtn.title = 'Feld löschen';
deleteBtn.addEventListener('click', () => deleteField(field.key));
wrapper.appendChild(deleteBtn);
}
row.appendChild(handle);
row.appendChild(label);
row.appendChild(wrapper);
return row;
}
function renderFields() {
const formBody = document.querySelector('.form-body');
if (!formBody) return;
formBody.innerHTML = '';
sortedFields().forEach((field, index) => {
if (field.visible) {
formBody.appendChild(createFieldRow(field, index));
}
});
}
function renderJSON() {
const jsonOutput = document.getElementById('json-output');
if (!jsonOutput) return;
if (showJSON()) {
jsonOutput.style.display = 'block';
jsonOutput.textContent = JSON.stringify(state, null, 2);
} else {
jsonOutput.style.display = 'none';
}
}
// Initial Render
const app = document.getElementById('app');
app.innerHTML = `
<div class="app-container">
<div class="form-card">
<div class="form-header">
<h1>${state.formTitle}</h1>
<div class="header-actions">
<button class="icon-btn" title="Hilfe">?</button>
<button class="icon-btn" title="Löschen">🗑</button>
<button class="icon-btn" title="Schließen">✕</button>
</div>
</div>
<div class="form-body"></div>
<div class="section-title">Benutzerdefinierte Felder hinzufügen</div>
<div class="add-field-section"></div>
<div class="form-footer">
<button class="btn btn-primary">✓ SPEICHERN</button>
</div>
</div>
<div class="json-panel">
<div class="json-header">
<span>Store JSON</span>
<button class="json-toggle-btn" id="toggleJsonBtn">Ausblenden</button>
</div>
<pre class="json-output" id="json-output"></pre>
</div>
</div>
`;
// Event Listeners
document.getElementById('toggleJsonBtn').addEventListener('click', () => {
setShowJSON(!showJSON());
const btn = document.getElementById('toggleJsonBtn');
const output = document.getElementById('json-output');
if (showJSON()) {
btn.textContent = 'Ausblenden';
output.style.display = 'block';
} else {
btn.textContent = 'Einblenden';
output.style.display = 'none';
}
});
// Render Validators
function renderValidators() {
const container = document.getElementById('validatorTags');
if (!container) return;
container.innerHTML = '';
VALIDATORS.forEach(validator => {
const tag = document.createElement('span');
tag.className = 'validator-tag';
tag.textContent = validator.label;
if (selectedValidators().includes(validator.id)) {
tag.classList.add('selected');
}
tag.addEventListener('click', () => {
const current = selectedValidators();
if (current.includes(validator.id)) {
setSelectedValidators(current.filter(v => v !== validator.id));
} else {
setSelectedValidators([...current, validator.id]);
}
renderValidators();
});
container.appendChild(tag);
});
}
// Render Enum Values with UUID and indentation
function renderEnumValues() {
const container = document.getElementById('enumValues');
if (!container) return;
container.innerHTML = '';
enumValues().forEach((enumVal, idx) => {
const row = document.createElement('div');
row.className = 'enum-value-row';
row.style.marginLeft = `${enumVal.indent * 20}px`;
// Indent controls
const indentControls = document.createElement('div');
indentControls.className = 'indent-controls';
const decreaseIndent = document.createElement('button');
decreaseIndent.className = 'indent-btn';
decreaseIndent.textContent = '←';
decreaseIndent.disabled = enumVal.indent === 0;
decreaseIndent.title = 'Einrückung verringern';
decreaseIndent.addEventListener('click', () => {
const vals = [...enumValues()];
vals[idx].indent = Math.max(0, vals[idx].indent - 1);
setEnumValues(vals);
renderEnumValues();
});
const increaseIndent = document.createElement('button');
increaseIndent.className = 'indent-btn';
increaseIndent.textContent = '→';
increaseIndent.disabled = enumVal.indent >= 3;
increaseIndent.title = 'Einrückung erhöhen';
increaseIndent.addEventListener('click', () => {
const vals = [...enumValues()];
vals[idx].indent = Math.min(3, vals[idx].indent + 1);
setEnumValues(vals);
renderEnumValues();
});
const indentLevel = document.createElement('div');
indentLevel.className = 'indent-indicator';
indentLevel.textContent = enumVal.indent;
indentControls.appendChild(decreaseIndent);
indentControls.appendChild(indentLevel);
indentControls.appendChild(increaseIndent);
// Key input
const keyInput = document.createElement('input');
keyInput.type = 'text';
keyInput.value = enumVal.id;
keyInput.placeholder = 'UUID/Key';
keyInput.addEventListener('input', (e) => {
const vals = [...enumValues()];
vals[idx].id = e.target.value;
setEnumValues(vals);
});
// Generate UUID button
const uuidBtn = document.createElement('button');
uuidBtn.className = 'small-btn';
uuidBtn.textContent = '🔑';
uuidBtn.title = 'UUID generieren';
uuidBtn.addEventListener('click', () => {
const vals = [...enumValues()];
vals[idx].id = generateUUID();
setEnumValues(vals);
renderEnumValues();
});
keyInput.style.display = 'flex';
keyInput.style.gap = '4px';
const keyWrapper = document.createElement('div');
keyWrapper.style.display = 'flex';
keyWrapper.style.gap = '4px';
keyWrapper.appendChild(keyInput);
keyWrapper.appendChild(uuidBtn);
// Display name input
const nameInput = document.createElement('input');
nameInput.type = 'text';
nameInput.value = enumVal.displayName;
nameInput.placeholder = 'Anzeigename';
nameInput.addEventListener('input', (e) => {
const vals = [...enumValues()];
vals[idx].displayName = e.target.value;
// Auto-generate key if empty
if (!vals[idx].id) {
vals[idx].id = generateKeyFromLabel(e.target.value);
}
setEnumValues(vals);
});
// Remove button
const removeBtn = document.createElement('button');
removeBtn.className = 'small-btn';
removeBtn.textContent = '✕';
removeBtn.title = 'Entfernen';
removeBtn.addEventListener('click', () => {
setEnumValues(enumValues().filter((_, i) => i !== idx));
renderEnumValues();
});
row.appendChild(indentControls);
row.appendChild(keyWrapper);
row.appendChild(nameInput);
if (enumValues().length > 1) {
row.appendChild(removeBtn);
} else {
row.appendChild(document.createElement('div')); // Spacer
}
container.appendChild(row);
});
// Add button
const addBtn = document.createElement('button');
addBtn.className = 'btn btn-secondary';
addBtn.style.marginTop = '8px';
addBtn.textContent = '+ Wert hinzufügen';
addBtn.addEventListener('click', () => {
setEnumValues([...enumValues(), { id: "", displayName: "", indent: 0 }]);
renderEnumValues();
});
container.appendChild(addBtn);
}
// Render Add Field Form
function renderAddFieldForm() {
const section = document.querySelector('.add-field-section');
if (!section) return;
section.innerHTML = `
<div class="add-field-form">
<div class="form-group">
<label>Feldname</label>
<input id="newFieldLabel" type="text" placeholder="z.B. Anfahrtszeit" />
</div>
<div class="form-group">
<label>Feldtyp</label>
<select id="newFieldType">
<option value="text">Text</option>
<option value="number">Zahl</option>
<option value="bool">Boolean</option>
<option value="select">Dropdown</option>
<option value="textarea">Textarea</option>
<option value="datetime-local">Datum/Zeit</option>
<option value="daterange">Datums-Bereich</option>
</select>
</div>
<div class="form-group" id="defaultValueGroup">
<label>Standardwert</label>
<input id="newFieldDefault" type="text" placeholder="Standard..." />
</div>
<div class="form-group" id="defaultBoolGroup" style="display: none;">
<label>Standardwert</label>
<div class="toggle-switch" id="newFieldBoolDefault"></div>
</div>
<button class="advanced-toggle" id="advancedToggle">
Erweiterte Optionen
</button>
<div class="advanced-config" id="advancedConfig">
<div class="form-group">
<label>Validatoren</label>
<div class="validator-tags" id="validatorTags"></div>
</div>
<div class="form-group" id="enumValuesGroup" style="display: none;">
<label>Dropdown-Werte (mit UUID & Einrückung)</label>
<div id="enumValues"></div>
</div>
</div>
<div class="form-actions">
<button class="btn btn-secondary" id="cancelFieldBtn">Abbrechen</button>
<button class="btn btn-primary" id="addFieldBtn">+ Feld hinzufügen</button>
</div>
</div>
`;
// Wire up event listeners
const labelInput = document.getElementById('newFieldLabel');
labelInput.value = newFieldLabel();
labelInput.addEventListener('input', (e) => setNewFieldLabel(e.target.value));
const typeSelect = document.getElementById('newFieldType');
typeSelect.value = newFieldType();
typeSelect.addEventListener('change', (e) => {
setNewFieldType(e.target.value);
updateDefaultValueVisibility();
renderAddFieldForm();
});
const defaultInput = document.getElementById('newFieldDefault');
if (defaultInput) {
defaultInput.value = newFieldDefault();
defaultInput.addEventListener('input', (e) => setNewFieldDefault(e.target.value));
}
// Boolean toggle
const boolToggle = document.getElementById('newFieldBoolDefault');
if (boolToggle) {
const currentBoolValue = newFieldDefault() === "true" || newFieldDefault() === true;
if (currentBoolValue) {
boolToggle.classList.add('active');
}
boolToggle.addEventListener('click', () => {
const newValue = !boolToggle.classList.contains('active');
setNewFieldDefault(newValue.toString());
boolToggle.classList.toggle('active');
});
}
// Advanced toggle
const advancedToggle = document.getElementById('advancedToggle');
const advancedConfig = document.getElementById('advancedConfig');
if (showAdvanced()) {
advancedConfig.classList.add('visible');
advancedToggle.classList.add('expanded');
}
advancedToggle.addEventListener('click', () => {
setShowAdvanced(!showAdvanced());
advancedConfig.classList.toggle('visible');
advancedToggle.classList.toggle('expanded');
});
// Buttons
document.getElementById('addFieldBtn').addEventListener('click', addCustomField);
document.getElementById('cancelFieldBtn').addEventListener('click', () => {
setNewFieldLabel("");
setNewFieldType("text");
setNewFieldDefault("");
setParentField("");
setSelectedValidators([]);
setEnumValues([{ id: "", displayName: "", indent: 0 }]);
setShowAdvanced(false);
renderAddFieldForm();
});
// Render sub-components
updateDefaultValueVisibility();
renderValidators();
// Show enum values if type is select
if (newFieldType() === 'select') {
document.getElementById('enumValuesGroup').style.display = 'flex';
renderEnumValues();
}
}
function updateDefaultValueVisibility() {
const type = newFieldType();
const defaultGroup = document.getElementById('defaultValueGroup');
const boolGroup = document.getElementById('defaultBoolGroup');
if (!defaultGroup || !boolGroup) return;
// Hide default value for datetime/daterange (defaults to "now")
if (type === 'datetime-local' || type === 'daterange') {
defaultGroup.style.display = 'none';
boolGroup.style.display = 'none';
} else if (type === 'select') {
defaultGroup.style.display = 'none'; // Handled by enum values
boolGroup.style.display = 'none';
} else if (type === 'bool') {
defaultGroup.style.display = 'none';
boolGroup.style.display = 'flex';
} else {
defaultGroup.style.display = 'flex';
boolGroup.style.display = 'none';
}
}
// Update Parent Field Selector
function updateParentFieldSelector() {
// No longer needed - hierarchy is done via drag & drop
}
// Event Listeners
document.getElementById('toggleJsonBtn').addEventListener('click', () => {
setShowJSON(!showJSON());
const btn = document.getElementById('toggleJsonBtn');
const output = document.getElementById('json-output');
if (showJSON()) {
btn.textContent = 'Ausblenden';
output.style.display = 'block';
} else {
btn.textContent = 'Einblenden';
output.style.display = 'none';
}
});
// Initial render
renderAddFieldForm();
// SolidJS Effects - Auto Re-render bei State-Änderungen
createEffect(() => {
renderFields();
});
createEffect(() => {
renderJSON();
});
// Initial Render
renderFields();
</script>
</body>
</html>
@fschutt
Copy link
Author

fschutt commented Nov 12, 2025

<title>Dynamisches ERP Formular</title>
<script type="module">
    import { createSignal, createEffect } from 'https://cdn.skypack.dev/solid-js';
    import { createStore } from 'https://cdn.skypack.dev/solid-js/store';

    const INITIAL_STATE = {
        formTitle: "LEISTUNG FÜR ADEL, ALINA",
        constants: {
            services: [
                { id: "service_001", displayName: "Stuga FLS" },
                { id: "service_002", displayName: "Therapie" },
                { id: "service_003", displayName: "Beratung" },
                { id: "service_004", displayName: "Hausbesuch" }
            ],
            activities: [
                { id: "activity_001", displayName: "Verwaltung" },
                { id: "activity_002", displayName: "Betreuung" },
                { id: "activity_003", displayName: "Dokumentation" }
            ],
            locations: [
                { id: "location_001", displayName: "Büro" },
                { id: "location_002", displayName: "Klient Zuhause" },
                { id: "location_003", displayName: "Praxis" }
            ]
        },
        fields: [
            {
                key: "system.mitarbeiter",
                type: "text",
                label: "Mitarbeiter",
                value: "Bredemeier, Syenja",
                valueType: "raw",
                required: true,
                readonly: false,
                visible: true,
                isSystem: true,
                order: 0
            },
            {
                key: "system.klient",
                type: "text",
                label: "Klient",
                value: "Adel, Alina",
                valueType: "raw",
                required: true,
                readonly: false,
                visible: true,
                isSystem: true,
                order: 1
            },
            {
                key: "system.beginn",
                type: "datetime-local",
                label: "Beginn",
                value: "2025-10-08T08:00",
                valueType: "raw",
                required: true,
                readonly: false,
                visible: true,
                isSystem: true,
                order: 2
            },
            {
                key: "system.ende",
                type: "datetime-local",
                label: "Ende",
                value: "2025-10-08T09:00",
                valueType: "raw",
                required: true,
                readonly: false,
                visible: true,
                isSystem: true,
                order: 3
            },
            {
                key: "system.zeitraum",
                type: "daterange",
                label: "Zeitraum",
                value: { start: "2025-10-08T08:00", end: "2025-10-08T09:00" },
                valueType: "raw",
                required: false,
                readonly: false,
                visible: false,
                isSystem: true,
                order: 4
            },
            {
                key: "system.leistung",
                type: "select",
                label: "Leistung",
                value: "service_001",
                valueType: "constant",
                constantKey: "services",
                options: ["service_001", "service_002", "service_003", "service_004"],
                required: true,
                readonly: false,
                visible: true,
                isSystem: true,
                order: 5
            },
            {
                key: "system.stunden",
                type: "number",
                label: "Stunden",
                value: 1.0,
                valueType: "raw",
                required: false,
                readonly: true,
                visible: true,
                isSystem: true,
                order: 6
            },
            {
                key: "system.sitzungslaenge",
                type: "number",
                label: "Sitzungslänge (min)",
                value: 60,
                valueType: "raw",
                required: false,
                readonly: false,
                visible: true,
                isSystem: true,
                order: 7,
                visibleWhen: {
                    field: "system.leistung",
                    operator: "equals",
                    value: "service_002"
                }
            },
            {
                key: "system.anzahl_sitzungen",
                type: "number",
                label: "Anzahl Sitzungen",
                value: 1,
                valueType: "raw",
                required: false,
                readonly: true,
                visible: true,
                isSystem: true,
                order: 8,
                visibleWhen: {
                    field: "system.leistung",
                    operator: "equals",
                    value: "service_002"
                }
            },
            {
                key: "system.taetigkeit",
                type: "select",
                label: "Tätigkeit",
                value: "",
                valueType: "constant",
                constantKey: "activities",
                options: ["activity_001", "activity_002", "activity_003"],
                required: false,
                readonly: false,
                visible: false,
                isSystem: true,
                order: 9,
                visibleWhen: {
                    field: "system.leistung",
                    operator: "equals",
                    value: "service_003"
                }
            },
            {
                key: "system.einsatzort",
                type: "select",
                label: "Einsatzort",
                value: "",
                valueType: "constant",
                constantKey: "locations",
                options: ["location_001", "location_002", "location_003"],
                required: false,
                readonly: false,
                visible: true,
                isSystem: true,
                order: 10
            },
            {
                key: "system.pause",
                type: "number",
                label: "Pause",
                value: 0.0,
                valueType: "raw",
                required: false,
                readonly: true,
                visible: true,
                isSystem: true,
                order: 11
            },
            {
                key: "system.pause_automatisch",
                type: "bool",
                label: "automatisch",
                value: true,
                valueType: "raw",
                required: false,
                readonly: false,
                visible: true,
                isSystem: true,
                order: 12
            },
            {
                key: "system.kommentar",
                type: "textarea",
                label: "Kommentar",
                value: "",
                valueType: "raw",
                required: false,
                readonly: false,
                visible: true,
                isSystem: true,
                order: 13
            }
        ]
    };

    // SolidJS Reactive State
    const [state, setState] = createStore(JSON.parse(JSON.stringify(INITIAL_STATE)));
    const [draggedIndex, setDraggedIndex] = createSignal(null);
    const [showJSON, setShowJSON] = createSignal(true);
    const [showAdvanced, setShowAdvanced] = createSignal(false);
    const [newFieldLabel, setNewFieldLabel] = createSignal("");
    const [newFieldType, setNewFieldType] = createSignal("text");
    const [newFieldDefault, setNewFieldDefault] = createSignal("");
    const [parentField, setParentField] = createSignal("");
    const [selectedValidators, setSelectedValidators] = createSignal([]);
    const [enumValues, setEnumValues] = createSignal([{ id: "", displayName: "", indent: 0 }]);

    const VALIDATORS = [
        { id: "readonly", label: "Readonly" },
        { id: "required", label: "Required" },
        { id: "non-negative", label: "Non-negative" },
        { id: "email", label: "Valid Email" },
        { id: "phone", label: "Valid Phone" },
        { id: "url", label: "Valid URL" },
        { id: "min-length", label: "Min Length" },
        { id: "max-length", label: "Max Length" }
    ];

    // Hilfsfunktionen
    const sortedFields = () => [...state.fields].sort((a, b) => a.order - b.order);

    const evaluateVisibility = (field) => {
        if (!field.visibleWhen) return true;
        
        const sourceField = state.fields.find(f => f.key === field.visibleWhen.field);
        if (!sourceField) return true;
        
        switch (field.visibleWhen.operator) {
            case "equals":
                return sourceField.value === field.visibleWhen.value;
            case "notEquals":
                return sourceField.value !== field.visibleWhen.value;
            case "contains":
                return String(sourceField.value).includes(field.visibleWhen.value);
            default:
                return true;
        }
    };

    const validateField = (field) => {
        if (!field.validators) return true;
        
        for (const validator of field.validators) {
            switch (validator) {
                case "non-negative":
                    if (field.type === "number" && field.value < 0) return false;
                    break;
                case "email":
                    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
                    if (field.type === "text" && field.value && !emailRegex.test(field.value)) return false;
                    break;
                case "phone":
                    const phoneRegex = /^[\d\s\-\+\(\)]+$/;
                    if (field.type === "text" && field.value && !phoneRegex.test(field.value)) return false;
                    break;
                case "url":
                    try {
                        if (field.value) new URL(field.value);
                    } catch {
                        return false;
                    }
                    break;
            }
        }
        return true;
    };

    const getFieldHierarchy = (key) => {
        const parts = key.split('.');
        if (parts[0] === 'user' && parts.length > 2) {
            return parts.slice(1, -1).join(' › ') + ' › ';
        }
        return '';
    };

    const getFieldName = (key) => {
        const parts = key.split('.');
        return parts[parts.length - 1];
    };

    const calculateHours = () => {
        const beginnField = state.fields.find(f => f.key === 'system.beginn');
        const endeField = state.fields.find(f => f.key === 'system.ende');
        if (beginnField?.value && endeField?.value) {
            const beginn = new Date(beginnField.value);
            const ende = new Date(endeField.value);
            const diff = (ende - beginn) / (1000 * 60 * 60);
            return Math.max(0, Number(diff.toFixed(2)));
        }
        return 0;
    };

    const calculateSessionCount = () => {
        const stundenField = state.fields.find(f => f.key === 'system.stunden');
        const sitzungslaengeField = state.fields.find(f => f.key === 'system.sitzungslaenge');
        
        if (stundenField?.value && sitzungslaengeField?.value && sitzungslaengeField.value > 0) {
            const totalMinutes = stundenField.value * 60;
            const sessionLength = sitzungslaengeField.value;
            const sessions = Math.floor(totalMinutes / sessionLength);
            return Math.max(0, sessions);
        }
        return 0;
    };

    const updateFormTitle = () => {
        const klientField = state.fields.find(f => f.key === 'system.klient');
        if (klientField?.value) {
            setState("formTitle", `LEISTUNG FÜR ${klientField.value.toUpperCase()}`);
            const headerTitle = document.querySelector('.form-header h1');
            if (headerTitle) {
                headerTitle.textContent = state.formTitle;
            }
        }
    };

    const updateFieldValue = (key, value) => {
        const fieldIndex = state.fields.findIndex(f => f.key === key);
        if (fieldIndex !== -1) {
            setState("fields", fieldIndex, "value", value);
            
            console.log('Field updated:', {
                key,
                value,
                fullState: JSON.parse(JSON.stringify(state))
            });
            
            // Abhängigkeit: Stunden neu berechnen
            if (key === 'system.beginn' || key === 'system.ende') {
                const stundenIndex = state.fields.findIndex(f => f.key === 'system.stunden');
                if (stundenIndex !== -1) {
                    const newHours = calculateHours();
                    setState("fields", stundenIndex, "value", newHours);
                    
                    // Auch Anzahl Sitzungen neu berechnen
                    const anzahlIndex = state.fields.findIndex(f => f.key === 'system.anzahl_sitzungen');
                    if (anzahlIndex !== -1) {
                        setState("fields", anzahlIndex, "value", calculateSessionCount());
                    }
                }
            }
            
            // Abhängigkeit: Anzahl Sitzungen neu berechnen wenn Sitzungslänge sich ändert
            if (key === 'system.sitzungslaenge') {
                const anzahlIndex = state.fields.findIndex(f => f.key === 'system.anzahl_sitzungen');
                if (anzahlIndex !== -1) {
                    const newCount = calculateSessionCount();
                    setState("fields", anzahlIndex, "value", newCount);
                    console.log('Session count recalculated:', {
                        newSessionLength: value,
                        totalHours: state.fields.find(f => f.key === 'system.stunden')?.value,
                        newSessionCount: newCount
                    });
                }
            }
            
            // Formular-Titel aktualisieren wenn Klient sich ändert
            if (key === 'system.klient') {
                updateFormTitle();
            }
            
            // Visibility Dependencies neu evaluieren
            state.fields.forEach((field, idx) => {
                if (field.visibleWhen && field.visibleWhen.field === key) {
                    const newVisibility = evaluateVisibility(field);
                    setState("fields", idx, "visible", newVisibility);
                    console.log('Visibility dependency triggered:', {
                        targetField: field.key,
                        sourceField: key,
                        sourceValue: value,
                        newVisibility,
                        condition: field.visibleWhen
                    });
                }
            });
        }
    };

    const handleDragStart = (index) => {
        setDraggedIndex(index);
        // Add dragging class to all rows
        document.querySelectorAll('.field-row').forEach((row, idx) => {
            if (idx === index) {
                row.classList.add('dragging');
            }
        });
    };

    const handleDragOver = (index) => {
        const dragIndex = draggedIndex();
        if (dragIndex === null || dragIndex === index) return;
        
        // Remove drag-over classes from all rows
        document.querySelectorAll('.field-row').forEach(row => {
            row.classList.remove('drag-over', 'drag-over-nested');
        });
        
        // Add drag-over class to target row
        const rows = document.querySelectorAll('.field-row');
        if (rows[index]) {
            rows[index].classList.add('drag-over');
        }
    };

    const handleDragOverNested = (index, event) => {
        event.preventDefault();
        event.stopPropagation();
        
        const dragIndex = draggedIndex();
        if (dragIndex === null || dragIndex === index) return;
        
        // Remove drag-over classes from all rows
        document.querySelectorAll('.field-row').forEach(row => {
            row.classList.remove('drag-over', 'drag-over-nested');
        });
        
        // Add nested drag-over class to target row
        const rows = document.querySelectorAll('.field-row');
        if (rows[index]) {
            rows[index].classList.add('drag-over-nested');
        }
    };

    const handleDrop = (dropIndex, isNested = false) => {
        const dragIndex = draggedIndex();
        if (dragIndex === null || dragIndex === dropIndex) {
            cleanupDragClasses();
            return;
        }
        
        const fields = sortedFields();
        const draggedField = fields[dragIndex];
        const droppedField = fields[dropIndex];
        
        // Get all fields that depend on the dragged field
        const getDependentFields = (parentKey) => {
            return state.fields.filter(f => f.visibleWhen && f.visibleWhen.field === parentKey);
        };
        
        const draggedDependents = getDependentFields(draggedField.key);
        
        // Calculate new order values
        const dragFieldIndex = state.fields.findIndex(f => f.key === draggedField.key);
        const dropFieldIndex = state.fields.findIndex(f => f.key === droppedField.key);
        
        if (isNested) {
            // Set parent relationship
            setState("fields", dragFieldIndex, "parentField", droppedField.key);
            setState("fields", dragFieldIndex, "order", droppedField.order + 0.5);
            
            console.log('Field nested under parent:', {
                draggedKey: draggedField.key,
                parentKey: droppedField.key
            });
        } else {
            // Swap order values (normal drop)
            const tempOrder = draggedField.order;
            setState("fields", dragFieldIndex, "order", droppedField.order);
            setState("fields", dropFieldIndex, "order", tempOrder);
            
            // Clear parent relationship if dropping at root level
            setState("fields", dragFieldIndex, "parentField", undefined);
        }
        
        // Move dependents to follow parent
        if (draggedDependents.length > 0) {
            const newParentOrder = isNested ? droppedField.order + 0.5 : droppedField.order;
            draggedDependents.forEach((dependent, idx) => {
                const depIndex = state.fields.findIndex(f => f.key === dependent.key);
                setState("fields", depIndex, "order", newParentOrder + 0.1 * (idx + 1));
            });
            
            // Renormalize all orders to be integers
            const sorted = sortedFields();
            sorted.forEach((field, idx) => {
                const fieldIndex = state.fields.findIndex(f => f.key === field.key);
                setState("fields", fieldIndex, "order", idx);
            });
        }
        
        console.log('Field reordered:', {
            draggedKey: draggedField.key,
            droppedKey: droppedField.key,
            isNested,
            dependents: draggedDependents.map(d => d.key)
        });
        
        setDraggedIndex(null);
        cleanupDragClasses();
    };

    const handleDragEnd = () => {
        cleanupDragClasses();
        setDraggedIndex(null);
    };

    const cleanupDragClasses = () => {
        document.querySelectorAll('.field-row').forEach(row => {
            row.classList.remove('dragging', 'drag-over', 'drag-over-nested');
        });
    };

    const deleteField = (key) => {
        const field = state.fields.find(f => f.key === key);
        if (field && !field.isSystem) {
            setState("fields", state.fields.filter(f => f.key !== key));
        }
    };

    // Generate key from label (e.g. "Mein Feld" -> "mein_feld")
    const generateKeyFromLabel = (label) => {
        return label
            .toLowerCase()
            .replace(/ä/g, 'ae')
            .replace(/ö/g, 'oe')
            .replace(/ü/g, 'ue')
            .replace(/ß/g, 'ss')
            .replace(/[^a-z0-9]+/g, '_')
            .replace(/^_+|_+$/g, '');
    };

    // Generate UUID v4
    const generateUUID = () => {
        return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
            const r = Math.random() * 16 | 0;
            const v = c === 'x' ? r : (r & 0x3 | 0x8);
            return v.toString(16);
        });
    };

    const addCustomField = () => {
        const label = newFieldLabel().trim();
        const type = newFieldType();
        const defaultValue = newFieldDefault();
        
        if (!label) {
            alert("Bitte Feldname ausfüllen");
            return;
        }
        
        // Generate key from label
        const generatedKey = generateKeyFromLabel(label);
        
        // Build full key (no parent hierarchy from dropdown anymore)
        const fullKey = `user.${generatedKey}`;
        
        if (state.fields.find(f => f.key === fullKey)) {
            alert("Ein Feld mit diesem Key existiert bereits");
            return;
        }
        
        // Get default value based on type
        let finalValue;
        if (type === "bool") {
            finalValue = defaultValue === "true" || defaultValue === true;
        } else if (type === "number") {
            finalValue = parseFloat(defaultValue) || 0;
        } else if (type === "datetime-local") {
            // Default to now
            const now = new Date();
            const year = now.getFullYear();
            const month = String(now.getMonth() + 1).padStart(2, '0');
            const day = String(now.getDate()).padStart(2, '0');
            const hours = String(now.getHours()).padStart(2, '0');
            const minutes = String(now.getMinutes()).padStart(2, '0');
            finalValue = `${year}-${month}-${day}T${hours}:${minutes}`;
        } else if (type === "daterange") {
            const now = new Date();
            const year = now.getFullYear();
            const month = String(now.getMonth() + 1).padStart(2, '0');
            const day = String(now.getDate()).padStart(2, '0');
            const hours = String(now.getHours()).padStart(2, '0');
            const minutes = String(now.getMinutes()).padStart(2, '0');
            const nowStr = `${year}-${month}-${day}T${hours}:${minutes}`;
            finalValue = { start: nowStr, end: nowStr };
        } else if (type === "select") {
            // For select, get first enum value ID
            const firstEnum = enumValues()[0];
            finalValue = firstEnum?.id || "";
        } else {
            finalValue = defaultValue || "";
        }
        
        const newField = {
            key: fullKey,
            type,
            label,
            value: finalValue,
            valueType: type === "select" ? "constant" : "raw",
            constantKey: type === "select" ? fullKey : undefined,
            required: selectedValidators().includes("required"),
            readonly: selectedValidators().includes("readonly"),
            visible: true,
            isSystem: false,
            order: state.fields.length,
            options: type === "select" ? enumValues().map(e => e.id) : undefined,
            validators: selectedValidators().filter(v => v !== "readonly" && v !== "required")
        };
        
        // If select type, add constants
        if (type === "select") {
            const constantsArray = enumValues().map(ev => ({
                id: ev.id,
                displayName: ev.displayName,
                indent: ev.indent
            }));
            setState("constants", fullKey, constantsArray);
        }
        
        setState("fields", [...state.fields, newField]);
        
        console.log('Custom field added:', newField);
        
        // Reset form
        setNewFieldLabel("");
        setNewFieldType("text");
        setNewFieldDefault("");
        setParentField("");
        setSelectedValidators([]);
        setEnumValues([{ id: "", displayName: "", indent: 0 }]);
        setShowAdvanced(false);
        
        // Trigger UI update
        renderAddFieldForm();
    };

    // DOM Rendering Funktionen
    function createFieldInput(field) {
        const container = document.createElement('div');
        container.className = 'field-input';
        
        const isValid = validateField(field);
        
        let input;
        
        switch (field.type) {
            case "text":
                input = document.createElement('input');
                input.type = 'text';
                input.value = field.value || '';
                input.readOnly = field.readonly;
                if (!isValid) input.classList.add('invalid');
                input.addEventListener('change', (e) => {
                    updateFieldValue(field.key, e.target.value);
                });
                break;
                
            case "number":
                input = document.createElement('input');
                input.type = 'number';
                input.value = field.value || 0;
                input.readOnly = field.readonly;
                input.step = '0.01';
                if (!isValid) input.classList.add('invalid');
                input.addEventListener('change', (e) => {
                    updateFieldValue(field.key, parseFloat(e.target.value) || 0);
                });
                break;
                
            case "datetime-local":
                input = document.createElement('input');
                input.type = 'datetime-local';
                input.value = field.value || '';
                input.readOnly = field.readonly;
                input.addEventListener('change', (e) => {
                    updateFieldValue(field.key, e.target.value);
                });
                break;
                
            case "daterange":
                // Create a wrapper for two datetime inputs side by side
                const rangeWrapper = document.createElement('div');
                rangeWrapper.style.display = 'grid';
                rangeWrapper.style.gridTemplateColumns = '1fr 1fr';
                rangeWrapper.style.gap = '8px';
                
                const startInput = document.createElement('input');
                startInput.type = 'datetime-local';
                startInput.value = field.value?.start || '';
                startInput.readOnly = field.readonly;
                startInput.placeholder = 'Von';
                startInput.addEventListener('change', (e) => {
                    const newValue = { ...(field.value || {}), start: e.target.value };
                    updateFieldValue(field.key, newValue);
                });
                
                const endInput = document.createElement('input');
                endInput.type = 'datetime-local';
                endInput.value = field.value?.end || '';
                endInput.readOnly = field.readonly;
                endInput.placeholder = 'Bis';
                endInput.addEventListener('change', (e) => {
                    const newValue = { ...(field.value || {}), end: e.target.value };
                    updateFieldValue(field.key, newValue);
                });
                
                rangeWrapper.appendChild(startInput);
                rangeWrapper.appendChild(endInput);
                container.appendChild(rangeWrapper);
                return container;
                
            case "select":
                input = document.createElement('select');
                input.disabled = field.readonly;
                const emptyOption = document.createElement('option');
                emptyOption.value = '';
                input.appendChild(emptyOption);
                (field.options || []).forEach(opt => {
                    const option = document.createElement('option');
                    option.value = opt;
                    
                    // If this field uses constants, look up the display name
                    if (field.valueType === "constant" && field.constantKey && state.constants[field.constantKey]) {
                        const constantEntry = state.constants[field.constantKey].find(c => c.id === opt);
                        option.textContent = constantEntry ? constantEntry.displayName : opt;
                    } else {
                        option.textContent = opt;
                    }
                    
                    if (opt === field.value) option.selected = true;
                    input.appendChild(option);
                });
                input.addEventListener('change', (e) => updateFieldValue(field.key, e.target.value));
                break;
                
            case "bool":
                input = document.createElement('div');
                input.className = 'toggle-switch' + (field.value ? ' active' : '');
                if (!field.readonly) {
                    input.addEventListener('click', () => updateFieldValue(field.key, !field.value));
                }
                container.appendChild(input);
                return container;
                
            case "textarea":
                input = document.createElement('textarea');
                input.value = field.value || '';
                input.readOnly = field.readonly;
                input.rows = 3;
                input.addEventListener('change', (e) => {
                    updateFieldValue(field.key, e.target.value);
                });
                break;
        }
        
        if (input) container.appendChild(input);
        return container;
    }

    function createFieldRow(field, index) {
        const row = document.createElement('div');
        row.className = 'field-row';
        
        // Add dependent class if field has visibleWhen dependency or parentField
        if (field.visibleWhen || field.parentField) {
            row.classList.add('dependent');
        }
        
        row.draggable = true;
        row.addEventListener('dragstart', () => handleDragStart(index));
        
        // Normal drop area (left 70%)
        row.addEventListener('dragover', (e) => {
            e.preventDefault();
            const rect = row.getBoundingClientRect();
            const mouseX = e.clientX - rect.left;
            const isRightSide = mouseX > rect.width * 0.7;
            
            if (isRightSide) {
                handleDragOverNested(index, e);
            } else {
                handleDragOver(index);
            }
        });
        
        row.addEventListener('drop', (e) => {
            e.preventDefault();
            const rect = row.getBoundingClientRect();
            const mouseX = e.clientX - rect.left;
            const isRightSide = mouseX > rect.width * 0.7;
            
            handleDrop(index, isRightSide);
        });
        
        row.addEventListener('dragend', handleDragEnd);
        
        // Drag Handle
        const handle = document.createElement('div');
        handle.className = 'drag-handle';
        handle.textContent = '⋮⋮';
        
        // Label
        const label = document.createElement('div');
        label.className = 'field-label' + (field.required ? ' required' : '');
        
        const hierarchy = getFieldHierarchy(field.key);
        if (hierarchy) {
            const hierarchySpan = document.createElement('span');
            hierarchySpan.className = 'hierarchy';
            hierarchySpan.textContent = hierarchy;
            label.appendChild(hierarchySpan);
        }
        
        const labelText = document.createTextNode(field.label);
        label.appendChild(labelText);
        
        if (!field.isSystem) {
            const badge = document.createElement('span');
            badge.className = 'custom-badge';
            badge.textContent = 'CUSTOM';
            label.appendChild(badge);
        }
        
        // Input Wrapper
        const wrapper = document.createElement('div');
        wrapper.className = 'field-input-wrapper';
        wrapper.appendChild(createFieldInput(field));
        
        // Clear Button
        if (!field.readonly && !field.required && field.value) {
            const clearBtn = document.createElement('button');
            clearBtn.className = 'clear-btn';
            clearBtn.textContent = '✕';
            clearBtn.title = 'Leeren';
            clearBtn.addEventListener('click', () => updateFieldValue(field.key, field.type === "bool" ? false : ""));
            wrapper.appendChild(clearBtn);
        }
        
        // Delete Button (nur für Custom Fields)
        if (!field.isSystem) {
            const deleteBtn = document.createElement('button');
            deleteBtn.className = 'clear-btn';
            deleteBtn.textContent = '🗑';
            deleteBtn.title = 'Feld löschen';
            deleteBtn.addEventListener('click', () => deleteField(field.key));
            wrapper.appendChild(deleteBtn);
        }
        
        row.appendChild(handle);
        row.appendChild(label);
        row.appendChild(wrapper);
        
        return row;
    }

    function renderFields() {
        const formBody = document.querySelector('.form-body');
        if (!formBody) return;
        
        formBody.innerHTML = '';
        
        sortedFields().forEach((field, index) => {
            if (field.visible) {
                formBody.appendChild(createFieldRow(field, index));
            }
        });
    }

    function renderJSON() {
        const jsonOutput = document.getElementById('json-output');
        if (!jsonOutput) return;
        
        if (showJSON()) {
            jsonOutput.style.display = 'block';
            jsonOutput.textContent = JSON.stringify(state, null, 2);
        } else {
            jsonOutput.style.display = 'none';
        }
    }

    // Initial Render
    const app = document.getElementById('app');
    app.innerHTML = `
        <div class="app-container">
            <div class="form-card">
                <div class="form-header">
                    <h1>${state.formTitle}</h1>
                    <div class="header-actions">
                        <button class="icon-btn" title="Hilfe">?</button>
                        <button class="icon-btn" title="Löschen">🗑</button>
                        <button class="icon-btn" title="Schließen">✕</button>
                    </div>
                </div>
                <div class="form-body"></div>
                <div class="section-title">Benutzerdefinierte Felder hinzufügen</div>
                <div class="add-field-section"></div>
                <div class="form-footer">
                    <button class="btn btn-primary">✓ SPEICHERN</button>
                </div>
            </div>
            <div class="json-panel">
                <div class="json-header">
                    <span>Store JSON</span>
                    <button class="json-toggle-btn" id="toggleJsonBtn">Ausblenden</button>
                </div>
                <pre class="json-output" id="json-output"></pre>
            </div>
        </div>
    `;

    // Event Listeners
    document.getElementById('toggleJsonBtn').addEventListener('click', () => {
        setShowJSON(!showJSON());
        const btn = document.getElementById('toggleJsonBtn');
        const output = document.getElementById('json-output');
        if (showJSON()) {
            btn.textContent = 'Ausblenden';
            output.style.display = 'block';
        } else {
            btn.textContent = 'Einblenden';
            output.style.display = 'none';
        }
    });

    // Render Validators
    function renderValidators() {
        const container = document.getElementById('validatorTags');
        if (!container) return;
        
        container.innerHTML = '';
        VALIDATORS.forEach(validator => {
            const tag = document.createElement('span');
            tag.className = 'validator-tag';
            tag.textContent = validator.label;
            if (selectedValidators().includes(validator.id)) {
                tag.classList.add('selected');
            }
            tag.addEventListener('click', () => {
                const current = selectedValidators();
                if (current.includes(validator.id)) {
                    setSelectedValidators(current.filter(v => v !== validator.id));
                } else {
                    setSelectedValidators([...current, validator.id]);
                }
                renderValidators();
            });
            container.appendChild(tag);
        });
    }

    // Render Enum Values with UUID and indentation
    function renderEnumValues() {
        const container = document.getElementById('enumValues');
        if (!container) return;
        
        container.innerHTML = '';
        
        enumValues().forEach((enumVal, idx) => {
            const row = document.createElement('div');
            row.className = 'enum-value-row';
            row.style.marginLeft = `${enumVal.indent * 20}px`;
            
            // Indent controls
            const indentControls = document.createElement('div');
            indentControls.className = 'indent-controls';
            
            const decreaseIndent = document.createElement('button');
            decreaseIndent.className = 'indent-btn';
            decreaseIndent.textContent = '←';
            decreaseIndent.disabled = enumVal.indent === 0;
            decreaseIndent.title = 'Einrückung verringern';
            decreaseIndent.addEventListener('click', () => {
                const vals = [...enumValues()];
                vals[idx].indent = Math.max(0, vals[idx].indent - 1);
                setEnumValues(vals);
                renderEnumValues();
            });
            
            const increaseIndent = document.createElement('button');
            increaseIndent.className = 'indent-btn';
            increaseIndent.textContent = '→';
            increaseIndent.disabled = enumVal.indent >= 3;
            increaseIndent.title = 'Einrückung erhöhen';
            increaseIndent.addEventListener('click', () => {
                const vals = [...enumValues()];
                vals[idx].indent = Math.min(3, vals[idx].indent + 1);
                setEnumValues(vals);
                renderEnumValues();
            });
            
            const indentLevel = document.createElement('div');
            indentLevel.className = 'indent-indicator';
            indentLevel.textContent = enumVal.indent;
            
            indentControls.appendChild(decreaseIndent);
            indentControls.appendChild(indentLevel);
            indentControls.appendChild(increaseIndent);
            
            // Key input
            const keyInput = document.createElement('input');
            keyInput.type = 'text';
            keyInput.value = enumVal.id;
            keyInput.placeholder = 'UUID/Key';
            keyInput.addEventListener('input', (e) => {
                const vals = [...enumValues()];
                vals[idx].id = e.target.value;
                setEnumValues(vals);
            });
            
            // Generate UUID button
            const uuidBtn = document.createElement('button');
            uuidBtn.className = 'small-btn';
            uuidBtn.textContent = '🔑';
            uuidBtn.title = 'UUID generieren';
            uuidBtn.addEventListener('click', () => {
                const vals = [...enumValues()];
                vals[idx].id = generateUUID();
                setEnumValues(vals);
                renderEnumValues();
            });
            keyInput.style.display = 'flex';
            keyInput.style.gap = '4px';
            
            const keyWrapper = document.createElement('div');
            keyWrapper.style.display = 'flex';
            keyWrapper.style.gap = '4px';
            keyWrapper.appendChild(keyInput);
            keyWrapper.appendChild(uuidBtn);
            
            // Display name input
            const nameInput = document.createElement('input');
            nameInput.type = 'text';
            nameInput.value = enumVal.displayName;
            nameInput.placeholder = 'Anzeigename';
            nameInput.addEventListener('input', (e) => {
                const vals = [...enumValues()];
                vals[idx].displayName = e.target.value;
                // Auto-generate key if empty
                if (!vals[idx].id) {
                    vals[idx].id = generateKeyFromLabel(e.target.value);
                }
                setEnumValues(vals);
            });
            
            // Remove button
            const removeBtn = document.createElement('button');
            removeBtn.className = 'small-btn';
            removeBtn.textContent = '✕';
            removeBtn.title = 'Entfernen';
            removeBtn.addEventListener('click', () => {
                setEnumValues(enumValues().filter((_, i) => i !== idx));
                renderEnumValues();
            });
            
            row.appendChild(indentControls);
            row.appendChild(keyWrapper);
            row.appendChild(nameInput);
            if (enumValues().length > 1) {
                row.appendChild(removeBtn);
            } else {
                row.appendChild(document.createElement('div')); // Spacer
            }
            
            container.appendChild(row);
        });
        
        // Add button
        const addBtn = document.createElement('button');
        addBtn.className = 'btn btn-secondary';
        addBtn.style.marginTop = '8px';
        addBtn.textContent = '+ Wert hinzufügen';
        addBtn.addEventListener('click', () => {
            setEnumValues([...enumValues(), { id: "", displayName: "", indent: 0 }]);
            renderEnumValues();
        });
        container.appendChild(addBtn);
    }

    // Render Add Field Form
    function renderAddFieldForm() {
        const section = document.querySelector('.add-field-section');
        if (!section) return;
        
        section.innerHTML = `
            <div class="add-field-form">
                <div class="form-group">
                    <label>Feldname</label>
                    <input id="newFieldLabel" type="text" placeholder="z.B. Anfahrtszeit" />
                </div>
                
                <div class="form-group">
                    <label>Feldtyp</label>
                    <select id="newFieldType">
                        <option value="text">Text</option>
                        <option value="number">Zahl</option>
                        <option value="bool">Boolean</option>
                        <option value="select">Dropdown</option>
                        <option value="textarea">Textarea</option>
                        <option value="datetime-local">Datum/Zeit</option>
                        <option value="daterange">Datums-Bereich</option>
                    </select>
                </div>
                
                <div class="form-group" id="defaultValueGroup">
                    <label>Standardwert</label>
                    <input id="newFieldDefault" type="text" placeholder="Standard..." />
                </div>
                
                <div class="form-group" id="defaultBoolGroup" style="display: none;">
                    <label>Standardwert</label>
                    <div class="toggle-switch" id="newFieldBoolDefault"></div>
                </div>
                
                <button class="advanced-toggle" id="advancedToggle">
                    Erweiterte Optionen
                </button>
                
                <div class="advanced-config" id="advancedConfig">
                    <div class="form-group">
                        <label>Validatoren</label>
                        <div class="validator-tags" id="validatorTags"></div>
                    </div>
                    
                    <div class="form-group" id="enumValuesGroup" style="display: none;">
                        <label>Dropdown-Werte (mit UUID & Einrückung)</label>
                        <div id="enumValues"></div>
                    </div>
                </div>
                
                <div class="form-actions">
                    <button class="btn btn-secondary" id="cancelFieldBtn">Abbrechen</button>
                    <button class="btn btn-primary" id="addFieldBtn">+ Feld hinzufügen</button>
                </div>
            </div>
        `;
        
        // Wire up event listeners
        const labelInput = document.getElementById('newFieldLabel');
        labelInput.value = newFieldLabel();
        labelInput.addEventListener('input', (e) => setNewFieldLabel(e.target.value));
        
        const typeSelect = document.getElementById('newFieldType');
        typeSelect.value = newFieldType();
        typeSelect.addEventListener('change', (e) => {
            setNewFieldType(e.target.value);
            updateDefaultValueVisibility();
            renderAddFieldForm();
        });
        
        const defaultInput = document.getElementById('newFieldDefault');
        if (defaultInput) {
            defaultInput.value = newFieldDefault();
            defaultInput.addEventListener('input', (e) => setNewFieldDefault(e.target.value));
        }
        
        // Boolean toggle
        const boolToggle = document.getElementById('newFieldBoolDefault');
        if (boolToggle) {
            const currentBoolValue = newFieldDefault() === "true" || newFieldDefault() === true;
            if (currentBoolValue) {
                boolToggle.classList.add('active');
            }
            boolToggle.addEventListener('click', () => {
                const newValue = !boolToggle.classList.contains('active');
                setNewFieldDefault(newValue.toString());
                boolToggle.classList.toggle('active');
            });
        }
        
        // Advanced toggle
        const advancedToggle = document.getElementById('advancedToggle');
        const advancedConfig = document.getElementById('advancedConfig');
        if (showAdvanced()) {
            advancedConfig.classList.add('visible');
            advancedToggle.classList.add('expanded');
        }
        advancedToggle.addEventListener('click', () => {
            setShowAdvanced(!showAdvanced());
            advancedConfig.classList.toggle('visible');
            advancedToggle.classList.toggle('expanded');
        });
        
        // Buttons
        document.getElementById('addFieldBtn').addEventListener('click', addCustomField);
        document.getElementById('cancelFieldBtn').addEventListener('click', () => {
            setNewFieldLabel("");
            setNewFieldType("text");
            setNewFieldDefault("");
            setParentField("");
            setSelectedValidators([]);
            setEnumValues([{ id: "", displayName: "", indent: 0 }]);
            setShowAdvanced(false);
            renderAddFieldForm();
        });
        
        // Render sub-components
        updateDefaultValueVisibility();
        renderValidators();
        
        // Show enum values if type is select
        if (newFieldType() === 'select') {
            document.getElementById('enumValuesGroup').style.display = 'flex';
            renderEnumValues();
        }
    }
    
    function updateDefaultValueVisibility() {
        const type = newFieldType();
        const defaultGroup = document.getElementById('defaultValueGroup');
        const boolGroup = document.getElementById('defaultBoolGroup');
        if (!defaultGroup || !boolGroup) return;
        
        // Hide default value for datetime/daterange (defaults to "now")
        if (type === 'datetime-local' || type === 'daterange') {
            defaultGroup.style.display = 'none';
            boolGroup.style.display = 'none';
        } else if (type === 'select') {
            defaultGroup.style.display = 'none'; // Handled by enum values
            boolGroup.style.display = 'none';
        } else if (type === 'bool') {
            defaultGroup.style.display = 'none';
            boolGroup.style.display = 'flex';
        } else {
            defaultGroup.style.display = 'flex';
            boolGroup.style.display = 'none';
        }
    }

    // Update Parent Field Selector
    function updateParentFieldSelector() {
        // No longer needed - hierarchy is done via drag & drop
    }

    // Event Listeners
    document.getElementById('toggleJsonBtn').addEventListener('click', () => {
        setShowJSON(!showJSON());
        const btn = document.getElementById('toggleJsonBtn');
        const output = document.getElementById('json-output');
        if (showJSON()) {
            btn.textContent = 'Ausblenden';
            output.style.display = 'block';
        } else {
            btn.textContent = 'Einblenden';
            output.style.display = 'none';
        }
    });

    // Initial render
    renderAddFieldForm();

    // SolidJS Effects - Auto Re-render bei State-Änderungen
    createEffect(() => {
        renderFields();
    });

    createEffect(() => {
        renderJSON();
    });

    // Initial Render
    renderFields();
</script>

Analyze the structure of this SolidJS application and what could be improved.

@fschutt
Copy link
Author

fschutt commented Nov 12, 2025

Of course. This is an excellent example of a SolidJS application written in an imperative "vanilla JS" style, likely to avoid a build step. While functional, it forgoes many of the benefits that make SolidJS powerful and elegant.

Let's break down the analysis and then outline a refactoring path using JSX and solid-dnd.

Analysis of the Current Structure

This application is a well-structured, single-file implementation that cleverly uses Solid's reactivity system for state management while handling rendering manually.

What it does well:

  1. State Management: The use of createStore for the main nested state object (INITIAL_STATE) is perfect. It's designed for exactly this kind of data structure where individual properties within objects need to be reactive. createSignal is correctly used for simple, top-level state like UI toggles (showJSON) and transient state (draggedIndex).
  2. Logic Separation: Business logic functions like calculateHours, evaluateVisibility, and validateField are cleanly separated from the rendering and state update logic.
  3. Reactivity: The core reactive loop is established with createEffect. Any change to the state store correctly triggers a re-render of the fields and the JSON output. The updateFieldValue function correctly centralizes state changes.

What could be improved (The "Anti-Patterns"):

  1. Imperative DOM Manipulation: The biggest issue is the manual creation and manipulation of the DOM. Functions like renderFields, createFieldRow, and createFieldInput use document.createElement, appendChild, and innerHTML. This is the exact pattern that frameworks like SolidJS are designed to eliminate.
    • Why it's a problem: It's verbose, error-prone, less readable, and bypasses Solid's highly optimized, fine-grained rendering engine. The current renderFields function completely rebuilds the form body on every single change, which is inefficient.
  2. Manual Drag and Drop: The drag-and-drop implementation is complex. It relies on manually setting signals (draggedIndex), adding/removing CSS classes (.dragging, .drag-over), and handling multiple DOM events (dragstart, dragover, drop, dragend). This is brittle and hard to maintain.
  3. Monolithic Structure: All the code—state, logic, and rendering for different parts of the UI (the form, the "add field" section, the JSON viewer)—is in one giant script block. This becomes difficult to navigate and maintain as the application grows.
  4. Side Effects in State Updaters: The updateFieldValue function contains a lot of chained logic (if you update beginn, also update stunden, and then also update anzahl_sitzungen). While it works, Solid provides a more declarative and elegant way to handle this using derived signals (createMemo).

Refactoring with JSX and solid-dnd

The goal of the refactor is to move from an imperative style to a declarative one. You describe what the UI should look like based on the state, and Solid figures out how to update it efficiently.

This requires a build step (like Vite) to compile JSX. The standard way to start a Solid project is:
npm create vite@latest my-erp-form -- --template solid-ts

Step 1: Componentization

First, break the monolithic script into logical components. Your file structure might look like this:

src/
├── components/
│   ├── FormField.tsx       # Renders a single field row
│   ├── AddFieldForm.tsx    # The form for adding new fields
│   └── JsonViewer.tsx      # The JSON output panel
├── App.tsx                 # The main application component
├── store.ts                # State management logic
└── index.css               # Your styles

Step 2: Centralize State in store.ts

Move your state definition and update logic into store.ts. This makes it reusable and separates concerns.

src/store.ts

import { createStore, produce } from 'solid-js/store';
import { createSignal } from 'solid-js';

// ... (Your INITIAL_STATE object here) ...

export const [state, setState] = createStore(INITIAL_STATE);

// The update function becomes much simpler
export const updateFieldValue = (key: string, value: any) => {
  const fieldIndex = state.fields.findIndex(f => f.key === key);
  if (fieldIndex !== -1) {
    setState('fields', fieldIndex, 'value', value);
  }
};

// ... (addCustomField, deleteField logic would also go here) ...

Step 3: Use Derived Memos for Calculated Values

Instead of manually recalculating values inside updateFieldValue, use createMemo to define values that automatically update when their dependencies change. This is the "Solid" way.

Inside App.tsx or a custom hook:

import { createMemo } from 'solid-js';
import { state, setState } from './store';

// Memo for calculating hours
const hours = createMemo(() => {
    const beginnField = state.fields.find(f => f.key === 'system.beginn');
    const endeField = state.fields.find(f => f.key === 'system.ende');
    if (beginnField?.value && endeField?.value) {
        const diff = (new Date(endeField.value) - new Date(beginnField.value)) / 3600000;
        return Math.max(0, Number(diff.toFixed(2)));
    }
    return 0;
});

// An effect that listens to the memo and updates the store
createEffect(() => {
    const stundenIndex = state.fields.findIndex(f => f.key === 'system.stunden');
    if (stundenIndex !== -1) {
        setState('fields', stundenIndex, 'value', hours());
    }
});

// You would do the same for session count, visibility checks, etc.
```This decouples the calculation logic from the event that triggered the change.

#### Step 4: Refactor the Form with JSX and `solid-dnd`

This is the biggest change. We'll use Solid's control flow components (`<For>`) and the `solid-dnd` library.

First, install `solid-dnd`: `npm install @thisbeyond/solid-dnd`

**`src/App.tsx`**
```typescript
import { For } from 'solid-js';
import { state, setState } from './store';
import { FormField } from './components/FormField';
import {
  DragDropProvider,
  DragDropSensors,
  createSortable,
} from '@thisbeyond/solid-dnd';

export function App() {
  const sortedFields = () => [...state.fields].sort((a, b) => a.order - b.order);

  const onDragEnd = ({ draggable, droppable }) => {
    if (draggable && droppable) {
      const fromIndex = draggable.id;
      const toIndex = droppable.id;
      if (fromIndex === toIndex) return;
      
      const fields = sortedFields();
      const [reorderedItem] = fields.splice(fromIndex, 1);
      fields.splice(toIndex, 0, reorderedItem);

      // Re-assign order based on new array position
      const newOrderedFields = fields.map((field, index) => ({...field, order: index }));

      // Update the store by replacing the fields array
      setState('fields', newOrderedFields);
    }
  };

  return (
    <div class="app-container">
      <div class="form-card">
        {/* ... form header ... */}
        <div class="form-body">
          <DragDropProvider onDragEnd={onDragEnd}>
            <DragDropSensors />
            <For each={sortedFields()}>
              {(field, index) => {
                  // Only render visible fields
                  if (!evaluateVisibility(field)) return null; 
                  return <FormField field={field} index={index()} />;
              }}
            </For>
          </DragDropProvider>
        </div>
        {/* ... AddFieldForm and footer ... */}
      </div>
      {/* ... JsonViewer ... */}
    </div>
  );
}

src/components/FormField.tsx

import { createSortable } from '@thisbeyond/solid-dnd';
import { Switch, Match } from 'solid-js';
import { updateFieldValue } from '../store';

export function FormField(props) {
  const sortable = createSortable(props.index);

  return (
    <div
      use:sortable // This directive from solid-dnd handles all drag events
      class="field-row"
      classList={{ 'opacity-25': sortable.isActiveDraggable }}
    >
      <div class="drag-handle">⋮⋮</div>
      <div class="field-label">{props.field.label}</div>
      <div class="field-input-wrapper">
        {/* Use Switch for conditional rendering of input types */}
        <Switch fallback={<p>Unsupported field type</p>}>
          <Match when={props.field.type === 'text'}>
            <input
              type="text"
              value={props.field.value}
              readOnly={props.field.readonly}
              onInput={(e) => updateFieldValue(props.field.key, e.currentTarget.value)}
            />
          </Match>
          <Match when={props.field.type === 'number'}>
            <input
              type="number"
              value={props.field.value}
              readOnly={props.field.readonly}
              onInput={(e) => updateFieldValue(props.field.key, parseFloat(e.currentTarget.value))}
            />
          </Match>
          <Match when={props.field.type === 'select'}>
            <select
              disabled={props.field.readonly}
              onChange={(e) => updateFieldValue(props.field.key, e.currentTarget.value)}
            >
              <For each={props.field.options}>
                {(option) => (
                  <option value={option} selected={option === props.field.value}>
                    {/* Logic to get display name */}
                    {option} 
                  </option>
                )}
              </For>
            </select>
          </Match>
          {/* ... other field types ... */}
        </Switch>
      </div>
    </div>
  );
}

Summary of Benefits from Refactoring

  1. Readability & Maintainability: JSX makes the component's structure immediately obvious. Components are self-contained, making them easier to debug and reuse.
  2. Performance: You are now using Solid's highly optimized, fine-grained reactivity. When you type in a single text field, only that input's value updates in the DOM, not the entire form.
  3. Simplicity: The complex manual drag-and-drop logic is replaced by a few lines of configuration using solid-dnd. The library handles all the complex state and event management internally.
  4. Declarative Code: You no longer tell the application how to update the DOM. You just declare that a list of fields should be rendered, and the framework takes care of the rest, making your code more robust and easier to reason about.

@fschutt
Copy link
Author

fschutt commented Nov 12, 2025

Excellent questions. You're moving beyond building a single application and into designing a flexible, data-driven form engine. This requires a significant architectural shift from hard-coded logic to a declarative, serializable system. Let's think through this very carefully.

Part 1: Refactoring Validators (Stackable & Excludable)

The current implementation uses a simple array of strings (validators: ["non-negative"]). This is insufficient. We need a richer structure and a central registry to define the behavior of validators.

The New Validator Architecture

  1. Richer Validator Definition: In your fields configuration, validators should be an array of objects, allowing for options.

    {
      "key": "user.age",
      "type": "number",
      "label": "Age",
      "validators": [
        { "type": "required", "message": "Age is required." },
        { "type": "min", "value": 18, "message": "Must be at least 18." },
        { "type": "max", "value": 120, "message": "Age seems unlikely." }
      ]
    }
  2. The Validator Registry: This is the crucial part. Create a separate, non-state configuration object that defines every possible validator, its validation function, and its metadata, including what it excludes.

    src/config/validatorRegistry.ts

    // The function signature for any validator
    type ValidatorFn = (value: any, options?: any) => boolean;
    
    interface ValidatorDefinition {
      // The function that performs the validation
      validate: ValidatorFn;
      // An array of other validator 'type's that this one is incompatible with
      excludes?: string[];
      // Optional: defines if this validator requires an option (e.g., min-length needs a number)
      requiresOption?: "number" | "string" | "boolean";
    }
    
    export const validatorRegistry: Record<string, ValidatorDefinition> = {
      "required": {
        validate: (value) => value !== null && value !== undefined && value !== "",
      },
      "email": {
        validate: (value) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value),
        excludes: ["url", "phone", "number"], // An email can't also be a URL or a number
      },
      "url": {
        validate: (value) => { try { new URL(value); return true; } catch { return false; } },
        excludes: ["email", "phone", "number"],
      },
      "number": {
        validate: (value) => !isNaN(parseFloat(value)) && isFinite(value),
        excludes: ["email", "url", "phone"],
      },
      "min": {
        validate: (value, options) => parseFloat(value) >= options.value,
        requiresOption: "number",
      },
      "max": {
        validate: (value, options) => parseFloat(value) <= options.value,
        requiresOption: "number",
      }
    };

How it Works in the UI

When a user is adding/editing validators for a field in the form builder UI:

  1. You present the list of available validators from the keys of validatorRegistry.
  2. When the user selects a validator (e.g., email), you read its excludes array (["url", "phone", "number"]).
  3. You then disable the checkboxes/options for "URL", "Phone", and "Number" in the UI, making it impossible to select incompatible validators.
  4. The validation engine would iterate through the field's validators array, look up the validate function from the registry, and execute it.

Part 2: Architecture for Future Extensibility

This is the core of your request. You want a system where the form's structure, appearance, and logic are all defined by serializable data (JSON). This requires separating the Data (Schema), the Engine (Runtime), and the Renderer (UI).

The Core Principle: Configuration as Data

Everything that can be customized—layout, field types, dependencies, calculations—must be represented in your JSON state. The JavaScript/SolidJS code becomes a generic interpreter of that configuration.

A. The New, Unified State Schema

Your INITIAL_STATE is not structured for this level of extensibility. Let's propose a new schema.

{
  "formId": "leistung-erfassen-v2",
  "formTitle": "Leistung für {fields.system.klient.value}", // Templating for dynamic titles

  // 1. FIELD DEFINITIONS: A flat map/dictionary of all possible fields.
  "fields": {
    "system.mitarbeiter": { "type": "text", "label": "Mitarbeiter", "defaultValue": "Bredemeier, Syenja" },
    "system.klient": { "type": "text", "label": "Klient" },
    "system.beginn": { "type": "datetime-local", "label": "Beginn" },
    "system.ende": { "type": "datetime-local", "label": "Ende" },
    "system.stunden": { "type": "number", "label": "Stunden", "readonly": true },
    "internal.minutes_passed": { "type": "internal" } // An intermediary value, not rendered
  },

  // 2. LAYOUT: A tree structure defining hierarchy and order.
  "layout": [
    {
      "key": "system.mitarbeiter",
      "children": []
    },
    {
      "key": "system.klient",
      "children": []
    },
    {
      "key": "system.leistung",
      "children": [ // Child fields are nested
        {
          "key": "system.sitzungslaenge",
          "children": []
        },
        {
          "key": "system.anzahl_sitzungen",
          "children": []
        }
      ]
    }
  ],

  // 3. LOGIC & DEPENDENCIES: A graph of computations and rules.
  "logic": [
    {
      "output": "internal.minutes_passed", // The field to write to
      "operation": "date_diff", // A key for a registered function
      "options": { "unit": "minutes" },
      "inputs": { // The inputs for that function
        "start": "fields.system.beginn.value",
        "end": "fields.system.ende.value"
      }
    },
    {
      "output": "fields.system.stunden.value",
      "operation": "divide",
      "inputs": {
        "numerator": "fields.internal.minutes_passed.value",
        "denominator": 60
      }
    },
    {
      "output": "fields.system.sitzungslaenge.visible", // We can control ANY property
      "operation": "equals",
      "inputs": {
        "a": "fields.system.leistung.value",
        "b": "service_002"
      }
    }
  ]
}

Let's break down how this schema addresses your points.

B. User-Defined Form Inputs

This is solved by creating a Component Registry, similar to the validator registry.

src/components/componentRegistry.ts

import { lazy } from 'solid-js';

// A map from a field 'type' in the JSON to the actual Solid component.
export const componentRegistry = {
  "text": lazy(() => import('./inputs/TextInput')),
  "number": lazy(() => import('./inputs/NumberInput')),
  "datetime-local": lazy(() => import('./inputs/DateTimeInput')),
  // User-defined inputs are just another entry in the map!
  "color-picker": lazy(() => import('./custom/ColorPicker')),
  "rich-text": lazy(() => import('./custom/RichTextEditor')),
  // 'internal' type has no component and is never rendered.
  "internal": null
};

Your form renderer would recursively walk the layout tree. For each key, it looks up the type in the fields definition and then renders the corresponding component from the componentRegistry.

C. Serializable Layout & Hierarchy

The layout tree in the proposed schema directly solves this.

  • Hierarchy is explicit: [parent-id, child-id, ...] is represented by the nesting in the layout array.
  • Serialization is trivial: The entire structure is pure JSON.
  • Drag-and-Drop: Libraries like solid-dnd can be adapted to work with trees. A drop operation would mean moving a node from one children array to another.

D. Dependency Graph, Intermediaries, and Printable Dependencies

This is the most powerful part of the new architecture, handled by the logic block and an Execution Engine.

  1. The Logic Block: The logic array is a declarative definition of your application's logic as a Directed Acyclic Graph (DAG). Each object in the array is a node in the graph.

    • output: Where the result of the computation is written. It can target a field's value, its visible property, its readonly state, or anything else.
    • operation: The function to execute (e.g., date_diff, equals, sum, lookup). These are mapped to actual JS functions in an operationRegistry.
    • inputs: The values this operation depends on. These can be static values (like 60) or pointers to other field properties.
  2. The Execution Engine (The "Heart" of the Form):
    This is a function or custom hook that you'd write. It takes the logic configuration and brings it to life using Solid's primitives.

    Conceptual useFormEngine hook:

    function useFormEngine(config) {
      const [state, setState] = createStore(config);
    
      // 1. Create a reactive signal for every field property mentioned in the logic graph.
      // This is a complex step involving parsing the 'inputs' and 'outputs'.
    
      // 2. For each node in state.logic, create a reactive computation.
      for (const rule of state.logic) {
        // Create a memo that runs the operation whenever its inputs change
        const resultMemo = createMemo(() => {
          const resolvedInputs = resolveInputs(rule.inputs, state); // Helper to get values
          const operationFn = operationRegistry[rule.operation]; // Look up the function
          return operationFn(...resolvedInputs);
        });
    
        // Create an effect that writes the memo's result to the target output
        createEffect(() => {
          const path = rule.output.split('.'); // e.g., ['fields', 'system.stunden', 'value']
          setState(...path, resultMemo());
        });
      }
    
      return [state, setState];
    }
  3. Solving Your Requirements:

    • Intermediary Dependencies: The internal.minutes_passed field is a perfect example. It's a node in the graph, computed by one rule and used as an input by another, but it's never rendered because its type is internal. The dependency chain is explicitly defined in the JSON: begin/end -> minutes_passed -> stunden.
    • List/Print Dependencies: This becomes trivial. You don't need to parse JavaScript code. You just parse the logic JSON array.
      • To find all dependencies of system.stunden, you find the logic node where output is fields.system.stunden.value and list its inputs.
      • To build a full graph for visualization (e.g., with D3.js or Mermaid), you iterate through the logic array, treating each output as a node and drawing directed edges from its inputs.

Summary: The Clean, Extensible Architecture

Concept Old Way (Current Code) New Architecture
State Monolithic object mixing data, UI, and logic hints (visibleWhen). Separated into fields (definitions), layout (UI tree), and logic (computation graph).
Logic Hard-coded in JavaScript functions and createEffect. Declarative, serializable JSON (logic array).
Rendering Imperative renderFields function. A generic, recursive FieldRenderer component that uses a componentRegistry to render the layout tree.
Execution Logic scattered in event handlers and effects. A centralized Form Engine that interprets the logic graph and wires it up with Solid's reactive primitives.
Extensibility Adding a new field type or dependency logic requires changing multiple JS functions. Adding a new field type is adding a component to a registry. Adding logic is adding a JSON object to the logic array.

This architecture is a significant investment but provides immense power. It transforms your project from a specific application into a general-purpose platform where users (or administrators) could potentially build complex, reactive forms themselves through a graphical interface that manipulates this underlying JSON structure.

@fschutt
Copy link
Author

fschutt commented Nov 12, 2025

v2


This is an outstanding set of questions. You are thinking beyond just building a form and moving into the realm of building a form engine. This requires a significant architectural shift from a data-driven UI to a computation-driven UI, where the form's state, structure, and behavior are explicitly defined and interconnected as a reactive graph.

Let's break down the architecture.

Analysis of the Architectural Goals

Your requirements point towards a system with three distinct, serializable pillars:

  1. State: The raw data values of the fields. What the user has entered.
  2. Structure (Layout): The visual hierarchy and presentation of the fields. Where things appear, how they are grouped.
  3. Behavior (Rules/Dependencies): The reactive "magic" that connects everything. This includes calculations, visibility toggles, and validations.

The current architecture mixes these concerns. For example, a field object in the fields array contains its value (State), its display order (Structure), and its visibility condition (Behavior). This tight coupling makes extensibility difficult.

Here is a new, clean, and extensible architecture designed to meet your goals.


Part 1: Refactoring the Validator System

The current system using an array of strings (["readonly", "required"]) is too simplistic. To make validators stackable and exclusive, we need a richer definition.

Proposed Validator Architecture

  1. Validator Definitions: Create a central, static library of all possible validators. Each definition includes metadata, like its exclusion group.

    // In a file like /src/validators/definitions.ts
    export const VALIDATOR_DEFINITIONS = {
      required: {
        label: "Required",
        group: "constraint", // Can be stacked with others in this group
        // The actual validation function
        fn: (value) => value !== null && value !== undefined && value !== "", 
      },
      minLength: {
        label: "Min Length",
        group: "constraint",
        params: [{ name: "length", type: "number", default: 0 }],
        fn: (value, params) => String(value).length >= params.length,
      },
      email: {
        label: "Valid Email",
        group: "format", // Mutually exclusive with others in 'format' group
        fn: (value) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value),
      },
      url: {
        label: "Valid URL",
        group: "format",
        fn: (value) => { try { new URL(value); return true; } catch { return false; } },
      },
      isNumber: {
        label: "Is Number",
        group: "format",
        fn: (value) => !isNaN(parseFloat(value)) && isFinite(value),
      },
    };
  2. Field Validator Instances: In your form state, a field's validators property will be an array of objects, not strings. This allows for parameters.

    // In your main state object
    "fields": [
      {
        "key": "user.contact_email",
        // ...
        "validators": [
          { "type": "required" },
          { "type": "email" }
        ]
      },
      {
        "key": "user.short_bio",
        // ...
        "validators": [
          { "type": "minLength", "params": { "length": 10 } }
        ]
      }
    ]

Implementation in the UI

When building the UI for adding/editing validators for a field:

  • You can group validators by their group property.
  • For the format group, use radio buttons or a dropdown to enforce that only one can be selected.
  • For the constraint group, use checkboxes to allow multiple selections.
  • When a validator with params is selected (like minLength), dynamically generate an input field for that parameter.

This system is stackable, configurable, and correctly handles mutual exclusion.


Part 2: A New Core Architecture for Extensibility

Let's redesign the main state object to separate State, Structure, and Behavior.

{
  "formTitle": "LEISTUNG FÜR ADEL, ALINA",

  // Pillar 1: STATE - The raw values. Nothing else.
  "values": {
    "system.mitarbeiter": "Bredemeier, Syenja",
    "system.klient": "Adel, Alina",
    "system.beginn": "2025-10-08T08:00",
    "system.ende": "2025-10-08T09:00",
    "system.leistung": "service_001",
    "system.sitzungslaenge": 60,
    "user.anfahrtszeit": 15
  },
  
  // Intermediary values calculated by rules, can be hidden from the UI.
  "computedValues": {
    "derived.durationInMinutes": 60,
    "derived.stunden": 1.0,
    "derived.anzahl_sitzungen": 1
  },

  // Pillar 2: STRUCTURE - The visual layout as a tree. Fully serializable.
  "layout": [
    {
      "component": "TextField", // Maps to a SolidJS component
      "fieldKey": "system.mitarbeiter",
      "label": "Mitarbeiter"
    },
    {
      "component": "TextField",
      "fieldKey": "system.klient",
      "label": "Klient"
    },
    {
      "component": "Section", // A layout component
      "title": "Zeitabrechnung",
      "children": [
        { "component": "DateTimeField", "fieldKey": "system.beginn", "label": "Beginn" },
        { "component": "DateTimeField", "fieldKey": "system.ende", "label": "Ende" },
        {
          "component": "NumberField",
          "fieldKey": "derived.stunden", // This field displays a computed value
          "label": "Stunden",
          "props": { "readonly": true }
        }
      ]
    }
  ],
  
  // Pillar 3: BEHAVIOR - A list of reactive rules. The heart of the engine.
  "rules": [
    {
      "id": "rule_calc_duration_minutes",
      "type": "COMPUTATION",
      "inputs": ["system.beginn", "system.ende"],
      "outputs": ["derived.durationInMinutes"],
      "function": "calculateDifferenceInMinutes"
    },
    {
      "id": "rule_calc_hours_from_minutes",
      "type": "COMPUTATION",
      "inputs": ["derived.durationInMinutes"],
      "outputs": ["derived.stunden"],
      "function": "convertMinutesToHours"
    },
    {
      "id": "rule_visibility_sitzungslaenge",
      "type": "VISIBILITY",
      "targetLayoutNodeId": "layout_node_sitzungslaenge", // Need IDs on layout nodes
      "inputs": ["system.leistung"],
      "function": "isEqualTo",
      "params": { "value": "service_002" }
    },
    {
      "id": "rule_validation_klient_email",
      "type": "VALIDATION",
      "targetFieldKey": "system.klient",
      "inputs": ["system.klient"],
      "function": "email" // Maps to our validator library
    }
  ]
}

How This New Architecture Solves Your Extensibility Goals

1. User-defined form inputs

The layout tree's "component": "TextField" property is the key. You would create a Component Registry in your application:

// /src/components/registry.ts
import { TextField } from './TextField';
import { NumberField } from './NumberField';
import { Section } from './Section';

export const COMPONENT_REGISTRY = {
  TextField,
  NumberField,
  Section,
  // Users or plugins could add more here
  // StarRating: lazy(() => import('./StarRating'))
};

Your renderer would look up the component in this registry. To add a new input type, a developer simply creates the component and adds it to the registry. The JSON can then use it.

2. Serializable Layout and Hierarchy

The layout tree is inherently hierarchical and serializable. It's a clean representation of the UI structure, completely separate from the data and logic. Rendering becomes a simple recursive function that walks this tree. Drag-and-drop would manipulate this layout tree directly.

3. Easily List Dependencies

This is now trivial. To find all dependencies for a field (e.g., derived.stunden):

  1. Find the rule where derived.stunden is in the outputs array (in this case, rule_calc_hours_from_minutes).
  2. The inputs of that rule (["derived.durationInMinutes"]) are its direct dependencies.
  3. You can then recursively trace the dependencies of derived.durationInMinutes to build the full dependency chain.

To find what a field affects, search for all rules where it is listed as an input.

4. Print a Dependency Graph/Tree

The rules array is a direct representation of a Directed Acyclic Graph (DAG).

  • Nodes: The keys from values and computedValues.
  • Edges: Defined by each rule, drawing a line from each input to each output.

You can parse this rules array and feed it directly into a library like Mermaid.js, D3.js, or Graphviz to automatically generate a visual diagram of your entire form's logic.

5. Intermediary Dependencies and Composable Functions

This is the most powerful part of the new architecture. The beginn/ende -> stunden chain is now explicitly modeled:

  1. Rule 1: (system.beginn, system.ende) -> calculateDifferenceInMinutes -> derived.durationInMinutes
  2. Rule 2: (derived.durationInMinutes) -> convertMinutesToHours -> derived.stunden

The derived.durationInMinutes field is our intermediary dependency. It exists in the state (computedValues) but may not appear in the layout (making it a "hidden" variable).

This system is completely composable. Users could build complex logic by chaining these rules together, and the entire chain would be serializable in JSON. The function property maps to a Function Registry, similar to the component registry, making the logic itself extensible.

// /src/functions/registry.ts
export const FUNCTION_REGISTRY = {
  calculateDifferenceInMinutes: (begin, end) => { /* ... */ },
  convertMinutesToHours: (minutes) => minutes / 60,
  isEqualTo: (inputValue, params) => inputValue === params.value,
  email: (value) => { /* validation logic */ },
  // ... more functions
};

Implementation with SolidJS: The Rule Engine

The final piece is to translate this declarative JSON into live SolidJS reactivity. You'd build a "Rule Engine" on startup.

// In your main App component or a dedicated setup function
import { createMemo, createEffect } from 'solid-js';
import { state, setState } from './store'; // The new store
import { FUNCTION_REGISTRY } from './functions/registry';

function initializeRuleEngine() {
  // A map to hold our reactive signals/memos
  const reactiveNodes = new Map();

  // 1. Create signals for all raw values
  for (const key in state.values) {
    reactiveNodes.set(key, () => state.values[key]); // Simplified; you'd use store accessors
  }

  // 2. Process rules and create memos/effects
  for (const rule of state.rules) {
    const inputs = rule.inputs.map(key => reactiveNodes.get(key));
    const computeFn = FUNCTION_REGISTRY[rule.function];

    if (rule.type === 'COMPUTATION') {
      const memo = createMemo(() => {
        const inputValues = inputs.map(inputSignal => inputSignal());
        return computeFn(...inputValues, rule.params);
      });

      // Link the output
      const outputKey = rule.outputs[0];
      reactiveNodes.set(outputKey, memo);

      // Write the computed value back to the store
      createEffect(() => {
        setState('computedValues', outputKey, memo());
      });
    }
    
    // ... handle VISIBILITY and VALIDATION rules similarly using createEffect
  }
}

// Call this once when the application loads.
initializeRuleEngine();

This engine dynamically constructs the createMemo and createEffect graph based on your JSON definition. Your UI components would then just read from the central store, and Solid's fine-grained reactivity would handle updating everything perfectly and efficiently when any input changes.

@fschutt
Copy link
Author

fschutt commented Nov 12, 2025

* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

body {
    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
    background: #f5f5f5;
    padding: 20px;
    min-height: 100vh;
}

.app-container {
    max-width: 1400px;
    margin: 0 auto;
    display: grid;
    grid-template-columns: 1fr 400px;
    gap: 20px;
}

.form-card {
    background: white;
    border-radius: 8px;
    box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
    overflow: hidden;
}

.form-header {
    background: #3a3a3a;
    color: white;
    padding: 10px 16px;
    display: flex;
    justify-content: space-between;
    align-items: center;
}

.form-header h1 {
    font-size: 14px;
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: 0.5px;
}

.header-actions {
    display: flex;
    gap: 10px;
}

.icon-btn {
    background: rgba(255, 255, 255, 0.2);
    border: none;
    color: white;
    width: 36px;
    height: 36px;
    border-radius: 50%;
    cursor: pointer;
    display: flex;
    align-items: center;
    justify-content: center;
    transition: background 0.2s;
    font-size: 16px;
}

.icon-btn:hover {
    background: rgba(255, 255, 255, 0.3);
}

.form-body {
    padding: 16px;
}

.field-row {
    display: grid;
    grid-template-columns: 30px 1fr 2fr;
    gap: 8px;
    align-items: start;
    padding: 8px 4px;
    border-bottom: 1px solid #f0f0f0;
    transition: all 0.3s ease;
    position: relative;
}

.field-row:hover {
    background: #f9f9f9;
}

.field-row.dragging {
    opacity: 0.5;
    background: #e8f5e9;
}

.field-row.drag-over {
    margin-top: 40px;
    transition: margin-top 0.2s ease;
}

.field-row.drag-over::before {
    content: '';
    position: absolute;
    top: -20px;
    left: 0;
    right: 0;
    height: 3px;
    background: #4caf50;
}

.field-row.drag-over-nested {
    margin-left: 50px;
    transition: margin-left 0.2s ease;
}

.field-row.drag-over-nested::before {
    content: '';
    position: absolute;
    top: -20px;
    left: 0;
    right: 0;
    height: 3px;
    background: #FF9800;
}

.field-row.dependent {
    margin-left: 40px;
    background: #f8f8f8;
}

.field-row.dependent .drag-handle {
    color: #ccc;
}

.drag-handle {
    cursor: grab;
    color: #999;
    padding: 4px;
    display: flex;
    align-items: center;
    justify-content: center;
    user-select: none;
    font-size: 14px;
}

.drag-handle:active {
    cursor: grabbing;
}

.field-label {
    color: #666;
    font-size: 13px;
    padding: 6px 0;
    display: flex;
    align-items: center;
    gap: 4px;
}

.field-label.required::after {
    content: '*';
    color: #f44336;
}

.field-label .hierarchy {
    color: #999;
    font-size: 11px;
    margin-right: 4px;
}

.custom-badge {
    background: #FF9800;
    color: white;
    font-size: 9px;
    padding: 2px 5px;
    border-radius: 3px;
    margin-left: 4px;
    font-weight: 600;
}

.field-input-wrapper {
    display: flex;
    gap: 8px;
    align-items: center;
}

.field-input {
    flex: 1;
}

input[type="text"],
input[type="number"],
input[type="datetime-local"],
select,
textarea {
    width: 100%;
    padding: 6px 10px;
    border: 1px solid #ddd;
    border-radius: 4px;
    font-size: 13px;
    transition: border-color 0.2s, box-shadow 0.2s;
}

input:focus,
select:focus,
textarea:focus {
    outline: none;
    border-color: #4caf50;
    box-shadow: 0 0 0 3px rgba(76, 175, 80, 0.1);
}

input.invalid,
select.invalid {
    border-color: #f44336;
}

input.invalid:focus,
select.invalid:focus {
    box-shadow: 0 0 0 3px rgba(244, 67, 54, 0.1);
}

input[readonly],
input[disabled] {
    background: #f5f5f5;
    cursor: not-allowed;
}

.toggle-switch {
    position: relative;
    width: 48px;
    height: 24px;
    background: #ccc;
    border-radius: 12px;
    cursor: pointer;
    transition: background 0.3s;
}

.toggle-switch.active {
    background: #4caf50;
}

.toggle-switch::after {
    content: '';
    position: absolute;
    top: 2px;
    left: 2px;
    width: 20px;
    height: 20px;
    background: white;
    border-radius: 50%;
    transition: transform 0.3s;
}

.toggle-switch.active::after {
    transform: translateX(24px);
}

.clear-btn {
    background: none;
    border: none;
    color: #999;
    cursor: pointer;
    padding: 4px 8px;
    font-size: 18px;
    line-height: 1;
}

.clear-btn:hover {
    color: #f44336;
}

.form-footer {
    padding: 12px 16px;
    background: #f9f9f9;
    display: flex;
    gap: 12px;
    justify-content: flex-end;
}

.btn {
    padding: 8px 16px;
    border: none;
    border-radius: 4px;
    font-size: 13px;
    cursor: pointer;
    display: flex;
    align-items: center;
    gap: 6px;
    transition: all 0.2s;
}

.btn-primary {
    background: #4caf50;
    color: white;
}

.btn-primary:hover {
    background: #45a049;
    box-shadow: 0 2px 8px rgba(76, 175, 80, 0.3);
}

.btn-secondary {
    background: white;
    color: #666;
    border: 1px solid #ddd;
}

.btn-secondary:hover {
    background: #f5f5f5;
}

.section-title {
    background: #f5f5f5;
    padding: 10px 16px;
    font-size: 13px;
    font-weight: 600;
    color: #555;
    border-bottom: 1px solid #e0e0e0;
}

.add-field-section {
    padding: 16px;
    background: #fafafa;
}

.add-field-form {
    display: flex;
    flex-direction: column;
    gap: 12px;
}

.form-group {
    display: flex;
    flex-direction: column;
    gap: 4px;
}

.form-group label {
    font-size: 12px;
    font-weight: 600;
    color: #555;
}

.form-group input,
.form-group select {
    padding: 8px 12px;
    border: 1px solid #ddd;
    border-radius: 4px;
    font-size: 13px;
}

.form-group .toggle-switch {
    align-self: flex-start;
}

.form-actions {
    display: flex;
    gap: 8px;
    justify-content: flex-end;
    margin-top: 8px;
}

.advanced-toggle {
    background: none;
    border: none;
    color: #1976d2;
    font-size: 12px;
    cursor: pointer;
    display: flex;
    align-items: center;
    gap: 4px;
    padding: 4px 0;
    font-weight: 600;
}

.advanced-toggle::before {
    content: '▶';
    display: inline-block;
    transition: transform 0.2s;
    font-size: 10px;
}

.advanced-toggle.expanded::before {
    transform: rotate(90deg);
}

.advanced-config {
    background: white;
    border: 1px solid #e0e0e0;
    border-radius: 4px;
    padding: 12px;
    margin-top: 12px;
    display: none;
}

.advanced-config.visible {
    display: block;
}

.advanced-config-title {
    font-size: 12px;
    font-weight: 600;
    color: #666;
    margin-bottom: 8px;
    cursor: pointer;
    user-select: none;
}

.advanced-config-title::before {
    content: '▶ ';
    display: inline-block;
    transition: transform 0.2s;
}

.advanced-config.visible .advanced-config-title::before {
    transform: rotate(90deg);
}

.config-row {
    display: grid;
    grid-template-columns: 120px 1fr;
    gap: 8px;
    margin-bottom: 8px;
    align-items: center;
    font-size: 12px;
}

.config-label {
    color: #666;
    font-weight: 500;
}

.parent-field-selector {
    font-size: 12px;
    padding: 4px 8px;
}

.validator-tags {
    display: flex;
    flex-wrap: wrap;
    gap: 4px;
}

.validator-tag {
    background: #e3f2fd;
    color: #1976d2;
    padding: 2px 8px;
    border-radius: 3px;
    font-size: 11px;
    cursor: pointer;
    border: 1px solid transparent;
}

.validator-tag.selected {
    background: #1976d2;
    color: white;
    border-color: #1565c0;
}

.enum-options {
    display: flex;
    flex-direction: column;
    gap: 4px;
}

.enum-option-row {
    display: grid;
    grid-template-columns: 1fr auto;
    gap: 4px;
}

.enum-option-row input {
    padding: 4px 8px;
    font-size: 12px;
}

.enum-value-row {
    display: grid;
    grid-template-columns: auto 1fr 2fr auto;
    gap: 8px;
    margin-bottom: 6px;
    align-items: center;
}

.enum-value-row input {
    padding: 6px 10px;
    font-size: 12px;
    border: 1px solid #ddd;
    border-radius: 3px;
}

.indent-controls {
    display: flex;
    gap: 4px;
}

.indent-btn {
    width: 24px;
    height: 24px;
    padding: 0;
    font-size: 14px;
    border: 1px solid #ddd;
    background: white;
    cursor: pointer;
    border-radius: 3px;
    display: flex;
    align-items: center;
    justify-content: center;
}

.indent-btn:hover {
    background: #f5f5f5;
}

.indent-btn:disabled {
    opacity: 0.3;
    cursor: not-allowed;
}

.indent-indicator {
    width: 20px;
    text-align: center;
    color: #999;
    font-size: 11px;
    font-weight: 600;
}

.small-btn {
    padding: 4px 8px;
    font-size: 11px;
    border: 1px solid #ddd;
    background: white;
    cursor: pointer;
    border-radius: 3px;
}

.small-btn:hover {
    background: #f5f5f5;
}

.json-panel {
    background: white;
    border-radius: 8px;
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
    overflow: hidden;
    position: sticky;
    top: 20px;
    max-height: calc(100vh - 40px);
    display: flex;
    flex-direction: column;
}

.json-header {
    background: #2d2d2d;
    color: white;
    padding: 12px 16px;
    font-size: 13px;
    font-weight: 600;
    display: flex;
    justify-content: space-between;
    align-items: center;
}

.json-toggle-btn {
    background: rgba(255, 255, 255, 0.1);
    border: 1px solid rgba(255, 255, 255, 0.2);
    color: white;
    padding: 4px 10px;
    border-radius: 3px;
    cursor: pointer;
    font-size: 11px;
}

.json-toggle-btn:hover {
    background: rgba(255, 255, 255, 0.2);
}

.json-output {
    background: #2d2d2d;
    color: #f8f8f2;
    padding: 16px;
    font-family: 'Courier New', monospace;
    font-size: 11px;
    overflow: auto;
    white-space: pre-wrap;
    flex: 1;
    margin: 0;
}
@media (max-width: 768px) {
    .app-container {
        grid-template-columns: 1fr;
    }

    .field-row {
        grid-template-columns: 30px 1fr;
        gap: 8px;
    }

    .field-label {
        grid-column: 2;
    }

    .field-input-wrapper {
        grid-column: 2;
    }

    .add-field-form {
        grid-template-columns: 1fr;
    }

    .json-panel {
        position: relative;
        max-height: 400px;
    }
}

@fschutt
Copy link
Author

fschutt commented Nov 12, 2025

Extensible Form Engine Architecture

Overview

A declarative, computation-driven form engine that transforms JSON into a fully reactive UI using SolidJS. Based on the three-pillar architecture from planning/arch-2.md.

Three-Pillar Architecture

graph TB
    subgraph "Pillar 1: STATE"
        VALUES[values<br/>User Input]
        COMPUTED[computedValues<br/>Derived Data]
    end
    
    subgraph "Pillar 2: STRUCTURE"
        LAYOUT[layout<br/>LayoutNode Tree]
        REGISTRY[COMPONENT_REGISTRY<br/>String → Component]
    end
    
    subgraph "Pillar 3: BEHAVIOR"
        RULES[rules<br/>Rule Definitions]
        FUNCTIONS[FUNCTION_REGISTRY<br/>String → Function]
        ENGINE[Rule Engine<br/>Creates Reactive Graph]
    end
    
    VALUES --> ENGINE
    COMPUTED --> ENGINE
    RULES --> ENGINE
    FUNCTIONS --> ENGINE
    ENGINE --> COMPUTED
    LAYOUT --> REGISTRY
    REGISTRY --> UI[Rendered UI]
    VALUES --> UI
    COMPUTED --> UI
Loading

Pillar 1: STATE (store.ts)

  • values: User-entered data (system.klient, system.beginn, etc.)
  • computedValues: Derived data (derived.stunden, derived.form_title, etc.)
  • SolidJS: createStore creates reactive proxy—reads track dependencies, writes trigger updates

Pillar 2: STRUCTURE (types.ts, registry.tsx)

  • layout: Tree of LayoutNode objects defining visual hierarchy
  • COMPONENT_REGISTRY: Maps component type strings to SolidJS components
  • SolidJS: <For> and <Show> primitives render tree recursively

Pillar 3: BEHAVIOR (types.ts, ruleEngine.ts, functionRegistry.ts)

  • rules: Array of declarative rules (COMPUTATION, VISIBILITY, VALIDATION)
  • FUNCTION_REGISTRY: Maps function name strings to implementations
  • Rule Engine: Transforms rules into SolidJS reactive primitives (memos/effects)

SolidJS Reactive Flow

Initialization Sequence

sequenceDiagram
    participant App
    participant Store
    participant RuleEngine
    participant Layout
    participant UI

    App->>Store: Load INITIAL_STATE
    App->>RuleEngine: initializeRuleEngine()
    RuleEngine->>Store: Read values & computedValues
    RuleEngine->>RuleEngine: Create reactive accessors
    loop For each rule
        RuleEngine->>RuleEngine: Create createMemo/createEffect
    end
    RuleEngine->>Store: Write initial computed values
    App->>Layout: Render layout tree
    Layout->>UI: Instantiate components
    UI->>Store: Read values (track dependencies)
Loading

Field Update Flow

sequenceDiagram
    participant User
    participant Component as Field Component
    participant Store
    participant Memo as Rule Engine Memo
    participant Effect as Rule Engine Effect
    participant UI as Dependent UI

    User->>Component: Changes value
    Component->>Component: onUpdate(newValue)
    Component->>Store: updateValue(key, newValue)
    Store->>Store: setFormState("values", key, newValue)
    Store->>Memo: Triggers re-evaluation
    Note over Memo: Dependencies changed
    Memo->>Memo: Recompute output
    Memo->>Effect: New value available
    Effect->>Store: setComputedValue(outputKey, result)
    Store->>UI: Triggers re-render
    UI->>UI: Display updated value
Loading

Concrete Example: Begin/End Time → Hours

The Setup

Rules:

[
  {
    "id": "rule_calc_duration_minutes",
    "type": "COMPUTATION",
    "inputs": ["system.beginn", "system.ende"],
    "outputs": ["derived.durationInMinutes"],
    "function": "calculateDifferenceInMinutes"
  },
  {
    "id": "rule_calc_hours",
    "type": "COMPUTATION",
    "inputs": ["derived.durationInMinutes"],
    "outputs": ["derived.stunden"],
    "function": "convertMinutesToHours"
  }
]

The Flow

graph LR
    A[User changes<br/>system.beginn] --> B[updateValue]
    B --> C[Store Update]
    C --> D[Memo 1 Recomputes]
    D --> E[calculateDifferenceInMinutes]
    E --> F[Effect 1 Writes]
    F --> G[derived.durationInMinutes<br/>updated]
    G --> H[Memo 2 Recomputes]
    H --> I[convertMinutesToHours]
    I --> J[Effect 2 Writes]
    J --> K[derived.stunden<br/>updated]
    K --> L[NumberField<br/>Re-renders]
Loading

What the Rule Engine Creates

// For rule_calc_duration_minutes:
const memo1 = createMemo(() => {
  const beginn = formState.values["system.beginn"];
  const ende = formState.values["system.ende"];
  return calculateDifferenceInMinutes(beginn, ende);
});

createEffect(() => {
  setComputedValue("derived.durationInMinutes", memo1());
});

// For rule_calc_hours:
const memo2 = createMemo(() => {
  const minutes = formState.computedValues["derived.durationInMinutes"];
  return convertMinutesToHours(minutes);
});

createEffect(() => {
  setComputedValue("derived.stunden", memo2());
});

Dependency Graph (DAG)

The rules form a Directed Acyclic Graph:

graph TD
    BEGIN[system.beginn] -->|calculateDifferenceInMinutes| DURATION[derived.durationInMinutes]
    END[system.ende] -->|calculateDifferenceInMinutes| DURATION
    DURATION -->|convertMinutesToHours| HOURS[derived.stunden]
    HOURS -->|calculateSessionCount| SESSIONS[derived.anzahl_sitzungen]
    SESSIONLEN[system.sitzungslaenge] -->|calculateSessionCount| SESSIONS
    KLIENT[system.klient] -->|updateFormTitle| TITLE[derived.form_title]
Loading

Rule Types

COMPUTATION

Calculates derived values from inputs.

What it creates:

  • createMemo: Computes the result
  • createEffect: Writes result to computedValues

Example:

// Rule
{
  type: "COMPUTATION",
  inputs: ["a", "b"],
  outputs: ["result"],
  function: "add"
}

// Generated Code
const memo = createMemo(() => add(formState.values.a, formState.values.b));
createEffect(() => setComputedValue("result", memo()));

VISIBILITY

Controls whether UI elements are shown.

What it creates:

  • createMemo: Returns boolean

Example:

// Rule
{
  type: "VISIBILITY",
  targetLayoutNodeId: "field_x",
  inputs: ["field_y"],
  function: "isEqualTo",
  params: { value: "show" }
}

// Generated Code
const visibilityMemo = createMemo(() => 
  isEqualTo(formState.values.field_y, { value: "show" })
);

// Used in component
<Show when={getNodeVisibilityAccessor("field_x")()}>
  <FieldComponent />
</Show>

VALIDATION

Validates field values (currently logs, future: validation state store).

What it creates:

  • createEffect: Runs validation function

Example:

// Rule
{
  type: "VALIDATION",
  targetFieldKey: "user.email",
  inputs: ["user.email"],
  function: "email"
}

// Generated Code
createEffect(() => {
  const isValid = email(formState.values["user.email"]);
  if (!isValid) console.warn("Invalid email");
});

Component Rendering

Layout Tree Rendering

graph TB
    FORM[ExtensibleDnDForm] --> RENDERER[LayoutNodeRenderer]
    RENDERER --> FOR["&lt;For each={layout}&gt;"]
    FOR --> SHOW1["&lt;Show when={isVisible()}&gt;"]
    SHOW1 --> CHECK{Is Section?}
    CHECK -->|Yes| SECTION[Render Section]
    CHECK -->|No| FIELD[SortableFieldRow]
    SECTION --> RECURSIVE[Recursive LayoutNodeRenderer]
    FIELD --> COMPONENT[Get component from registry]
    COMPONENT --> INSTANCE[Render field component]
    INSTANCE --> VALUE[Read value from store]
Loading

Component Instantiation

// Layout node
{
  component: "TextField",
  fieldKey: "system.klient",
  label: "Klient"
}

// Rendered as
const FieldComponent = COMPONENT_REGISTRY["TextField"];
<FieldComponent 
  value={getValue("system.klient")}  // Reactive!
  onUpdate={(v) => updateValue("system.klient", v)}
/>

SolidJS Primitives Deep Dive

createStore

const [formState, setFormState] = createStore({ values: {...} });

// Read (tracks dependency)
const val = formState.values.klient;  // Component now depends on this

// Write (triggers updates)
setFormState("values", "klient", "New");  // All dependent components update

createMemo

// Only recomputes when dependencies change, result is cached
const hours = createMemo(() => {
  return formState.computedValues.durationInMinutes / 60;
});

hours();  // First call: computes
hours();  // Second call: returns cached value
// formState changes...
hours();  // Third call: recomputes

createEffect

// Runs immediately and whenever dependencies change
createEffect(() => {
  const val = formState.values.x;  // Tracks dependency
  console.log("x changed to:", val);  // Side effect
});

createRoot

// Creates owned reactive scope for cleanup
const dispose = createRoot((dispose) => {
  const memo = createMemo(...);
  createEffect(...);
  return dispose;
});

// Later: cleanup all reactive primitives
dispose();

File Responsibilities

File Purpose SolidJS Primitives
store.ts Central state createStore
types.ts Type definitions -
ruleEngine.ts Build reactive graph createMemo, createEffect, createRoot
functionRegistry.ts Function implementations -
registry.tsx Component mapping -
ExtensibleDnDForm.tsx Main UI, DnD, save/load <For>, <Show>, onMount
fields/*.tsx Field components Reactive props

Extending the System

Add a New Field Type

// 1. Create component (fields/ColorPicker.tsx)
export const ColorPickerField: Component<FieldInputProps> = (props) => (
  <input type="color" value={props.value} 
    onInput={(e) => props.onUpdate(e.target.value)} />
);

// 2. Register (registry.tsx)
export const COMPONENT_REGISTRY = {
  ...existing,
  ColorPicker: ColorPickerField,
};

// 3. Add type (types.ts)
export type ComponentType = "TextField" | ... | "ColorPicker";

// 4. Use in layout JSON
{ component: "ColorPicker", fieldKey: "brand.color", label: "Color" }

Add a New Function

// 1. Add to functionRegistry.ts
export const FUNCTION_REGISTRY = {
  ...existing,
  calculateDiscount: (price: number, percent: number) => price * (1 - percent/100),
};

// 2. Use in rules
{
  type: "COMPUTATION",
  inputs: ["cart.price", "user.discount"],
  outputs: ["cart.final_price"],
  function: "calculateDiscount"
}

Key Features

Fully Declarative: Entire form is JSON (state, structure, behavior)
Fine-Grained Reactivity: Only affected components update
Computation Graph: Automatic dependency tracking and updates
Extensible: Add fields/functions without touching core
Serializable: Save/load entire form state
Visualizable: Generate Mermaid dependency graphs
Type-Safe: Full TypeScript support

Performance

  • Memoization: Computed values cached via createMemo
  • Granular Updates: Only components reading changed values re-render
  • Lazy Evaluation: <Show> components don't render when hidden
  • No Virtual DOM: SolidJS compiles to direct DOM updates
  • Memory Safe: createRoot ensures proper cleanup

Debugging

// View dependency graph
import { generateDependencyGraph } from './ruleEngine';
console.log(generateDependencyGraph());
// Paste into https://mermaid.live

// Trace dependencies
import { getFieldDependencies, getFieldDependents } from './ruleEngine';
console.log(getFieldDependencies('derived.stunden'));
// → ['derived.durationInMinutes']
console.log(getFieldDependents('system.beginn'));
// → ['derived.durationInMinutes']

Architecture Summary

graph TB
    subgraph "User Action"
        USER[User Input]
    end
    
    subgraph "State Layer"
        STORE[SolidJS Store<br/>values & computedValues]
    end
    
    subgraph "Behavior Layer"
        RULES[Rule Definitions]
        ENGINE[Rule Engine]
        MEMOS[createMemo]
        EFFECTS[createEffect]
    end
    
    subgraph "Structure Layer"
        LAYOUT[Layout Tree]
        REGISTRY[Component Registry]
        RENDERER[Recursive Renderer]
    end
    
    subgraph "Presentation"
        UI[UI Components]
    end
    
    USER --> UI
    UI --> STORE
    STORE --> MEMOS
    RULES --> ENGINE
    ENGINE --> MEMOS
    ENGINE --> EFFECTS
    MEMOS --> EFFECTS
    EFFECTS --> STORE
    STORE --> UI
    LAYOUT --> REGISTRY
    REGISTRY --> RENDERER
    RENDERER --> UI
Loading

The form engine transforms declarative JSON into a reactive SolidJS application where:

  1. State lives in a reactive store
  2. Structure is a layout tree mapped to components
  3. Behavior is rules that create an automatic computation graph

When any value changes, SolidJS's fine-grained reactivity propagates updates through the graph, automatically recomputing derived values and updating only the affected UI components.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment