Created
November 12, 2025 09:03
-
-
Save fschutt/8fc918728d1be795a28c9ccdc14315aa to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!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> |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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 --> UIPillar 1: STATE (
store.ts)values: User-entered data (system.klient,system.beginn, etc.)computedValues: Derived data (derived.stunden,derived.form_title, etc.)createStorecreates reactive proxy—reads track dependencies, writes trigger updatesPillar 2: STRUCTURE (
types.ts,registry.tsx)layout: Tree ofLayoutNodeobjects defining visual hierarchyCOMPONENT_REGISTRY: Maps component type strings to SolidJS components<For>and<Show>primitives render tree recursivelyPillar 3: BEHAVIOR (
types.ts,ruleEngine.ts,functionRegistry.ts)rules: Array of declarative rules (COMPUTATION, VISIBILITY, VALIDATION)FUNCTION_REGISTRY: Maps function name strings to implementationsSolidJS 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)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 valueConcrete 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]What the Rule Engine Creates
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]Rule Types
COMPUTATION
Calculates derived values from inputs.
What it creates:
createMemo: Computes the resultcreateEffect: Writes result tocomputedValuesExample:
VISIBILITY
Controls whether UI elements are shown.
What it creates:
createMemo: Returns booleanExample:
VALIDATION
Validates field values (currently logs, future: validation state store).
What it creates:
createEffect: Runs validation functionExample:
Component Rendering
Layout Tree Rendering
graph TB FORM[ExtensibleDnDForm] --> RENDERER[LayoutNodeRenderer] RENDERER --> FOR["<For each={layout}>"] FOR --> SHOW1["<Show when={isVisible()}>"] 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]Component Instantiation
SolidJS Primitives Deep Dive
createStore
createMemo
createEffect
createRoot
File Responsibilities
store.tscreateStoretypes.tsruleEngine.tscreateMemo,createEffect,createRootfunctionRegistry.tsregistry.tsxExtensibleDnDForm.tsx<For>,<Show>,onMountfields/*.tsxExtending the System
Add a New Field Type
Add a New Function
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
createMemo<Show>components don't render when hiddencreateRootensures proper cleanupDebugging
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 --> UIThe form engine transforms declarative JSON into a reactive SolidJS application where:
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.