@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.
- package/README.md +73 -0
- package/eslint.config.js +23 -0
- package/index.html +13 -0
- package/package.json +60 -0
- package/public/vite.svg +1 -0
- package/src/App.css +42 -0
- package/src/App.tsx +83 -0
- package/src/assets/react.svg +1 -0
- package/src/components/FieldRegistry.ts +34 -0
- package/src/components/FormContainer.tsx +25 -0
- package/src/components/FormRenderer.tsx +121 -0
- package/src/components/builder/DraggableTool.tsx +66 -0
- package/src/components/builder/DroppableCanvas.tsx +51 -0
- package/src/components/builder/EditorWrapper.tsx +87 -0
- package/src/components/builder/FormBuilder.tsx +313 -0
- package/src/components/builder/FormChildrenRenderer.tsx +68 -0
- package/src/components/builder/IntegrationSettings.tsx +110 -0
- package/src/components/builder/PropertiesModal.tsx +75 -0
- package/src/components/builder/PropertiesPanel.tsx +858 -0
- package/src/components/builder/SortableNode.tsx +53 -0
- package/src/components/builder/Toolbox.tsx +123 -0
- package/src/components/fields/CheckboxField.tsx +41 -0
- package/src/components/fields/DateField.tsx +56 -0
- package/src/components/fields/FileUploadField.tsx +45 -0
- package/src/components/fields/LabelField.tsx +20 -0
- package/src/components/fields/NumberField.tsx +39 -0
- package/src/components/fields/RichTextField.tsx +39 -0
- package/src/components/fields/SelectField.tsx +64 -0
- package/src/components/fields/TextField.tsx +44 -0
- package/src/components/layout/FormCol.tsx +30 -0
- package/src/components/layout/FormDivider.tsx +19 -0
- package/src/components/layout/FormPaper.tsx +85 -0
- package/src/components/layout/FormRepeater.tsx +130 -0
- package/src/components/layout/FormRow.tsx +61 -0
- package/src/components/layout/FormTab.tsx +33 -0
- package/src/components/layout/FormTable.tsx +47 -0
- package/src/components/layout/FormTableCell.tsx +47 -0
- package/src/components/layout/FormTabs.tsx +77 -0
- package/src/components/layout/LayoutPlaceholder.tsx +85 -0
- package/src/components/registerComponents.ts +30 -0
- package/src/index.css +75 -0
- package/src/index.ts +5 -0
- package/src/main.tsx +10 -0
- package/src/store/FormStore.ts +811 -0
- package/src/utils/apiTransformer.ts +206 -0
- package/src/utils/idGenerator.ts +3 -0
- package/tsconfig.app.json +28 -0
- package/tsconfig.json +7 -0
- package/tsconfig.node.json +26 -0
- 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();
|