-
-
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> |
* {
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;
}
}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
Pillar 1: STATE (store.ts)
values: User-entered data (system.klient,system.beginn, etc.)computedValues: Derived data (derived.stunden,derived.form_title, etc.)- SolidJS:
createStorecreates reactive proxy—reads track dependencies, writes trigger updates
Pillar 2: STRUCTURE (types.ts, registry.tsx)
layout: Tree ofLayoutNodeobjects defining visual hierarchyCOMPONENT_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)
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
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]
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]
Rule Types
COMPUTATION
Calculates derived values from inputs.
What it creates:
createMemo: Computes the resultcreateEffect: Writes result tocomputedValues
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["<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
// 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 updatecreateMemo
// 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: recomputescreateEffect
// 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:
createRootensures 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
The form engine transforms declarative JSON into a reactive SolidJS application where:
- State lives in a reactive store
- Structure is a layout tree mapped to components
- 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.
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:
The current architecture mixes these concerns. For example, a field object in the
fieldsarray 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
Validator Definitions: Create a central, static library of all possible validators. Each definition includes metadata, like its exclusion group.
Field Validator Instances: In your form state, a field's
validatorsproperty will be an array of objects, not strings. This allows for parameters.Implementation in the UI
When building the UI for adding/editing validators for a field:
groupproperty.formatgroup, use radio buttons or a dropdown to enforce that only one can be selected.constraintgroup, use checkboxes to allow multiple selections.paramsis selected (likeminLength), 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
layouttree's"component": "TextField"property is the key. You would create a Component Registry in your application: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
layouttree 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 thislayouttree directly.3. Easily List Dependencies
This is now trivial. To find all dependencies for a field (e.g.,
derived.stunden):derived.stundenis in theoutputsarray (in this case,rule_calc_hours_from_minutes).inputsof that rule (["derived.durationInMinutes"]) are its direct dependencies.derived.durationInMinutesto 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
rulesarray is a direct representation of a Directed Acyclic Graph (DAG).valuesandcomputedValues.inputto eachoutput.You can parse this
rulesarray 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->stundenchain is now explicitly modeled:(system.beginn, system.ende) -> calculateDifferenceInMinutes -> derived.durationInMinutes(derived.durationInMinutes) -> convertMinutesToHours -> derived.stundenThe
derived.durationInMinutesfield is our intermediary dependency. It exists in the state (computedValues) but may not appear in thelayout(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
functionproperty maps to a Function Registry, similar to the component registry, making the logic itself extensible.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.
This engine dynamically constructs the
createMemoandcreateEffectgraph 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.