@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,858 @@
1
+ import React from 'react';
2
+ import { Box, Typography, TextField, FormControl, FormLabel, Select, MenuItem, Button, Popper, Paper, List, ListItemButton, ListItemText, ClickAwayListener, Tabs, Tab, InputLabel } from '@mui/material';
3
+ import { observer } from 'mobx-react-lite';
4
+ import { formStore } from '../../store/FormStore';
5
+ import type { SchemaNode } from '../../store/FormStore';
6
+ import DeleteIcon from '@mui/icons-material/Delete';
7
+ import { useTranslation } from 'react-i18next';
8
+
9
+ interface TabPanelProps {
10
+ children?: React.ReactNode;
11
+ index: number;
12
+ value: number;
13
+ }
14
+
15
+ function CustomTabPanel(props: TabPanelProps) {
16
+ const { children, value, index, ...other } = props;
17
+
18
+ return (
19
+ <Box
20
+ role="tabpanel"
21
+ hidden={value !== index}
22
+ id={`simple-tabpanel-${index}`}
23
+ aria-labelledby={`simple-tab-${index}`}
24
+ {...other}
25
+ sx={{
26
+ flexGrow: 1,
27
+ overflow: 'hidden',
28
+ display: value === index ? 'flex' : 'none',
29
+ flexDirection: 'column'
30
+ }}
31
+ >
32
+ {value === index && (
33
+ <Box sx={{ p: 2, pt: 2, flexGrow: 1, overflow: 'auto', display: 'flex', flexDirection: 'column', gap: 2 }}>
34
+ {children}
35
+ </Box>
36
+ )}
37
+ </Box>
38
+ );
39
+ }
40
+
41
+ function a11yProps(index: number) {
42
+ return {
43
+ id: `simple-tab-${index}`,
44
+ 'aria-controls': `simple-tabpanel-${index}`,
45
+ };
46
+ }
47
+
48
+ export const PropertiesPanel: React.FC = observer(() => {
49
+ const { t } = useTranslation();
50
+ const node = formStore.selectedNode;
51
+ const [tabValue, setTabValue] = React.useState(0);
52
+
53
+ const isLayout = node ? ['root', 'row', 'col', 'tabs', 'tab', 'paper', 'repeater', 'table', 'cell', 'divider'].includes(node.type) : false;
54
+
55
+ const allTabs = [
56
+ { label: t("properties.tabView"), index: 0, visible: true },
57
+ { label: t("properties.tabValidation"), index: 1, visible: !isLayout },
58
+ { label: t("properties.tabMapping"), index: 4, visible: !isLayout }, // Added mapping tab
59
+ { label: t("properties.tabLogic"), index: 2, visible: true },
60
+ { label: t("properties.tabCalc"), index: 3, visible: !isLayout },
61
+ ];
62
+
63
+ const visibleTabs = allTabs.filter(t => t.visible);
64
+
65
+ React.useEffect(() => {
66
+ if (tabValue >= visibleTabs.length) {
67
+ setTabValue(0);
68
+ }
69
+ }, [node?.id, visibleTabs.length]);
70
+
71
+ if (!node) {
72
+ return (
73
+ <Box p={2}>
74
+ <Typography variant="body2" color="text.secondary">
75
+ {t('properties.noSelection')}
76
+ </Typography>
77
+ </Box>
78
+ );
79
+ }
80
+
81
+ // Autocomplete State
82
+ const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
83
+ const [filter, setFilter] = React.useState('');
84
+ const [cursorPos, setCursorPos] = React.useState(0);
85
+ // Track which field checks for autocomplete
86
+ const [activeFormulaField, setActiveFormulaField] = React.useState<'calculation' | 'defaultValue' | 'min' | 'max' | null>(null);
87
+
88
+ const activeGlobalIndex = visibleTabs[tabValue]?.index ?? 0;
89
+
90
+ const handleChange = (key: string, value: any) => {
91
+ formStore.updateNodeProps(node.id, { [key]: value });
92
+ };
93
+
94
+ const handleDelete = () => {
95
+ formStore.removeNode(node.id);
96
+ formStore.closePropertiesModal(); // Also close modal on delete
97
+ };
98
+
99
+ const handleTabChange = (_event: React.SyntheticEvent, newValue: number) => {
100
+ setTabValue(newValue);
101
+ };
102
+
103
+ const triggerAutocomplete = (event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>, fieldType: 'calculation' | 'defaultValue' | 'min' | 'max') => {
104
+ const val = event.target.value;
105
+ const selectionStart = event.target.selectionStart || 0;
106
+ setCursorPos(selectionStart);
107
+ setActiveFormulaField(fieldType);
108
+
109
+ // Detect {{ trigger
110
+ const textBeforeCursor = val.slice(0, selectionStart);
111
+ const match = textBeforeCursor.match(/\{\{([^}]*)$/);
112
+
113
+ if (match) {
114
+ setFilter(match[1]);
115
+ setAnchorEl(event.currentTarget);
116
+ } else {
117
+ setAnchorEl(null);
118
+ }
119
+ };
120
+
121
+ const handleSelectField = (name: string) => {
122
+ if (!node) return;
123
+ let currentContent = '';
124
+ if (activeFormulaField === 'calculation') {
125
+ currentContent = node.calculation?.formula || '';
126
+ } else if (activeFormulaField === 'defaultValue') {
127
+ currentContent = node.defaultValue || '';
128
+ } else if (activeFormulaField === 'min') {
129
+ currentContent = String(node.validation?.min || '');
130
+ } else if (activeFormulaField === 'max') {
131
+ currentContent = String(node.validation?.max || '');
132
+ }
133
+
134
+ const before = currentContent.slice(0, cursorPos);
135
+ const after = currentContent.slice(cursorPos);
136
+ const lastBraces = before.lastIndexOf('{{');
137
+
138
+ if (lastBraces !== -1) {
139
+ const newBefore = before.slice(0, lastBraces) + `{{${name}}}`;
140
+ const newContent = newBefore + after;
141
+
142
+ if (activeFormulaField === 'defaultValue') {
143
+ formStore.updateNode(node.id, { defaultValue: newContent });
144
+ formStore.applyDefaultValues();
145
+ } else if (activeFormulaField === 'calculation') {
146
+ formStore.updateNodeCalculation(node.id, { formula: newContent });
147
+ } else if (activeFormulaField === 'min') {
148
+ formStore.updateNodeValidation(node.id, { min: newContent });
149
+ } else if (activeFormulaField === 'max') {
150
+ formStore.updateNodeValidation(node.id, { max: newContent });
151
+ }
152
+ setCursorPos(newBefore.length);
153
+ }
154
+ setAnchorEl(null);
155
+ };
156
+
157
+ const FormulaHints = ({ fieldType }: { fieldType: 'calculation' | 'defaultValue' | 'min' | 'max' }) => {
158
+ const insertText = (text: string) => {
159
+ let current = '';
160
+ if (fieldType === 'calculation') current = node.calculation?.formula || '';
161
+ else if (fieldType === 'defaultValue') current = node.defaultValue || '';
162
+ else if (fieldType === 'min') current = String(node.validation?.min || '');
163
+ else if (fieldType === 'max') current = String(node.validation?.max || '');
164
+
165
+ const newVal = current + text;
166
+ if (fieldType === 'calculation') formStore.updateNodeCalculation(node.id, { formula: newVal });
167
+ else if (fieldType === 'defaultValue') {
168
+ formStore.updateNode(node.id, { defaultValue: newVal });
169
+ formStore.applyDefaultValues();
170
+ }
171
+ else if (fieldType === 'min') formStore.updateNodeValidation(node.id, { min: newVal });
172
+ else if (fieldType === 'max') formStore.updateNodeValidation(node.id, { max: newVal });
173
+ };
174
+
175
+ return (
176
+ <Box mt={1} display="flex" flexWrap="wrap" gap={0.5}>
177
+ <Typography variant="caption" width="100%" color="text.secondary">{t("properties.hints")}</Typography>
178
+ {['today', 'now', ...formStore.getAllFieldNames()]
179
+ .filter(name => name !== node.props?.name)
180
+ .map(name => (
181
+ <Button
182
+ key={name}
183
+ size="small"
184
+ variant="outlined"
185
+ sx={{ textTransform: 'none', px: 1, py: 0, fontSize: '0.7rem' }}
186
+ onClick={() => insertText(`{{${name}}}`)}
187
+ >
188
+ {name}
189
+ </Button>
190
+ ))}
191
+
192
+ <Box width="100%" mt={1}>
193
+ <Typography variant="caption" color="text.secondary">{t("properties.functions") || "Functions"}</Typography>
194
+ </Box>
195
+ {['IF()', 'AND()', 'OR()', 'NOT()', 'ABS()', 'ROUND()'].map(func => (
196
+ <Button
197
+ key={func}
198
+ size="small"
199
+ variant="outlined"
200
+ color="secondary"
201
+ sx={{ textTransform: 'none', px: 1, py: 0, fontSize: '0.7rem' }}
202
+ onClick={() => insertText(func)}
203
+ >
204
+ {func}
205
+ </Button>
206
+ ))}
207
+ </Box>
208
+ );
209
+ };
210
+
211
+ return (
212
+ <Box display="flex" flexDirection="column" height="100%">
213
+ {/* Unified Autocomplete Popper */}
214
+ <Popper open={Boolean(anchorEl)} anchorEl={anchorEl} placement="bottom-start" style={{ zIndex: 1500 }}>
215
+ <ClickAwayListener onClickAway={() => setAnchorEl(null)}>
216
+ <Paper sx={{ mt: 1, maxHeight: 200, overflow: 'auto', minWidth: 200 }}>
217
+ <List dense>
218
+ {['today', 'now', ...formStore.getAllFieldNames()]
219
+ .filter(name => name !== node.props?.name && name.toLowerCase().includes(filter.toLowerCase()))
220
+ .map(name => (
221
+ <ListItemButton key={name} onClick={() => handleSelectField(name)}>
222
+ <ListItemText
223
+ primary={name}
224
+ secondary={name === 'today' || name === 'now' ? 'System variable' : `{{${name}}}`}
225
+ primaryTypographyProps={{ variant: 'body2' }}
226
+ />
227
+ </ListItemButton>
228
+ ))}
229
+ </List>
230
+ </Paper>
231
+ </ClickAwayListener>
232
+ </Popper>
233
+
234
+ {/* Header always visible */}
235
+ <Box p={2} borderBottom={1} borderColor="divider" display="flex" alignItems="center" justifyContent="space-between" bgcolor="background.default">
236
+ <Box>
237
+ <Typography variant="subtitle1" fontWeight="bold">{t('properties.title')}</Typography>
238
+ <Typography variant="caption" color="text.secondary">
239
+ {node.type.toUpperCase()} | ID: {node.id}
240
+ </Typography>
241
+ </Box>
242
+ <Button
243
+ size="small"
244
+ color="error"
245
+ startIcon={<DeleteIcon />}
246
+ onClick={handleDelete}
247
+ >
248
+ {t("properties.delete")}
249
+ </Button>
250
+ </Box>
251
+
252
+ <Box borderBottom={1} borderColor="divider" mb={0.5}>
253
+ <Tabs value={tabValue} onChange={handleTabChange} aria-label="properties tabs" variant="fullWidth">
254
+ {visibleTabs.map((tab, i) => (
255
+ <Tab key={tab.index} label={tab.label} {...a11yProps(i)} />
256
+ ))}
257
+ </Tabs>
258
+ </Box>
259
+
260
+ {/* View Tab */}
261
+ <CustomTabPanel value={activeGlobalIndex} index={0}>
262
+ {/* Common Props */}
263
+ {node.type !== 'root' && (
264
+ <Box mb={0.5}>
265
+ {formStore.availableApiColumns.length > 0 ? (
266
+ <FormControl fullWidth size="small">
267
+ <InputLabel>{t('properties.fieldId')}</InputLabel>
268
+ <Select
269
+ label={t('properties.fieldId')}
270
+ value={node.props?.name || ''}
271
+ onChange={(e) => {
272
+ const colId = e.target.value;
273
+ const col = formStore.availableApiColumns.find(c => c.id.toString() === colId);
274
+ if (col) {
275
+ formStore.updateNodeProps(node.id, {
276
+ name: col.id.toString(),
277
+ label: col.colTitle,
278
+ apiColumnName: col.colName
279
+ });
280
+ // Enforce numeric validation based on API type
281
+ if (node.type === 'number') {
282
+ formStore.updateNodeValidation(node.id, {
283
+ integer: col.colType === 'INT'
284
+ });
285
+ }
286
+ } else {
287
+ handleChange('name', colId);
288
+ }
289
+ }}
290
+ >
291
+ <MenuItem value=""><em>None</em></MenuItem>
292
+ {formStore.availableApiColumns
293
+ .filter(col => {
294
+ // 1. Type Compatibility Check
295
+ const compatibleTypes: Record<string, string[]> = {
296
+ 'text': ['STRING'],
297
+ 'number': ['INT', 'FLOAT'],
298
+ 'checkbox': ['BOOLEAN'],
299
+ 'date': ['TIMESTAMP', 'TIMESTAMP_2'],
300
+ 'file': ['FILE', 'FILE_SET'],
301
+ 'select': ['REF']
302
+ };
303
+ const allowed = compatibleTypes[node.type] || [];
304
+ if (!allowed.includes(col.colType)) return false;
305
+
306
+ // 2. Uniqueness Check: Check if this column ID is used by ANY OTHER node
307
+ const isUsedByOther = formStore.getAllNodes().some((n: SchemaNode) =>
308
+ n.id !== node.id && n.props?.name === col.id.toString()
309
+ );
310
+ return !isUsedByOther;
311
+ })
312
+ .map(col => (
313
+ <MenuItem key={col.id} value={col.id.toString()}>
314
+ {col.colTitle} ({col.colName})
315
+ </MenuItem>
316
+ ))
317
+ }
318
+ </Select>
319
+ <Typography variant="caption" color="text.secondary">
320
+ {t('properties.fieldName')}
321
+ </Typography>
322
+ </FormControl>
323
+ ) : (
324
+ <TextField
325
+ label={t('properties.fieldId')}
326
+ size="small"
327
+ fullWidth
328
+ value={node.props?.name || ''}
329
+ onChange={(e) => handleChange('name', e.target.value)}
330
+ helperText={t('properties.fieldName')}
331
+ />
332
+ )}
333
+ </Box>
334
+ )}
335
+
336
+ <Box mb={0.5}>
337
+ <TextField
338
+ label={node.type === 'root' ? t("properties.formTitle") : (t('properties.label') || "Label")}
339
+ size="small"
340
+ fullWidth
341
+ multiline={node.type === 'label'}
342
+ rows={node.type === 'label' ? 3 : 1}
343
+ value={node.props?.label || ''}
344
+ onChange={(e) => handleChange('label', e.target.value)}
345
+ />
346
+ </Box>
347
+
348
+ <Box mb={2}>
349
+ <Typography variant="caption" color="text.secondary" display="block" mb={0.5}>
350
+ {t("properties.defaultValue")}
351
+ </Typography>
352
+ {node.type === 'select' ? (
353
+ <FormControl fullWidth size="small">
354
+ <Select
355
+ value={node.defaultValue ?? ''}
356
+ onChange={(e) => {
357
+ formStore.updateNode(node.id, { defaultValue: e.target.value });
358
+ formStore.applyDefaultValues();
359
+ }}
360
+ displayEmpty
361
+ >
362
+ <MenuItem value=""><em>{t('properties.none')}</em></MenuItem>
363
+ {(node.props?.options || []).map((opt: any, idx: number) => (
364
+ <MenuItem key={idx} value={opt.value}>
365
+ {opt.label || <em>{t('properties.empty')}</em>}
366
+ </MenuItem>
367
+ ))}
368
+ </Select>
369
+ </FormControl>
370
+ ) : (
371
+ <>
372
+ <TextField
373
+ size="small"
374
+ fullWidth
375
+ value={node.defaultValue ?? ''}
376
+ onChange={(e) => {
377
+ const val = e.target.value;
378
+ formStore.updateNode(node.id, { defaultValue: val });
379
+ formStore.applyDefaultValues();
380
+ triggerAutocomplete(e, 'defaultValue');
381
+ }}
382
+ onKeyUp={(e) => {
383
+ const target = e.target as HTMLInputElement;
384
+ setCursorPos(target.selectionStart || 0);
385
+ }}
386
+ />
387
+ <FormulaHints fieldType="defaultValue" />
388
+ </>
389
+ )}
390
+ </Box>
391
+
392
+ {node.type !== 'root' && (
393
+ <FormControl fullWidth size="small">
394
+ <FormLabel>{t("properties.width")}</FormLabel>
395
+ <Select
396
+ value={node.props?.width ?? node.props?.cols ?? 12}
397
+ onChange={(e) => handleChange('width', Number(e.target.value))}
398
+ >
399
+ {[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].map(col => (
400
+ <MenuItem key={col} value={col}>{col} / 12</MenuItem>
401
+ ))}
402
+ </Select>
403
+ </FormControl>
404
+ )}
405
+
406
+ {/* Type Specific Props */}
407
+ {(['text', 'number', 'select'].includes(node.type)) && (
408
+ <TextField
409
+ label={t('properties.placeholder')}
410
+ size="small"
411
+ fullWidth
412
+ value={node.props?.placeholder || ''}
413
+ onChange={(e) => handleChange('placeholder', e.target.value)}
414
+ />
415
+ )}
416
+
417
+ {node.type === 'select' && (
418
+ <Box display="flex" flexDirection="column" gap={2} mt={2}>
419
+ <TextField
420
+ label={t('properties.selectOptions')}
421
+ multiline
422
+ rows={6}
423
+ size="small"
424
+ fullWidth
425
+ disabled={!!node.props?.dictionaryInfo}
426
+ value={(node.props?.options || []).map((opt: any) => opt.label).join('\n')}
427
+ onChange={(e) => {
428
+ const lines = e.target.value.split('\n');
429
+ // Only filter out the LAST empty line if it was just created by pressing Enter
430
+ // but allow internal empty lines.
431
+ const newOptions = lines.map(line => ({ label: line, value: line }));
432
+ handleChange('options', newOptions);
433
+ }}
434
+ helperText={node.props?.dictionaryInfo
435
+ ? "Options are managed by API dictionary"
436
+ : t('properties.selectOptionsHelper')}
437
+ />
438
+ <Box display="flex" alignItems="center" mt={1}>
439
+ <input
440
+ type="checkbox"
441
+ checked={node.props?.enableAutocomplete || false}
442
+ onChange={(e) => handleChange('enableAutocomplete', e.target.checked)}
443
+ style={{ marginRight: 8 }}
444
+ />
445
+ <Typography variant="body2">{t('properties.enableAutocomplete')}</Typography>
446
+ </Box>
447
+ </Box>
448
+ )}
449
+
450
+ {node.type === 'repeater' && (
451
+ <TextField
452
+ label={t('properties.addLabel')}
453
+ size="small"
454
+ fullWidth
455
+ value={node.props?.addLabel || ''}
456
+ onChange={(e) => handleChange('addLabel', e.target.value)}
457
+ />
458
+ )}
459
+
460
+ {node.type === 'tabs' && (
461
+ <Button
462
+ variant="outlined"
463
+ fullWidth
464
+ onClick={() => {
465
+ const newTabId = `tab-${Math.random().toString(36).substr(2, 9)}`;
466
+ formStore.addNode(node.id, {
467
+ id: newTabId,
468
+ type: 'tab',
469
+ props: {},
470
+ children: []
471
+ });
472
+ }}
473
+ >
474
+ {t("properties.addTab")}
475
+ </Button>
476
+ )}
477
+
478
+ {/* Typography / Label Props */}
479
+ {(node.type === 'label' || node.type === 'root') && (
480
+ node.type === 'label' && (
481
+ <Box display="flex" flexDirection="column" gap={2}>
482
+ <FormControl fullWidth size="small">
483
+ <FormLabel>{t("properties.typography")}</FormLabel>
484
+ <Select
485
+ value={node.props?.variant || 'body1'}
486
+ onChange={(e) => handleChange('variant', e.target.value)}
487
+ >
488
+ <MenuItem value="h1">Heading 1</MenuItem>
489
+ <MenuItem value="h2">Heading 2</MenuItem>
490
+ <MenuItem value="h3">Heading 3</MenuItem>
491
+ <MenuItem value="h4">Heading 4</MenuItem>
492
+ <MenuItem value="h5">Heading 5</MenuItem>
493
+ <MenuItem value="h6">Heading 6</MenuItem>
494
+ <MenuItem value="body1">Body 1</MenuItem>
495
+ <MenuItem value="body2">Body 2</MenuItem>
496
+ <MenuItem value="caption">Caption</MenuItem>
497
+ </Select>
498
+ </FormControl>
499
+ <FormControl fullWidth size="small">
500
+ <FormLabel>{t("properties.alignment")}</FormLabel>
501
+ <Select
502
+ value={node.props?.align || 'left'}
503
+ onChange={(e) => handleChange('align', e.target.value)}
504
+ >
505
+ <MenuItem value="left">{t('properties.alignLeft')}</MenuItem>
506
+ <MenuItem value="center">{t('properties.alignCenter')}</MenuItem>
507
+ <MenuItem value="right">{t('properties.alignRight')}</MenuItem>
508
+ <MenuItem value="justify">{t('properties.alignJustify')}</MenuItem>
509
+ </Select>
510
+ </FormControl>
511
+ </Box>
512
+ )
513
+ )}
514
+
515
+ {node.type === 'divider' && (
516
+ <Box display="flex" flexDirection="column" gap={2}>
517
+ <TextField
518
+ label={t('properties.dividerText')}
519
+ size="small"
520
+ fullWidth
521
+ value={node.props?.text || ''}
522
+ onChange={(e) => handleChange('text', e.target.value)}
523
+ />
524
+ <FormControl fullWidth size="small">
525
+ <FormLabel>{t('properties.alignment')}</FormLabel>
526
+ <Select
527
+ value={node.props?.align || 'center'}
528
+ onChange={(e) => handleChange('align', e.target.value)}
529
+ >
530
+ <MenuItem value="left">{t('properties.alignLeft')}</MenuItem>
531
+ <MenuItem value="center">{t('properties.alignCenter')}</MenuItem>
532
+ <MenuItem value="right">{t('properties.alignRight')}</MenuItem>
533
+ </Select>
534
+ </FormControl>
535
+ </Box>
536
+ )}
537
+
538
+ {node.type === 'table' && (
539
+ <Box display="flex" gap={1}>
540
+ <TextField
541
+ label={t('properties.rows') || "Rows"}
542
+ type="number"
543
+ size="small"
544
+ fullWidth
545
+ value={node.props?.rows || 2}
546
+ onChange={(e) => {
547
+ const newRows = parseInt(e.target.value, 10) || 1;
548
+ formStore.resizeTable(node.id, newRows, node.props?.cols || 2);
549
+ }}
550
+ />
551
+ <TextField
552
+ label={t('properties.cols') || "Cols"}
553
+ type="number"
554
+ size="small"
555
+ fullWidth
556
+ value={node.props?.cols || 2}
557
+ onChange={(e) => {
558
+ const newCols = parseInt(e.target.value, 10) || 1;
559
+ formStore.resizeTable(node.id, node.props?.rows || 2, newCols);
560
+ }}
561
+ />
562
+ </Box>
563
+ )}
564
+
565
+ {['paper', 'tabs', 'tab'].includes(node.type) && (
566
+ <Box display="flex" flexDirection="column" gap={2}>
567
+ <FormControl fullWidth size="small">
568
+ <FormLabel>{t('properties.styleVariant')}</FormLabel>
569
+ <Select
570
+ value={node.props?.variant || 'elevation'}
571
+ onChange={(e) => handleChange('variant', e.target.value)}
572
+ >
573
+ <MenuItem value="elevation">{t('properties.styleElevated')}</MenuItem>
574
+ <MenuItem value="outlined">{t('properties.styleOutlined')}</MenuItem>
575
+ <MenuItem value="transparent">{t('properties.styleTransparent')}</MenuItem>
576
+ </Select>
577
+ </FormControl>
578
+ {(!node.props?.variant || node.props?.variant === 'elevation') && (
579
+ <TextField
580
+ label={t('properties.elevationDepth')}
581
+ type="number"
582
+ size="small"
583
+ fullWidth
584
+ value={node.props?.elevation ?? 1}
585
+ onChange={(e) => handleChange('elevation', Number(e.target.value))}
586
+ />
587
+ )}
588
+ <TextField
589
+ label={t('properties.padding') || "Padding"}
590
+ type="number"
591
+ size="small"
592
+ fullWidth
593
+ value={node.props?.padding ?? 2}
594
+ onChange={(e) => handleChange('padding', Number(e.target.value))}
595
+ />
596
+ </Box>
597
+ )}
598
+ </CustomTabPanel>
599
+
600
+ {/* Validation Tab */}
601
+ <CustomTabPanel value={activeGlobalIndex} index={1}>
602
+ <Box display="flex" alignItems="center" mb={1}>
603
+ <input
604
+ type="checkbox"
605
+ checked={node.validation?.required || false}
606
+ onChange={(e) => formStore.updateNodeValidation(node.id, { required: e.target.checked })}
607
+ style={{ marginRight: 8 }}
608
+ />
609
+ <Typography variant="body2">{t('properties.required')}</Typography>
610
+ </Box>
611
+
612
+ {node.type === 'text' && (
613
+ <Box display="flex" alignItems="center" mb={1}>
614
+ <input
615
+ type="checkbox"
616
+ checked={node.validation?.email || false}
617
+ onChange={(e) => formStore.updateNodeValidation(node.id, { email: e.target.checked })}
618
+ style={{ marginRight: 8 }}
619
+ />
620
+ <Typography variant="body2">{t("properties.emailFormat")}</Typography>
621
+ </Box>
622
+ )}
623
+
624
+ {node.type === 'number' && (
625
+ <Box display="flex" alignItems="center" mb={1}>
626
+ <input
627
+ type="checkbox"
628
+ checked={node.validation?.integer || false}
629
+ disabled={!!node.props?.name && formStore.availableApiColumns.some(c => c.id.toString() === node.props?.name)}
630
+ onChange={(e) => formStore.updateNodeValidation(node.id, { integer: e.target.checked })}
631
+ style={{ marginRight: 8 }}
632
+ />
633
+ <Typography variant="body2">
634
+ {t("properties.integer")}
635
+ {node.props?.name && formStore.availableApiColumns.some(c => c.id.toString() === node.props?.name) && (
636
+ <Typography variant="caption" color="text.secondary" ml={1}>
637
+ (Enforced by API)
638
+ </Typography>
639
+ )}
640
+ </Typography>
641
+ </Box>
642
+ )}
643
+
644
+ {node.type === 'number' && (
645
+ <Box display="flex" flexDirection="column" gap={1}>
646
+ <Box mb={1}>
647
+ <TextField
648
+ label={t("properties.minValue")}
649
+ size="small"
650
+ fullWidth
651
+ value={node.validation?.min ?? ''}
652
+ onChange={(e) => {
653
+ const val = e.target.value;
654
+ formStore.updateNodeValidation(node.id, { min: val });
655
+ triggerAutocomplete(e, 'min');
656
+ }}
657
+ onKeyUp={(e) => {
658
+ const target = e.target as HTMLInputElement;
659
+ setCursorPos(target.selectionStart || 0);
660
+ }}
661
+ placeholder="e.g. 10 or {{otherField}} + 5"
662
+ />
663
+ <FormulaHints fieldType="min" />
664
+ </Box>
665
+ <Box mb={1}>
666
+ <TextField
667
+ label={t("properties.maxValue")}
668
+ size="small"
669
+ fullWidth
670
+ value={node.validation?.max ?? ''}
671
+ onChange={(e) => {
672
+ const val = e.target.value;
673
+ formStore.updateNodeValidation(node.id, { max: val });
674
+ triggerAutocomplete(e, 'max');
675
+ }}
676
+ onKeyUp={(e) => {
677
+ const target = e.target as HTMLInputElement;
678
+ setCursorPos(target.selectionStart || 0);
679
+ }}
680
+ placeholder="e.g. 100 or {{otherField}} * 2"
681
+ />
682
+ <FormulaHints fieldType="max" />
683
+ </Box>
684
+ </Box>
685
+ )}
686
+
687
+ {node.type === 'text' && (
688
+ <Box display="flex" flexDirection="column" gap={1}>
689
+ <TextField
690
+ label={t("properties.minLength")}
691
+ type="number"
692
+ size="small"
693
+ fullWidth
694
+ value={node.validation?.minLength ?? ''}
695
+ onChange={(e) => {
696
+ const val = e.target.value === '' ? undefined : Number(e.target.value);
697
+ formStore.updateNodeValidation(node.id, { minLength: val });
698
+ }}
699
+ />
700
+ <TextField
701
+ label={t("properties.maxLength")}
702
+ type="number"
703
+ size="small"
704
+ fullWidth
705
+ value={node.validation?.maxLength ?? ''}
706
+ onChange={(e) => {
707
+ const val = e.target.value === '' ? undefined : Number(e.target.value);
708
+ formStore.updateNodeValidation(node.id, { maxLength: val });
709
+ }}
710
+ />
711
+ <TextField
712
+ label={t("properties.regex")}
713
+ size="small"
714
+ fullWidth
715
+ value={node.validation?.pattern || ''}
716
+ onChange={(e) => formStore.updateNodeValidation(node.id, { pattern: e.target.value })}
717
+ />
718
+ </Box>
719
+ )}
720
+ </CustomTabPanel>
721
+
722
+ {/* Logic Tab */}
723
+ <CustomTabPanel value={activeGlobalIndex} index={2}>
724
+ <Box display="flex" flexDirection="column" gap={1.5}>
725
+ <FormControl fullWidth size="small">
726
+ <FormLabel>{t('properties.fieldNameCheck')}</FormLabel>
727
+ <Select
728
+ value={node.condition?.field || ''}
729
+ onChange={(e) => {
730
+ const field = e.target.value;
731
+ if (!field) {
732
+ formStore.updateNodeCondition(node.id, undefined);
733
+ } else {
734
+ const newCond = { ...(node.condition || { op: 'eq', value: '' }), field };
735
+ formStore.updateNodeCondition(node.id, newCond as any);
736
+ }
737
+ }}
738
+ displayEmpty
739
+ >
740
+ <MenuItem value=""><em>None</em></MenuItem>
741
+ {formStore.getAllFieldNames().filter(name => name !== node.props?.name).map(name => (
742
+ <MenuItem key={name} value={name}>{name}</MenuItem>
743
+ ))}
744
+ </Select>
745
+ <Typography variant="caption" color="text.secondary">
746
+ {t('properties.fieldVisibilityHelper')}
747
+ </Typography>
748
+ </FormControl>
749
+
750
+ {node.condition && (
751
+ <>
752
+ <FormControl fullWidth size="small">
753
+ <FormLabel>{t('properties.operator')}</FormLabel>
754
+ <Select
755
+ value={node.condition.op}
756
+ onChange={(e) => formStore.updateNodeCondition(node.id, { ...node.condition!, op: e.target.value as any })}
757
+ >
758
+ <MenuItem value="eq">{t('properties.opEq')}</MenuItem>
759
+ <MenuItem value="neq">{t('properties.opNeq')}</MenuItem>
760
+ <MenuItem value="gt">{t('properties.opGt')}</MenuItem>
761
+ <MenuItem value="lt">{t('properties.opLt')}</MenuItem>
762
+ <MenuItem value="contains">{t('properties.opContains')}</MenuItem>
763
+ </Select>
764
+ </FormControl>
765
+
766
+ <TextField
767
+ label={t("properties.value")}
768
+ size="small"
769
+ fullWidth
770
+ value={node.condition.value ?? ''}
771
+ onChange={(e) => {
772
+ let val: any = e.target.value;
773
+ // Try to convert to boolean or number if possible for easier matching
774
+ if (val === 'true') val = true;
775
+ else if (val === 'false') val = false;
776
+ else if (!isNaN(Number(val)) && val !== '') val = Number(val);
777
+
778
+ formStore.updateNodeCondition(node.id, { ...node.condition!, value: val });
779
+ }}
780
+ />
781
+
782
+ <Button
783
+ variant="outlined"
784
+ size="small"
785
+ color="secondary"
786
+ onClick={() => formStore.updateNodeCondition(node.id, undefined)}
787
+ >
788
+ {t("properties.clearCondition")}
789
+ </Button>
790
+ </>
791
+ )}
792
+ </Box>
793
+ </CustomTabPanel>
794
+
795
+ {/* Calculation Tab */}
796
+ <CustomTabPanel value={activeGlobalIndex} index={3}>
797
+ <TextField
798
+ label={t("properties.formula")}
799
+ size="small"
800
+ fullWidth
801
+ placeholder="e.g. {{price}} * {{qty}}"
802
+ value={node.calculation?.formula || ''}
803
+ onChange={(e) => {
804
+ const val = e.target.value;
805
+ formStore.updateNodeCalculation(node.id, val ? { formula: val } : undefined);
806
+ triggerAutocomplete(e, 'calculation');
807
+ }}
808
+ onKeyUp={(e) => {
809
+ const target = e.target as HTMLInputElement;
810
+ setCursorPos(target.selectionStart || 0);
811
+ }}
812
+ helperText={t("properties.formulaHelper")}
813
+ />
814
+ <FormulaHints fieldType="calculation" />
815
+ </CustomTabPanel>
816
+
817
+ {/* API Mapping Tab */}
818
+ <CustomTabPanel value={activeGlobalIndex} index={4}>
819
+ <Typography variant="body2" color="text.secondary" gutterBottom>
820
+ Map this field to an external API field for submission.
821
+ </Typography>
822
+
823
+ <FormControl fullWidth size="small">
824
+ <InputLabel>Integration</InputLabel>
825
+ <Select
826
+ label="Integration"
827
+ value={node.props?.apiIntegrationId || ''}
828
+ onChange={(e) => {
829
+ handleChange('apiIntegrationId', e.target.value);
830
+ formStore.updateNode(node.id, { apiMapping: undefined });
831
+ }}
832
+ >
833
+ <MenuItem value=""><em>None</em></MenuItem>
834
+ {(formStore.rootNode.props?.integrations || []).map((i: any) => (
835
+ <MenuItem key={i.id} value={i.id}>{i.name}</MenuItem>
836
+ ))}
837
+ </Select>
838
+ </FormControl>
839
+
840
+ {node.props?.apiIntegrationId && (
841
+ <FormControl fullWidth size="small" sx={{ mt: 2 }}>
842
+ <InputLabel>API Field</InputLabel>
843
+ <Select
844
+ label="API Field"
845
+ value={node.apiMapping || ''}
846
+ onChange={(e) => formStore.updateNode(node.id, { apiMapping: e.target.value })}
847
+ >
848
+ <MenuItem value=""><em>None</em></MenuItem>
849
+ {(formStore.rootNode.props?.integrations?.find((i: any) => i.id === node.props?.apiIntegrationId)?.availableFields || []).map((f: any) => (
850
+ <MenuItem key={f.id} value={f.id}>{f.label} ({f.type})</MenuItem>
851
+ ))}
852
+ </Select>
853
+ </FormControl>
854
+ )}
855
+ </CustomTabPanel>
856
+ </Box>
857
+ );
858
+ });