@stormlmd/form-builder 0.3.0 → 0.3.1

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.
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stormlmd/form-builder",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "module": "./src/index.ts",
@@ -0,0 +1,116 @@
1
+ import React from 'react';
2
+ import {
3
+ Dialog,
4
+ DialogTitle,
5
+ DialogContent,
6
+ DialogActions,
7
+ Button,
8
+ Typography,
9
+ Box,
10
+ IconButton,
11
+ Table,
12
+ TableBody,
13
+ TableCell,
14
+ TableContainer,
15
+ TableHead,
16
+ TableRow,
17
+ Paper
18
+ } from '@mui/material';
19
+ import CloseIcon from '@mui/icons-material/Close';
20
+
21
+ interface FormulaHelpProps {
22
+ open: boolean;
23
+ onClose: () => void;
24
+ }
25
+
26
+ export const FormulaHelp: React.FC<FormulaHelpProps> = ({ open, onClose }) => {
27
+
28
+ const examples = [
29
+ {
30
+ func: 'IF(cond, t, f)',
31
+ desc: 'Условие: если истина -> t, иначе -> f',
32
+ example: 'IF({{price}} > 100, "Высокая", "Низкая")'
33
+ },
34
+ {
35
+ func: 'SWITCH(v, v1, r1, ...)',
36
+ desc: 'Выбор по значению одного поля',
37
+ example: 'SWITCH({{type}}, "A", 10, "B", 20, 0)'
38
+ },
39
+ {
40
+ func: 'IFS(c1, r1, c2, r2, ...)',
41
+ desc: 'Цепочка условий (первое истинное)',
42
+ example: 'IFS({{score}} > 90, "A", {{score}} > 50, "B", "F")'
43
+ },
44
+ {
45
+ func: 'DATE_ADD(d, days)',
46
+ desc: 'Прибавить дни к дате',
47
+ example: 'DATE_ADD({{today}}, 7)'
48
+ },
49
+ {
50
+ func: 'DATE_DIFF(d1, d2)',
51
+ desc: 'Разница между датами в днях',
52
+ example: 'DATE_DIFF({{today}}, {{start_date}})'
53
+ },
54
+ {
55
+ func: 'ROUND(v, p)',
56
+ desc: 'Округление до p знаков',
57
+ example: 'ROUND({{price}} * 1.2, 2)'
58
+ },
59
+ {
60
+ func: 'AND/OR/NOT',
61
+ desc: 'Логические операторы',
62
+ example: 'AND({{active}}, NOT({{hidden}}))'
63
+ }
64
+ ];
65
+
66
+ return (
67
+ <Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
68
+ <DialogTitle sx={{ m: 0, p: 2, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
69
+ <Typography variant="h6">Подсказка по формулам</Typography>
70
+ <IconButton onClick={onClose} size="small">
71
+ <CloseIcon />
72
+ </IconButton>
73
+ </DialogTitle>
74
+ <DialogContent dividers>
75
+ <Typography variant="body2" gutterBottom color="text.secondary">
76
+ Используйте <code>{`{{имя_поля}}`}</code> для доступа к значениям. Ниже приведены популярные функции:
77
+ </Typography>
78
+
79
+ <TableContainer component={Paper} variant="outlined" sx={{ mt: 2 }}>
80
+ <Table size="small">
81
+ <TableHead>
82
+ <TableRow sx={{ bgcolor: 'action.hover' }}>
83
+ <TableCell><strong>Функция</strong></TableCell>
84
+ <TableCell><strong>Описание</strong></TableCell>
85
+ <TableCell><strong>Пример</strong></TableCell>
86
+ </TableRow>
87
+ </TableHead>
88
+ <TableBody>
89
+ {examples.map((item, index) => (
90
+ <TableRow key={index}>
91
+ <TableCell sx={{ fontFamily: 'monospace', fontWeight: 'bold' }}>{item.func}</TableCell>
92
+ <TableCell>{item.desc}</TableCell>
93
+ <TableCell sx={{ fontFamily: 'monospace', fontSize: '0.8rem', color: 'primary.main' }}>
94
+ {item.example}
95
+ </TableCell>
96
+ </TableRow>
97
+ ))}
98
+ </TableBody>
99
+ </Table>
100
+ </TableContainer>
101
+
102
+ <Box mt={3}>
103
+ <Typography variant="subtitle2" gutterBottom>Полезные советы:</Typography>
104
+ <Typography variant="body2" component="ul" sx={{ pl: 2 }}>
105
+ <li>Пустые поля автоматически считаются <code>0</code> в математике.</li>
106
+ <li>Для строк используйте кавычки: <code>"Текст"</code>.</li>
107
+ <li><code>{`{{today}}`}</code> - текущая дата в формате DD-MM-YYYY.</li>
108
+ </Typography>
109
+ </Box>
110
+ </DialogContent>
111
+ <DialogActions>
112
+ <Button onClick={onClose} color="primary">Закрыть</Button>
113
+ </DialogActions>
114
+ </Dialog>
115
+ );
116
+ };
@@ -1,5 +1,5 @@
1
1
  import React from 'react';
2
- import { Box, TextField, IconButton, Button, Typography, Paper } from '@mui/material';
2
+ import { Box, TextField, IconButton, Button, Typography } from '@mui/material';
3
3
  import DeleteIcon from '@mui/icons-material/Delete';
4
4
  import AddIcon from '@mui/icons-material/Add';
5
5
  import { useTranslation } from 'react-i18next';
@@ -1,11 +1,13 @@
1
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';
2
+ import { Box, Typography, TextField, FormControl, FormLabel, Select, MenuItem, Button, Popper, Paper, List, ListItemButton, ListItemText, ClickAwayListener, Tabs, Tab, InputLabel, IconButton, InputAdornment, Tooltip } from '@mui/material';
3
3
  import { observer } from 'mobx-react-lite';
4
4
  import { useFormStore } from '../../store/FormStoreContext';
5
5
  import type { SchemaNode } from '../../store/FormStore';
6
6
  import DeleteIcon from '@mui/icons-material/Delete';
7
+ import HelpOutlineIcon from '@mui/icons-material/HelpOutline';
7
8
  import { useTranslation } from 'react-i18next';
8
9
  import { getPlugin } from '../../plugins/PluginRegistry';
10
+ import { FormulaHelp } from './FormulaHelp';
9
11
 
10
12
  interface TabPanelProps {
11
13
  children?: React.ReactNode;
@@ -39,6 +41,48 @@ function CustomTabPanel(props: TabPanelProps) {
39
41
  );
40
42
  }
41
43
 
44
+ const FormulaHints: React.FC<{
45
+ node: SchemaNode,
46
+ formStore: any,
47
+ insertText: (text: string) => void,
48
+ t: any
49
+ }> = ({ node, formStore, insertText, t }) => {
50
+ return (
51
+ <Box mt={1} display="flex" flexWrap="wrap" gap={0.5}>
52
+ <Typography variant="caption" width="100%" color="text.secondary">{t("properties.hints")}</Typography>
53
+ {['today', 'now', ...formStore.getAllFieldNames()]
54
+ .filter(name => name !== node.props?.name)
55
+ .map(name => (
56
+ <Button
57
+ key={name}
58
+ size="small"
59
+ variant="outlined"
60
+ sx={{ textTransform: 'none', px: 1, py: 0, fontSize: '0.7rem' }}
61
+ onClick={() => insertText(`{{${name}}}`)}
62
+ >
63
+ {name}
64
+ </Button>
65
+ ))}
66
+
67
+ <Box width="100%" mt={1}>
68
+ <Typography variant="caption" color="text.secondary">{t("properties.functions") || "Functions"}</Typography>
69
+ </Box>
70
+ {['IF()', 'AND()', 'OR()', 'NOT()', 'ABS()', 'ROUND()', 'SWITCH()', 'IFS()', 'DATE_ADD()', 'DATE_DIFF()'].map(func => (
71
+ <Button
72
+ key={func}
73
+ size="small"
74
+ variant="outlined"
75
+ color="secondary"
76
+ sx={{ textTransform: 'none', px: 1, py: 0, fontSize: '0.7rem' }}
77
+ onClick={() => insertText(func)}
78
+ >
79
+ {func}
80
+ </Button>
81
+ ))}
82
+ </Box>
83
+ );
84
+ };
85
+
42
86
  function a11yProps(index: number) {
43
87
  return {
44
88
  id: `simple-tab-${index}`,
@@ -51,6 +95,7 @@ export const PropertiesPanel: React.FC = observer(() => {
51
95
  const { t } = useTranslation();
52
96
  const node = formStore.selectedNode;
53
97
  const [tabValue, setTabValue] = React.useState(0);
98
+ const [helpOpen, setHelpOpen] = React.useState(false);
54
99
 
55
100
  const BUILT_IN_LAYOUT_TYPES = ['root', 'row', 'col', 'tabs', 'tab', 'paper', 'repeater', 'table', 'cell', 'divider'];
56
101
  const isLayout = node ? BUILT_IN_LAYOUT_TYPES.includes(node.type) : false;
@@ -96,6 +141,7 @@ export const PropertiesPanel: React.FC = observer(() => {
96
141
  };
97
142
 
98
143
  const handleDelete = () => {
144
+ if (node.id === 'root') return;
99
145
  formStore.removeNode(node.id);
100
146
  formStore.closePropertiesModal(); // Also close modal on delete
101
147
  };
@@ -158,58 +204,22 @@ export const PropertiesPanel: React.FC = observer(() => {
158
204
  setAnchorEl(null);
159
205
  };
160
206
 
161
- const FormulaHints = ({ fieldType }: { fieldType: 'calculation' | 'defaultValue' | 'min' | 'max' }) => {
162
- const insertText = (text: string) => {
163
- let current = '';
164
- if (fieldType === 'calculation') current = node.calculation?.formula || '';
165
- else if (fieldType === 'defaultValue') current = node.defaultValue || '';
166
- else if (fieldType === 'min') current = String(node.validation?.min || '');
167
- else if (fieldType === 'max') current = String(node.validation?.max || '');
168
-
169
- const newVal = current + text;
170
- if (fieldType === 'calculation') formStore.updateNodeCalculation(node.id, { formula: newVal });
171
- else if (fieldType === 'defaultValue') {
172
- formStore.updateNode(node.id, { defaultValue: newVal });
173
- formStore.applyDefaultValues();
174
- }
175
- else if (fieldType === 'min') formStore.updateNodeValidation(node.id, { min: newVal });
176
- else if (fieldType === 'max') formStore.updateNodeValidation(node.id, { max: newVal });
177
- };
178
-
179
- return (
180
- <Box mt={1} display="flex" flexWrap="wrap" gap={0.5}>
181
- <Typography variant="caption" width="100%" color="text.secondary">{t("properties.hints")}</Typography>
182
- {['today', 'now', ...formStore.getAllFieldNames()]
183
- .filter(name => name !== node.props?.name)
184
- .map(name => (
185
- <Button
186
- key={name}
187
- size="small"
188
- variant="outlined"
189
- sx={{ textTransform: 'none', px: 1, py: 0, fontSize: '0.7rem' }}
190
- onClick={() => insertText(`{{${name}}}`)}
191
- >
192
- {name}
193
- </Button>
194
- ))}
195
-
196
- <Box width="100%" mt={1}>
197
- <Typography variant="caption" color="text.secondary">{t("properties.functions") || "Functions"}</Typography>
198
- </Box>
199
- {['IF()', 'AND()', 'OR()', 'NOT()', 'ABS()', 'ROUND()'].map(func => (
200
- <Button
201
- key={func}
202
- size="small"
203
- variant="outlined"
204
- color="secondary"
205
- sx={{ textTransform: 'none', px: 1, py: 0, fontSize: '0.7rem' }}
206
- onClick={() => insertText(func)}
207
- >
208
- {func}
209
- </Button>
210
- ))}
211
- </Box>
212
- );
207
+ const insertText = (fieldType: string, text: string) => {
208
+ if (!node) return;
209
+ let current = '';
210
+ if (fieldType === 'calculation') current = node.calculation?.formula || '';
211
+ else if (fieldType === 'defaultValue') current = node.defaultValue || '';
212
+ else if (fieldType === 'min') current = String(node.validation?.min || '');
213
+ else if (fieldType === 'max') current = String(node.validation?.max || '');
214
+
215
+ const newVal = current + text;
216
+ if (fieldType === 'calculation') formStore.updateNodeCalculation(node.id, { formula: newVal });
217
+ else if (fieldType === 'defaultValue') {
218
+ formStore.updateNode(node.id, { defaultValue: newVal });
219
+ formStore.applyDefaultValues();
220
+ }
221
+ else if (fieldType === 'min') formStore.updateNodeValidation(node.id, { min: newVal });
222
+ else if (fieldType === 'max') formStore.updateNodeValidation(node.id, { max: newVal });
213
223
  };
214
224
 
215
225
  return (
@@ -240,7 +250,7 @@ export const PropertiesPanel: React.FC = observer(() => {
240
250
  <Box>
241
251
  <Typography variant="subtitle1" fontWeight="bold">{t('properties.title')}</Typography>
242
252
  <Typography variant="caption" color="text.secondary">
243
- {node.type.toUpperCase()} | ID: {node.id}
253
+ {(node.type || 'unknown').toUpperCase()} | ID: {node.id || 'none'}
244
254
  </Typography>
245
255
  </Box>
246
256
  <Button
@@ -377,6 +387,17 @@ export const PropertiesPanel: React.FC = observer(() => {
377
387
  size="small"
378
388
  fullWidth
379
389
  value={node.defaultValue ?? ''}
390
+ InputProps={{
391
+ endAdornment: (
392
+ <InputAdornment position="end">
393
+ <Tooltip title="Справка по формулам">
394
+ <IconButton size="small" onClick={() => setHelpOpen(true)}>
395
+ <HelpOutlineIcon fontSize="small" />
396
+ </IconButton>
397
+ </Tooltip>
398
+ </InputAdornment>
399
+ ),
400
+ }}
380
401
  onChange={(e) => {
381
402
  const val = e.target.value;
382
403
  formStore.updateNode(node.id, { defaultValue: val });
@@ -388,7 +409,7 @@ export const PropertiesPanel: React.FC = observer(() => {
388
409
  setCursorPos(target.selectionStart || 0);
389
410
  }}
390
411
  />
391
- <FormulaHints fieldType="defaultValue" />
412
+ <FormulaHints node={node} formStore={formStore} insertText={(txt) => insertText('defaultValue', txt)} t={t} />
392
413
  </>
393
414
  )}
394
415
  </Box>
@@ -686,6 +707,17 @@ export const PropertiesPanel: React.FC = observer(() => {
686
707
  size="small"
687
708
  fullWidth
688
709
  value={node.validation?.min ?? ''}
710
+ InputProps={{
711
+ endAdornment: (
712
+ <InputAdornment position="end">
713
+ <Tooltip title="Справка по формулам">
714
+ <IconButton size="small" onClick={() => setHelpOpen(true)}>
715
+ <HelpOutlineIcon fontSize="small" />
716
+ </IconButton>
717
+ </Tooltip>
718
+ </InputAdornment>
719
+ ),
720
+ }}
689
721
  onChange={(e) => {
690
722
  const val = e.target.value;
691
723
  formStore.updateNodeValidation(node.id, { min: val });
@@ -697,7 +729,7 @@ export const PropertiesPanel: React.FC = observer(() => {
697
729
  }}
698
730
  placeholder="e.g. 10 or {{otherField}} + 5"
699
731
  />
700
- <FormulaHints fieldType="min" />
732
+ <FormulaHints node={node} formStore={formStore} insertText={(txt) => insertText('min', txt)} t={t} />
701
733
  </Box>
702
734
  <Box mb={1}>
703
735
  <TextField
@@ -705,6 +737,17 @@ export const PropertiesPanel: React.FC = observer(() => {
705
737
  size="small"
706
738
  fullWidth
707
739
  value={node.validation?.max ?? ''}
740
+ InputProps={{
741
+ endAdornment: (
742
+ <InputAdornment position="end">
743
+ <Tooltip title="Справка по формулам">
744
+ <IconButton size="small" onClick={() => setHelpOpen(true)}>
745
+ <HelpOutlineIcon fontSize="small" />
746
+ </IconButton>
747
+ </Tooltip>
748
+ </InputAdornment>
749
+ ),
750
+ }}
708
751
  onChange={(e) => {
709
752
  const val = e.target.value;
710
753
  formStore.updateNodeValidation(node.id, { max: val });
@@ -716,7 +759,7 @@ export const PropertiesPanel: React.FC = observer(() => {
716
759
  }}
717
760
  placeholder="e.g. 100 or {{otherField}} * 2"
718
761
  />
719
- <FormulaHints fieldType="max" />
762
+ <FormulaHints node={node} formStore={formStore} insertText={(txt) => insertText('max', txt)} t={t} />
720
763
  </Box>
721
764
  </Box>
722
765
  )}
@@ -837,6 +880,17 @@ export const PropertiesPanel: React.FC = observer(() => {
837
880
  fullWidth
838
881
  placeholder="e.g. {{price}} * {{qty}}"
839
882
  value={node.calculation?.formula || ''}
883
+ InputProps={{
884
+ endAdornment: (
885
+ <InputAdornment position="end">
886
+ <Tooltip title="Справка по формулам">
887
+ <IconButton size="small" onClick={() => setHelpOpen(true)}>
888
+ <HelpOutlineIcon fontSize="small" />
889
+ </IconButton>
890
+ </Tooltip>
891
+ </InputAdornment>
892
+ ),
893
+ }}
840
894
  onChange={(e) => {
841
895
  const val = e.target.value;
842
896
  formStore.updateNodeCalculation(node.id, val ? { formula: val } : undefined);
@@ -848,7 +902,7 @@ export const PropertiesPanel: React.FC = observer(() => {
848
902
  }}
849
903
  helperText={t("properties.formulaHelper")}
850
904
  />
851
- <FormulaHints fieldType="calculation" />
905
+ <FormulaHints node={node} formStore={formStore} insertText={(txt) => insertText('calculation', txt)} t={t} />
852
906
  </CustomTabPanel>
853
907
 
854
908
  {/* API Mapping Tab */}
@@ -890,6 +944,8 @@ export const PropertiesPanel: React.FC = observer(() => {
890
944
  </FormControl>
891
945
  )}
892
946
  </CustomTabPanel>
947
+
948
+ <FormulaHelp open={helpOpen} onClose={() => setHelpOpen(false)} />
893
949
  </Box>
894
950
  );
895
951
  });
@@ -1,5 +1,4 @@
1
1
  import { registerPlugin } from '../plugins/PluginRegistry';
2
- import { registerComponent } from './FieldRegistry';
3
2
  import type { FieldPlugin } from '../plugins/FieldPlugin';
4
3
 
5
4
  // Field components
@@ -1,5 +1,6 @@
1
1
  import { makeAutoObservable, toJS } from "mobx";
2
2
  import i18next from 'i18next';
3
+ import { addDays, differenceInDays, parse, format, parseISO, isValid } from 'date-fns';
3
4
  import { transformAPIDataToSchema, transformSchemaToSubmission, transformDictionaryToOptions } from '../utils/apiTransformer';
4
5
  import { getPlugin, isContainerType } from '../plugins/PluginRegistry';
5
6
 
@@ -140,6 +141,7 @@ export class FormStore {
140
141
  const response = await fetch(`${this.apiBase}/schema/${formId}`);
141
142
  if (!response.ok) throw new Error("Failed to fetch schema");
142
143
  const schema = await response.json();
144
+ if (schema && !schema.id) schema.id = 'root'; // Enforce root ID
143
145
  this.rootNode = schema;
144
146
  this.applyDefaultValues();
145
147
  } catch (error) {
@@ -448,6 +450,40 @@ export class FormStore {
448
450
  ROUND: (v: any, p: number = 0) => {
449
451
  const factor = Math.pow(10, p);
450
452
  return Math.round(v * factor) / factor;
453
+ },
454
+ DATE_ADD: (date: any, days: any) => {
455
+ if (!date) return '';
456
+ const daysNum = Number(days) || 0;
457
+ let d = parseISO(date);
458
+ let isISO = true;
459
+ if (!isValid(d)) {
460
+ d = parse(date, 'dd-MM-yyyy', new Date());
461
+ isISO = false;
462
+ }
463
+ if (!isValid(d)) return date;
464
+ const result = addDays(d, daysNum);
465
+ return isISO ? result.toISOString() : format(result, 'dd-MM-yyyy');
466
+ },
467
+ DATE_DIFF: (date1: any, date2: any) => {
468
+ if (!date1 || !date2) return 0;
469
+ let d1 = parseISO(date1);
470
+ if (!isValid(d1)) d1 = parse(date1, 'dd-MM-yyyy', new Date());
471
+ let d2 = parseISO(date2);
472
+ if (!isValid(d2)) d2 = parse(date2, 'dd-MM-yyyy', new Date());
473
+ if (!isValid(d1) || !isValid(d2)) return 0;
474
+ return differenceInDays(d1, d2);
475
+ },
476
+ SWITCH: (val: any, ...args: any[]) => {
477
+ for (let i = 0; i < args.length - 1; i += 2) {
478
+ if (val === args[i]) return args[i + 1];
479
+ }
480
+ return args.length % 2 === 1 ? args[args.length - 1] : undefined;
481
+ },
482
+ IFS: (...args: any[]) => {
483
+ for (let i = 0; i < args.length - 1; i += 2) {
484
+ if (args[i]) return args[i + 1];
485
+ }
486
+ return args.length % 2 === 1 ? args[args.length - 1] : undefined;
451
487
  }
452
488
  };
453
489
 
@@ -739,7 +775,9 @@ export class FormStore {
739
775
  try {
740
776
  const stored = localStorage.getItem('form_schema');
741
777
  if (stored) {
742
- this.rootNode = JSON.parse(stored);
778
+ const schema = JSON.parse(stored);
779
+ if (schema && !schema.id) schema.id = 'root'; // Enforce root ID
780
+ this.rootNode = schema;
743
781
  this.applyDefaultValues();
744
782
  console.log('Schema loaded from localStorage');
745
783
  }
@@ -780,6 +818,7 @@ export class FormStore {
780
818
  }
781
819
 
782
820
  findNode(root: SchemaNode, id: string): SchemaNode | null {
821
+ if (id === 'root' && (root === this.rootNode || root.id === 'root')) return root;
783
822
  if (root.id === id) return root;
784
823
  if (root.children) {
785
824
  for (const child of root.children) {