@stormlmd/form-builder 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +73 -0
  2. package/eslint.config.js +23 -0
  3. package/index.html +13 -0
  4. package/package.json +60 -0
  5. package/public/vite.svg +1 -0
  6. package/src/App.css +42 -0
  7. package/src/App.tsx +83 -0
  8. package/src/assets/react.svg +1 -0
  9. package/src/components/FieldRegistry.ts +34 -0
  10. package/src/components/FormContainer.tsx +25 -0
  11. package/src/components/FormRenderer.tsx +121 -0
  12. package/src/components/builder/DraggableTool.tsx +66 -0
  13. package/src/components/builder/DroppableCanvas.tsx +51 -0
  14. package/src/components/builder/EditorWrapper.tsx +87 -0
  15. package/src/components/builder/FormBuilder.tsx +313 -0
  16. package/src/components/builder/FormChildrenRenderer.tsx +68 -0
  17. package/src/components/builder/IntegrationSettings.tsx +110 -0
  18. package/src/components/builder/PropertiesModal.tsx +75 -0
  19. package/src/components/builder/PropertiesPanel.tsx +858 -0
  20. package/src/components/builder/SortableNode.tsx +53 -0
  21. package/src/components/builder/Toolbox.tsx +123 -0
  22. package/src/components/fields/CheckboxField.tsx +41 -0
  23. package/src/components/fields/DateField.tsx +56 -0
  24. package/src/components/fields/FileUploadField.tsx +45 -0
  25. package/src/components/fields/LabelField.tsx +20 -0
  26. package/src/components/fields/NumberField.tsx +39 -0
  27. package/src/components/fields/RichTextField.tsx +39 -0
  28. package/src/components/fields/SelectField.tsx +64 -0
  29. package/src/components/fields/TextField.tsx +44 -0
  30. package/src/components/layout/FormCol.tsx +30 -0
  31. package/src/components/layout/FormDivider.tsx +19 -0
  32. package/src/components/layout/FormPaper.tsx +85 -0
  33. package/src/components/layout/FormRepeater.tsx +130 -0
  34. package/src/components/layout/FormRow.tsx +61 -0
  35. package/src/components/layout/FormTab.tsx +33 -0
  36. package/src/components/layout/FormTable.tsx +47 -0
  37. package/src/components/layout/FormTableCell.tsx +47 -0
  38. package/src/components/layout/FormTabs.tsx +77 -0
  39. package/src/components/layout/LayoutPlaceholder.tsx +85 -0
  40. package/src/components/registerComponents.ts +30 -0
  41. package/src/index.css +75 -0
  42. package/src/index.ts +5 -0
  43. package/src/main.tsx +10 -0
  44. package/src/store/FormStore.ts +811 -0
  45. package/src/utils/apiTransformer.ts +206 -0
  46. package/src/utils/idGenerator.ts +3 -0
  47. package/tsconfig.app.json +28 -0
  48. package/tsconfig.json +7 -0
  49. package/tsconfig.node.json +26 -0
  50. package/vite.config.ts +46 -0
@@ -0,0 +1,811 @@
1
+ import { makeAutoObservable, toJS } from "mobx";
2
+ import i18next from 'i18next';
3
+ import { transformAPIDataToSchema, transformSchemaToSubmission, transformDictionaryToOptions } from '../utils/apiTransformer';
4
+
5
+ export type NodeType =
6
+ | 'root'
7
+ | 'text'
8
+ | 'number'
9
+ | 'checkbox'
10
+ | 'select'
11
+ | 'row'
12
+ | 'col'
13
+ | 'tabs' // container for tabs
14
+ | 'tab' // individual tab
15
+ | 'table'
16
+ | 'cell'
17
+ | 'paper'
18
+ | 'repeater'
19
+ | 'date'
20
+ | 'file'
21
+ | 'richtext'
22
+ | 'label'
23
+ | 'divider';
24
+
25
+ export interface FieldOption {
26
+ label: string;
27
+ value: string | number;
28
+ }
29
+
30
+ export interface ExternalField {
31
+ id: string;
32
+ label: string;
33
+ type: string;
34
+ }
35
+
36
+ export interface APIIntegration {
37
+ id: string;
38
+ name: string;
39
+ fetchFieldsURL: string;
40
+ submitURL: string;
41
+ method: 'POST' | 'PUT';
42
+ headers?: Record<string, string>;
43
+ availableFields: ExternalField[];
44
+ }
45
+
46
+ export interface ValidationRules {
47
+ required?: boolean;
48
+ min?: number | string;
49
+ max?: number | string;
50
+ integer?: boolean;
51
+ minLength?: number;
52
+ maxLength?: number;
53
+ pattern?: string;
54
+ email?: boolean;
55
+ }
56
+
57
+ export interface Condition {
58
+ field: string;
59
+ op: 'eq' | 'neq' | 'gt' | 'lt' | 'contains';
60
+ value: any;
61
+ }
62
+
63
+ export interface SchemaNode {
64
+ id: string;
65
+ type: NodeType;
66
+ props?: Record<string, any>; // generic props: label, name, required, etc.
67
+ validation?: ValidationRules;
68
+ condition?: Condition;
69
+ calculation?: { formula: string };
70
+ apiMapping?: string; // ID of the external field
71
+ defaultValue?: any;
72
+ children?: SchemaNode[];
73
+ }
74
+
75
+ export interface RootNodeProps {
76
+ integrations?: APIIntegration[];
77
+ }
78
+
79
+ export class FormStore {
80
+ rootNode: SchemaNode = {
81
+ id: 'root',
82
+ type: 'root',
83
+ children: []
84
+ };
85
+
86
+ selectedNodeId: string | null = null;
87
+ isPropertiesModalOpen: boolean = false;
88
+
89
+ // Store form values separately from schema
90
+ values: Record<string, any> = {};
91
+
92
+ // Store validation errors
93
+ errors: Record<string, string> = {};
94
+
95
+ // Drag and drop state
96
+ dropIndicator: { parentId: string; index: number } | null = null;
97
+
98
+ isFetchingExternalFields: boolean = false;
99
+ isEditMode: boolean = true;
100
+
101
+ // Session data (user info, report IDs, etc.)
102
+ sessionVariables: Record<string, any> = {};
103
+
104
+ // Pool of available API columns for mapping
105
+ availableApiColumns: any[] = [];
106
+
107
+ constructor() {
108
+ makeAutoObservable(this);
109
+ this.loadFromStorage(); // Load from storage on init
110
+ }
111
+
112
+ setEditMode(mode: boolean) {
113
+ this.isEditMode = mode;
114
+ }
115
+
116
+ // --- Actions ---
117
+
118
+ selectNode(id: string | null) {
119
+ this.selectedNodeId = id;
120
+ // Do not auto-open modal
121
+ }
122
+
123
+ openPropertiesModal(id?: string) {
124
+ if (id) {
125
+ this.selectedNodeId = id;
126
+ }
127
+ this.isPropertiesModalOpen = true;
128
+ }
129
+
130
+ closePropertiesModal() {
131
+ this.isPropertiesModalOpen = false;
132
+ }
133
+
134
+ setDropIndicator(indicator: { parentId: string; index: number } | null) {
135
+ this.dropIndicator = indicator;
136
+ }
137
+
138
+ // --- API Integration ---
139
+
140
+ private apiBase = "http://localhost:8000/api";
141
+
142
+ async fetchSchema(formId: string = "default") {
143
+ try {
144
+ const response = await fetch(`${this.apiBase}/schema/${formId}`);
145
+ if (!response.ok) throw new Error("Failed to fetch schema");
146
+ const schema = await response.json();
147
+ this.rootNode = schema;
148
+ this.applyDefaultValues();
149
+ } catch (error) {
150
+ console.error("Error fetching schema:", error);
151
+ }
152
+ }
153
+
154
+ importFromAPIResponse(data: any) {
155
+ try {
156
+ const newSchema = transformAPIDataToSchema(data);
157
+ this.rootNode = newSchema;
158
+ this.selectedNodeId = null;
159
+ this.values = {};
160
+ this.errors = {};
161
+
162
+ // Save original report info to session variables if available
163
+ this.setSessionVariables({
164
+ reportId: data.id,
165
+ userGroupId: data.userGroupId || 1,
166
+ stageStatus: data.stageStatus || "T"
167
+ });
168
+
169
+ // Save pool of available columns for mapping in PropertiesPanel
170
+ this.availableApiColumns = data.columns || [];
171
+
172
+ this.applyDefaultValues();
173
+ console.log("Schema successfully imported from API response");
174
+ } catch (error) {
175
+ console.error("Error importing from API response:", error);
176
+ }
177
+ }
178
+
179
+ setSessionVariables(vars: Record<string, any>) {
180
+ this.sessionVariables = { ...this.sessionVariables, ...vars };
181
+ }
182
+
183
+ getSubmissionData() {
184
+ return transformSchemaToSubmission(
185
+ this.rootNode,
186
+ toJS(this.values),
187
+ toJS(this.sessionVariables)
188
+ );
189
+ }
190
+
191
+ importDictionaryData(nodeId: string, data: any, labelKey?: string) {
192
+ const node = this.findNode(this.rootNode, nodeId);
193
+ if (node) {
194
+ const options = transformDictionaryToOptions(data, labelKey);
195
+ if (!node.props) node.props = {};
196
+ node.props.options = options;
197
+ }
198
+ }
199
+
200
+ async saveSchema(formId: string = "default") {
201
+ try {
202
+ const response = await fetch(`${this.apiBase}/schema/${formId}`, {
203
+ method: "POST",
204
+ headers: { "Content-Type": "application/json" },
205
+ body: JSON.stringify(toJS(this.rootNode))
206
+ });
207
+ if (!response.ok) throw new Error("Failed to save schema");
208
+ console.log("Schema saved successfully");
209
+ } catch (error) {
210
+ console.error("Error saving schema:", error);
211
+ }
212
+ }
213
+
214
+ async submitForm(formId: string = "default") {
215
+ try {
216
+ const response = await fetch(`${this.apiBase}/submit/${formId}`, {
217
+ method: "POST",
218
+ headers: { "Content-Type": "application/json" },
219
+ body: JSON.stringify(this.values)
220
+ });
221
+ if (!response.ok) throw new Error("Failed to submit form");
222
+ const result = await response.json();
223
+ console.log("Form submitted successfully:", result);
224
+ return result;
225
+ } catch (error) {
226
+ console.error("Error submitting form:", error);
227
+ throw error;
228
+ }
229
+ }
230
+
231
+ // --- External API Integration Actions ---
232
+
233
+ async fetchExternalFields(integrationId: string) {
234
+ const integration = this.rootNode.props?.integrations?.find((i: APIIntegration) => i.id === integrationId);
235
+ if (!integration || !integration.fetchFieldsURL) return;
236
+
237
+ this.isFetchingExternalFields = true;
238
+ try {
239
+ const response = await fetch(integration.fetchFieldsURL);
240
+ if (!response.ok) throw new Error("Failed to fetch external fields");
241
+ const fields = await response.json();
242
+ integration.availableFields = fields;
243
+ } catch (error) {
244
+ console.error("Error fetching external fields:", error);
245
+ } finally {
246
+ this.isFetchingExternalFields = false;
247
+ }
248
+ }
249
+
250
+ async submitToIntegration(integrationId: string) {
251
+ const integration = this.rootNode.props?.integrations?.find((i: APIIntegration) => i.id === integrationId);
252
+ if (!integration || !integration.submitURL) return;
253
+
254
+ const mappedData = this.prepareMappedData();
255
+
256
+ try {
257
+ const response = await fetch(integration.submitURL, {
258
+ method: integration.method,
259
+ headers: {
260
+ "Content-Type": "application/json",
261
+ ...integration.headers
262
+ },
263
+ body: JSON.stringify(mappedData)
264
+ });
265
+ if (!response.ok) throw new Error("Failed to submit to integration");
266
+ const result = await response.json();
267
+ console.log(`Submitted to ${integration.name}:`, result);
268
+ return result;
269
+ } catch (error) {
270
+ console.error(`Error submitting to ${integration.name}:`, error);
271
+ throw error;
272
+ }
273
+ }
274
+
275
+ private prepareMappedData(): Record<string, any> {
276
+ const result: Record<string, any> = {};
277
+ const traverse = (node: SchemaNode) => {
278
+ if (node.props?.name && node.apiMapping) {
279
+ const val = this.getValue(node.props.name);
280
+ if (val !== undefined) {
281
+ result[node.apiMapping] = val;
282
+ }
283
+ }
284
+ node.children?.forEach(traverse);
285
+ };
286
+ traverse(this.rootNode);
287
+ return result;
288
+ }
289
+
290
+ addIntegration(integration: APIIntegration) {
291
+ if (!this.rootNode.props) this.rootNode.props = {};
292
+ if (!this.rootNode.props.integrations) this.rootNode.props.integrations = [];
293
+ this.rootNode.props.integrations.push(integration);
294
+ }
295
+
296
+ updateIntegration(id: string, updates: Partial<APIIntegration>) {
297
+ const integration = this.rootNode.props?.integrations?.find((i: APIIntegration) => i.id === id);
298
+ if (integration) {
299
+ Object.assign(integration, updates);
300
+ }
301
+ }
302
+
303
+ removeIntegration(id: string) {
304
+ if (this.rootNode.props?.integrations) {
305
+ this.rootNode.props.integrations = this.rootNode.props.integrations.filter((i: APIIntegration) => i.id !== id);
306
+ }
307
+ }
308
+
309
+ // --- Validation ---
310
+
311
+ validateField(path: string, value: any, rules?: ValidationRules) {
312
+ if (!rules) return;
313
+
314
+ let error: string | null = null;
315
+
316
+ if (rules.required) {
317
+ if (value === undefined || value === null || value === '') {
318
+ error = i18next.t('validation.required');
319
+ }
320
+ }
321
+
322
+ if (!error && rules.integer && typeof value === 'number') {
323
+ if (!Number.isInteger(value)) {
324
+ error = i18next.t('validation.integer') || 'Must be an integer';
325
+ }
326
+ }
327
+
328
+ if (!error && rules.min !== undefined && typeof value === 'number') {
329
+ const minVal = typeof rules.min === 'string' ? this.evaluateFormula(rules.min) : rules.min;
330
+ if (typeof minVal === 'number' && value < minVal) {
331
+ error = i18next.t('validation.min', { min: minVal });
332
+ }
333
+ }
334
+
335
+ if (!error && rules.max !== undefined && typeof value === 'number') {
336
+ const maxVal = typeof rules.max === 'string' ? this.evaluateFormula(rules.max) : rules.max;
337
+ if (typeof maxVal === 'number' && value > maxVal) {
338
+ error = i18next.t('validation.max', { max: maxVal });
339
+ }
340
+ }
341
+
342
+ if (!error && (typeof rules.minLength === 'number') && typeof value === 'string') {
343
+ if (value.length < rules.minLength) {
344
+ error = i18next.t('validation.minLength', { min: rules.minLength });
345
+ }
346
+ }
347
+
348
+ if (!error && (typeof rules.maxLength === 'number') && typeof value === 'string') {
349
+ if (value.length > rules.maxLength) {
350
+ error = i18next.t('validation.maxLength', { max: rules.maxLength });
351
+ }
352
+ }
353
+
354
+ if (!error && rules.email && typeof value === 'string') {
355
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
356
+ if (value && !emailRegex.test(value)) {
357
+ error = i18next.t('validation.email');
358
+ }
359
+ }
360
+
361
+ if (!error && rules.pattern && typeof value === 'string') {
362
+ try {
363
+ const regex = new RegExp(rules.pattern);
364
+ if (!regex.test(value)) {
365
+ error = i18next.t('validation.pattern');
366
+ }
367
+ } catch (e) {
368
+ console.warn('Invalid regex pattern', rules.pattern, e);
369
+ }
370
+ }
371
+
372
+ if (error) {
373
+ this.errors[path] = error;
374
+ } else {
375
+ delete this.errors[path];
376
+ }
377
+ }
378
+
379
+ evaluateCondition(condition: Condition): boolean {
380
+ const fieldValue = this.getValue(condition.field);
381
+
382
+ switch (condition.op) {
383
+ case 'eq':
384
+ return fieldValue === condition.value;
385
+ case 'neq':
386
+ return fieldValue !== condition.value;
387
+ case 'gt':
388
+ return typeof fieldValue === 'number' && typeof condition.value === 'number'
389
+ ? fieldValue > condition.value
390
+ : false;
391
+ case 'lt':
392
+ return typeof fieldValue === 'number' && typeof condition.value === 'number'
393
+ ? fieldValue < condition.value
394
+ : false;
395
+ case 'contains':
396
+ if (typeof fieldValue === 'string' && typeof condition.value === 'string') {
397
+ return fieldValue.includes(condition.value);
398
+ }
399
+ if (Array.isArray(fieldValue)) {
400
+ return fieldValue.includes(condition.value);
401
+ }
402
+ return false;
403
+ default:
404
+ return true;
405
+ }
406
+ }
407
+
408
+ evaluateFormula(formula: string): any {
409
+ try {
410
+ // Replace {{fieldName}} with actual values
411
+ const evaluated = formula.replace(/\{\{([^}]+)\}\}/g, (_, fieldName) => {
412
+ if (fieldName === 'today') {
413
+ const d = new Date();
414
+ const day = String(d.getDate()).padStart(2, '0');
415
+ const month = String(d.getMonth() + 1).padStart(2, '0');
416
+ const year = d.getFullYear();
417
+ return JSON.stringify(`${day}-${month}-${year}`);
418
+ }
419
+ if (fieldName === 'now') {
420
+ return JSON.stringify(new Date().toISOString());
421
+ }
422
+
423
+ const value = this.getValue(fieldName);
424
+ // If it's a number, return it raw. If it's a string, JSON.stringify it.
425
+ // If undefined/null, return '0' for safety in math.
426
+ if (value === undefined || value === null) return '0';
427
+ if (typeof value === 'string') return JSON.stringify(value);
428
+ return value.toString();
429
+ });
430
+
431
+ // Injected helper functions
432
+ const helpers = {
433
+ IF: (c: any, t: any, f: any) => (c ? t : f),
434
+ AND: (...args: any[]) => args.every(Boolean),
435
+ OR: (...args: any[]) => args.some(Boolean),
436
+ NOT: (v: any) => !v,
437
+ ABS: (v: any) => Math.abs(v),
438
+ ROUND: (v: any, p: number = 0) => {
439
+ const factor = Math.pow(10, p);
440
+ return Math.round(v * factor) / factor;
441
+ }
442
+ };
443
+
444
+ // Use a basic eval-like mechanism for simple math/logic
445
+ const helperKeys = Object.keys(helpers);
446
+ const helperVals = Object.values(helpers);
447
+ return new Function(...helperKeys, `return ${evaluated}`)(...helperVals);
448
+ } catch (e) {
449
+ console.warn('Formula evaluation failed:', formula, e);
450
+ return undefined;
451
+ }
452
+ }
453
+
454
+ runCalculations(node: SchemaNode = this.rootNode) {
455
+ if (node.calculation?.formula && node.props?.name) {
456
+ const newValue = this.evaluateFormula(node.calculation.formula);
457
+ if (newValue !== undefined) {
458
+ this.setDeepValue(this.values, node.props.name, newValue);
459
+ }
460
+ }
461
+
462
+ if (node.children) {
463
+ for (const child of node.children) {
464
+ this.runCalculations(child);
465
+ }
466
+ }
467
+ }
468
+
469
+ addNode(parentId: string, node: SchemaNode, index?: number) {
470
+ const parent = this.findNode(this.rootNode, parentId);
471
+ console.log('FormStore.addNode:', { parentId, node, index });
472
+ if (parent) {
473
+ const children = parent.children ? [...parent.children] : [];
474
+ if (index !== undefined && index >= 0 && index <= children.length) {
475
+ children.splice(index, 0, node);
476
+ } else {
477
+ children.push(node);
478
+ }
479
+ parent.children = children;
480
+
481
+ // Initialize if has default value
482
+ if (node.defaultValue !== undefined && node.props?.name) {
483
+ if (this.getValue(node.props.name) === undefined) {
484
+ this.updateValue(node.props.name, node.defaultValue);
485
+ }
486
+ }
487
+ }
488
+ }
489
+
490
+ removeNode(id: string) {
491
+ this.removeNodeRecursive(this.rootNode, id);
492
+ if (this.selectedNodeId === id) {
493
+ this.selectedNodeId = null;
494
+ }
495
+ }
496
+
497
+ updateNodeProps(id: string, props: Record<string, any>) {
498
+ const node = this.findNode(this.rootNode, id);
499
+ if (node) {
500
+ node.props = { ...node.props, ...props };
501
+ }
502
+ }
503
+
504
+ updateNodeValidation(id: string, validation: ValidationRules) {
505
+ const node = this.findNode(this.rootNode, id);
506
+ if (node) {
507
+ node.validation = { ...node.validation, ...validation };
508
+ }
509
+ }
510
+
511
+ updateNodeCondition(id: string, condition: Condition | undefined) {
512
+ const node = this.findNode(this.rootNode, id);
513
+ if (node) {
514
+ node.condition = condition;
515
+ }
516
+ }
517
+
518
+ updateNodeCalculation(id: string, calculation: { formula: string } | undefined) {
519
+ const node = this.findNode(this.rootNode, id);
520
+ if (node) {
521
+ node.calculation = calculation;
522
+ this.runCalculations();
523
+ }
524
+ }
525
+
526
+ updateNode(id: string, updates: Partial<SchemaNode>) {
527
+ const node = this.findNode(this.rootNode, id);
528
+ if (node) {
529
+ Object.assign(node, updates);
530
+ }
531
+ }
532
+
533
+ resizeTable(tableId: string, newRows: number, newCols: number) {
534
+ const node = this.findNode(this.rootNode, tableId);
535
+ if (!node || node.type !== 'table') return;
536
+
537
+ if (!node.children) node.children = [];
538
+ const currentCells = node.children;
539
+
540
+ // We assume cells are stored in row-major order: (0,0), (0,1), ... (1,0), (1,1) ...
541
+ // But children list is just a flat list.
542
+ // Ideally we re-construct the list to match new dimensions, preserving existing cells where possible.
543
+
544
+ const oldCols = node.props?.cols || 1;
545
+ // const oldRows = Math.ceil(currentCells.length / oldCols);
546
+
547
+ const newCells: SchemaNode[] = [];
548
+
549
+ // Helper to generate a basic cell
550
+ const createCell = () => ({
551
+ id: `cell-${Math.random().toString(36).substr(2, 9)}`,
552
+ type: 'cell' as NodeType,
553
+ children: []
554
+ });
555
+
556
+ for (let r = 0; r < newRows; r++) {
557
+ for (let c = 0; c < newCols; c++) {
558
+ // Try to find if a cell existed at this position (r, c)
559
+ // Original index = r * oldCols + c
560
+ if (c < oldCols && r * oldCols + c < currentCells.length) {
561
+ // Check bounds. If we are within the old width, we might map to an existing cell
562
+ const oldIndex = r * oldCols + c;
563
+ // Ensure we don't pick undefined if shrinking rows
564
+ if (currentCells[oldIndex]) {
565
+ newCells.push(currentCells[oldIndex]);
566
+ } else {
567
+ newCells.push(createCell());
568
+ }
569
+ } else {
570
+ // New column or new row
571
+ newCells.push(createCell());
572
+ }
573
+ }
574
+ }
575
+
576
+ node.children = newCells;
577
+ this.updateNodeProps(tableId, { rows: newRows, cols: newCols });
578
+ }
579
+
580
+ updateValue(path: string, value: any) {
581
+ this.setDeepValue(this.values, path, value);
582
+ this.applyDefaultValues();
583
+ this.runCalculations();
584
+ }
585
+
586
+ updateField(path: string, value: any, rules?: ValidationRules) {
587
+ this.updateValue(path, value);
588
+ if (rules) {
589
+ this.validateField(path, value, rules);
590
+ }
591
+ }
592
+
593
+ getValue(path: string) {
594
+ // Handle bracket notation like addresses[0].city
595
+ const normalizedPath = path.replace(/\[(\d+)\]/g, '.$1');
596
+ const keys = normalizedPath.split('.');
597
+ let current: any = this.values;
598
+
599
+ for (const key of keys) {
600
+ if (current === undefined || current === null) return undefined;
601
+ current = current[key];
602
+ }
603
+ return current;
604
+ }
605
+
606
+ private setDeepValue(obj: any, path: string, value: any) {
607
+ const normalizedPath = path.replace(/\[(\d+)\]/g, '.$1');
608
+ const keys = normalizedPath.split('.');
609
+ let current = obj;
610
+
611
+ for (let i = 0; i < keys.length - 1; i++) {
612
+ const key = keys[i];
613
+ const nextKey = keys[i + 1];
614
+
615
+ // If property doesn't exist
616
+ if (current[key] === undefined) {
617
+ // If next key is a number, create an array, otherwise an object
618
+ current[key] = !isNaN(Number(nextKey)) ? [] : {};
619
+ }
620
+
621
+ current = current[key];
622
+ }
623
+
624
+ current[keys[keys.length - 1]] = value;
625
+ }
626
+
627
+ get selectedNode() {
628
+ return this.selectedNodeId ? this.findNode(this.rootNode, this.selectedNodeId) : null;
629
+ }
630
+
631
+ // --- Helpers ---
632
+
633
+ getAllFieldNames(root: SchemaNode = this.rootNode, names: string[] = []): string[] {
634
+ if (root.props?.name) {
635
+ names.push(root.props.name);
636
+ }
637
+ if (root.children) {
638
+ for (const child of root.children) {
639
+ this.getAllFieldNames(child, names);
640
+ }
641
+ }
642
+ return Array.from(new Set(names)); // Return unique names
643
+ }
644
+
645
+ moveNode(activeId: string, overId: string) {
646
+ const activeNode = this.findNode(this.rootNode, activeId);
647
+ const activeParent = this.findParent(this.rootNode, activeId);
648
+ const overNode = this.findNode(this.rootNode, overId);
649
+
650
+ // If we drop on a container, the parent is the container itself
651
+ const containerTypes: NodeType[] = ['root', 'row', 'col', 'tabs', 'tab', 'paper', 'repeater', 'table', 'cell'];
652
+ const isOverContainer = overNode && containerTypes.includes(overNode.type);
653
+ const overParent = isOverContainer ? overNode : this.findParent(this.rootNode, overId);
654
+
655
+ if (!activeNode || !activeParent || !activeParent.children) {
656
+ console.warn('MoveNode: Active node or parent not found');
657
+ return;
658
+ }
659
+
660
+ if (!overParent || !overParent.children) {
661
+ console.warn('MoveNode: Target parent not found');
662
+ return;
663
+ }
664
+
665
+ // Same parent reordering
666
+ if (activeParent.id === overParent.id && !isOverContainer) {
667
+ const oldIndex = activeParent.children.findIndex(c => c.id === activeId);
668
+ const newIndex = overParent.children.findIndex(c => c.id === overId);
669
+
670
+ if (oldIndex !== -1 && newIndex !== -1) {
671
+ const newChildren = [...activeParent.children];
672
+ const [moved] = newChildren.splice(oldIndex, 1);
673
+ newChildren.splice(newIndex, 0, moved);
674
+ activeParent.children = newChildren;
675
+ console.log(`Reordered in parent ${activeParent.id}: ${oldIndex} -> ${newIndex}`);
676
+ }
677
+ return;
678
+ }
679
+
680
+ // Cross-parent move or drop on container
681
+ const oldIndex = activeParent.children.findIndex(c => c.id === activeId);
682
+ if (oldIndex === -1) return;
683
+
684
+ const newChildrenActive = [...activeParent.children];
685
+ const [movedNode] = newChildrenActive.splice(oldIndex, 1);
686
+ activeParent.children = newChildrenActive;
687
+
688
+ if (isOverContainer && overNode) {
689
+ // Drop directly into a container
690
+ const newChildrenOver = overNode.children ? [...overNode.children] : [];
691
+ newChildrenOver.push(movedNode);
692
+ overNode.children = newChildrenOver;
693
+ console.log(`Moved to container ${overNode.id}`);
694
+ } else if (overParent) {
695
+ // Drop onto a sibling in a different parent
696
+ const newIndex = overParent.children.findIndex(c => c.id === overId);
697
+ const newChildrenOver = [...overParent.children];
698
+ if (newIndex !== -1) {
699
+ newChildrenOver.splice(newIndex, 0, movedNode);
700
+ } else {
701
+ newChildrenOver.push(movedNode);
702
+ }
703
+ overParent.children = newChildrenOver;
704
+ console.log(`Moved to parent ${overParent.id} at index ${newIndex}`);
705
+ }
706
+ }
707
+
708
+ // --- Persistence ---
709
+
710
+ getAllNodes(): SchemaNode[] {
711
+ const nodes: SchemaNode[] = [];
712
+ const traverse = (node: SchemaNode) => {
713
+ nodes.push(node);
714
+ node.children?.forEach(traverse);
715
+ };
716
+ traverse(this.rootNode);
717
+ return nodes;
718
+ }
719
+
720
+ saveToStorage() {
721
+ try {
722
+ localStorage.setItem('form_schema', JSON.stringify(this.rootNode));
723
+ console.log('Schema saved to localStorage');
724
+ } catch (e) {
725
+ console.error('Failed to save schema', e);
726
+ }
727
+ }
728
+
729
+ loadFromStorage() {
730
+ try {
731
+ const stored = localStorage.getItem('form_schema');
732
+ if (stored) {
733
+ this.rootNode = JSON.parse(stored);
734
+ this.applyDefaultValues();
735
+ console.log('Schema loaded from localStorage');
736
+ }
737
+ } catch (e) {
738
+ console.error('Failed to load schema', e);
739
+ }
740
+ }
741
+
742
+ // --- Helpers ---
743
+
744
+ applyDefaultValues(node: SchemaNode = this.rootNode) {
745
+ if (node.defaultValue !== undefined && node.props?.name) {
746
+ const currentValue = this.getValue(node.props.name);
747
+ // Apply if value is missing OR if it's a formula (to keep it reactive)
748
+ // Note: This might overwrite manual input if the user cleared the field.
749
+ // But usually formulas in default values are expected to be reactive until a hard value is set.
750
+ const isFormula = typeof node.defaultValue === 'string' && node.defaultValue.includes('{{');
751
+
752
+ if (currentValue === undefined || currentValue === null || currentValue === '' || isFormula) {
753
+ let val = node.defaultValue;
754
+ if (isFormula) {
755
+ val = this.evaluateFormula(val);
756
+ }
757
+
758
+ // Only update if value actually changed to avoid infinite loops
759
+ if (val !== currentValue) {
760
+ this.setDeepValue(this.values, node.props.name, val);
761
+ // We use setDeepValue directly here to avoid re-triggering this.updateValue -> infinite loop
762
+ }
763
+ }
764
+ }
765
+
766
+ if (node.children) {
767
+ for (const child of node.children) {
768
+ this.applyDefaultValues(child);
769
+ }
770
+ }
771
+ }
772
+
773
+ findNode(root: SchemaNode, id: string): SchemaNode | null {
774
+ if (root.id === id) return root;
775
+ if (root.children) {
776
+ for (const child of root.children) {
777
+ const found = this.findNode(child, id);
778
+ if (found) return found;
779
+ }
780
+ }
781
+ return null;
782
+ }
783
+
784
+ findParent(root: SchemaNode, childId: string): SchemaNode | null {
785
+ if (!root.children) return null;
786
+
787
+ for (const child of root.children) {
788
+ if (child.id === childId) return root;
789
+ const found = this.findParent(child, childId);
790
+ if (found) return found;
791
+ }
792
+ return null;
793
+ }
794
+
795
+ private removeNodeRecursive(parent: SchemaNode, id: string): boolean {
796
+ if (!parent.children) return false;
797
+
798
+ const index = parent.children.findIndex(c => c.id === id);
799
+ if (index !== -1) {
800
+ parent.children.splice(index, 1);
801
+ return true;
802
+ }
803
+
804
+ for (const child of parent.children) {
805
+ if (this.removeNodeRecursive(child, id)) return true;
806
+ }
807
+ return false;
808
+ }
809
+ }
810
+
811
+ export const formStore = new FormStore();