@stormlmd/form-builder 0.1.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.
Files changed (30) hide show
  1. package/build_output.txt +0 -0
  2. package/package.json +1 -1
  3. package/src/components/FieldRegistry.ts +16 -18
  4. package/src/components/FormRenderer.tsx +4 -2
  5. package/src/components/builder/EditorWrapper.tsx +3 -1
  6. package/src/components/builder/FormBuilder.tsx +20 -2
  7. package/src/components/builder/FormChildrenRenderer.tsx +3 -1
  8. package/src/components/builder/FormulaHelp.tsx +116 -0
  9. package/src/components/builder/IntegrationSettings.tsx +3 -1
  10. package/src/components/builder/OptionsEditor.tsx +90 -0
  11. package/src/components/builder/PropertiesModal.tsx +2 -1
  12. package/src/components/builder/PropertiesPanel.tsx +155 -62
  13. package/src/components/builder/SortableNode.tsx +2 -1
  14. package/src/components/builder/Toolbox.tsx +30 -63
  15. package/src/components/fields/CheckboxField.tsx +2 -1
  16. package/src/components/fields/DateField.tsx +2 -1
  17. package/src/components/fields/FileUploadField.tsx +2 -1
  18. package/src/components/fields/LabelField.tsx +2 -1
  19. package/src/components/fields/NumberField.tsx +2 -1
  20. package/src/components/fields/RichTextField.tsx +2 -1
  21. package/src/components/fields/SelectField.tsx +8 -2
  22. package/src/components/fields/TextField.tsx +2 -1
  23. package/src/components/layout/FormRepeater.tsx +2 -1
  24. package/src/components/layout/FormTabs.tsx +2 -1
  25. package/src/components/registerComponents.ts +69 -14
  26. package/src/index.ts +7 -0
  27. package/src/plugins/FieldPlugin.ts +63 -0
  28. package/src/plugins/PluginRegistry.ts +94 -0
  29. package/src/store/FormStore.ts +72 -24
  30. package/src/store/FormStoreContext.tsx +66 -0
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stormlmd/form-builder",
3
- "version": "0.1.0",
3
+ "version": "0.3.1",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "module": "./src/index.ts",
@@ -1,9 +1,12 @@
1
+ /**
2
+ * FieldRegistry — backward-compatible facade over PluginRegistry.
3
+ *
4
+ * Existing code that imports registerComponent/getComponent from here
5
+ * will continue to work unchanged.
6
+ */
1
7
  import React from 'react';
2
8
  import type { SchemaNode } from '../store/FormStore';
3
- import { FormTable } from './layout/FormTable';
4
- import { FormTableCell } from './layout/FormTableCell';
5
- import { FormTabs } from './layout/FormTabs';
6
- import { FormTab } from './layout/FormTab';
9
+ import { registerComponentAsPlugin, getComponentForType } from '../plugins/PluginRegistry';
7
10
 
8
11
  export interface FieldProps {
9
12
  node: SchemaNode;
@@ -13,22 +16,17 @@ export interface FieldProps {
13
16
 
14
17
  type ComponentType = React.FC<FieldProps>;
15
18
 
16
- const registry: Record<string, ComponentType> = {};
17
-
19
+ /**
20
+ * @deprecated Use registerPlugin() for new code. This function is kept
21
+ * for backward compatibility and creates a minimal plugin entry.
22
+ */
18
23
  export const registerComponent = (type: string, component: ComponentType) => {
19
- registry[type] = component;
24
+ registerComponentAsPlugin(type, component);
20
25
  };
21
26
 
22
- import { LabelField } from './fields/LabelField';
23
- import { FormDivider } from './layout/FormDivider';
24
-
27
+ /**
28
+ * Get the React component for a given type.
29
+ */
25
30
  export const getComponent = (type: string): ComponentType | undefined => {
26
- return registry[type];
31
+ return getComponentForType(type);
27
32
  };
28
-
29
- registerComponent('table', FormTable);
30
- registerComponent('cell', FormTableCell);
31
- registerComponent('label', LabelField);
32
- registerComponent('divider', FormDivider);
33
- registerComponent('tabs', FormTabs);
34
- registerComponent('tab', FormTab);
@@ -1,9 +1,10 @@
1
1
  import React from 'react';
2
2
  import { observer } from 'mobx-react-lite';
3
3
  import { Box, Typography, Tooltip } from '@mui/material';
4
- import { formStore } from '../store/FormStore';
4
+ import { useFormStore } from '../store/FormStoreContext';
5
5
  import type { SchemaNode } from '../store/FormStore';
6
6
  import { getComponent } from './FieldRegistry';
7
+ import { isContainerType } from '../plugins/PluginRegistry';
7
8
  import { FormChildrenRenderer } from './builder/FormChildrenRenderer';
8
9
  import { EditorWrapper } from './builder/EditorWrapper';
9
10
  import { SortableNode } from './builder/SortableNode';
@@ -27,6 +28,7 @@ export const DropIndicator = () => (
27
28
  );
28
29
 
29
30
  export const FormRenderer: React.FC<FormRendererProps> = observer(({ node, path, isEditing }) => {
31
+ const formStore = useFormStore();
30
32
  // Evaluate condition if present (skip in editing mode)
31
33
  if (!isEditing && node.condition) {
32
34
  const conditionMet = formStore.evaluateCondition(node.condition);
@@ -76,7 +78,7 @@ export const FormRenderer: React.FC<FormRendererProps> = observer(({ node, path,
76
78
 
77
79
  const width = `${(colSpan / 12) * 100}%`;
78
80
 
79
- const isContainer = ['root', 'row', 'col', 'tab', 'tabs', 'paper', 'table', 'cell', 'repeater'].includes(node.type);
81
+ const isContainer = isContainerType(node.type);
80
82
 
81
83
  const layoutSx = {
82
84
  width: width,
@@ -2,7 +2,8 @@ import React from 'react';
2
2
  import { Box, IconButton } from '@mui/material';
3
3
  import SettingsIcon from '@mui/icons-material/Settings';
4
4
  import { observer } from 'mobx-react-lite';
5
- import { formStore, type SchemaNode } from '../../store/FormStore';
5
+ import { type SchemaNode } from '../../store/FormStore';
6
+ import { useFormStore } from '../../store/FormStoreContext';
6
7
 
7
8
  interface EditorWrapperProps {
8
9
  node: SchemaNode;
@@ -10,6 +11,7 @@ interface EditorWrapperProps {
10
11
  }
11
12
 
12
13
  export const EditorWrapper: React.FC<EditorWrapperProps> = observer(({ node, children }) => {
14
+ const formStore = useFormStore();
13
15
  const isSelected = formStore.selectedNodeId === node.id;
14
16
 
15
17
  const handleClick = (e: React.MouseEvent) => {
@@ -22,9 +22,11 @@ import {
22
22
  sortableKeyboardCoordinates
23
23
  } from '@dnd-kit/sortable';
24
24
  import {
25
- formStore,
26
25
  type NodeType
27
26
  } from '../../store/FormStore';
27
+ import { useFormStore } from '../../store/FormStoreContext';
28
+ import { FormStoreProvider } from '../../store/FormStoreContext';
29
+ import type { FormStore } from '../../store/FormStore';
28
30
  import { FormRenderer } from '../FormRenderer';
29
31
  import { Toolbox } from './Toolbox';
30
32
  import { IntegrationSettings } from './IntegrationSettings';
@@ -34,9 +36,12 @@ import { DroppableCanvas } from './DroppableCanvas';
34
36
 
35
37
  interface FormBuilderProps {
36
38
  onCancel?: () => void;
39
+ /** Optional FormStore instance. If omitted, the default singleton is used. */
40
+ store?: FormStore;
37
41
  }
38
42
 
39
- export const FormBuilder: React.FC<FormBuilderProps> = observer(({ onCancel }) => {
43
+ const FormBuilderInner: React.FC<FormBuilderProps> = observer(({ onCancel }) => {
44
+ const formStore = useFormStore();
40
45
  const { t } = useTranslation();
41
46
  const [activeId, setActiveId] = React.useState<string | null>(null);
42
47
  const [activeData, setActiveData] = React.useState<any>(null);
@@ -311,3 +316,16 @@ export const FormBuilder: React.FC<FormBuilderProps> = observer(({ onCancel }) =
311
316
  </DndContext>
312
317
  );
313
318
  });
319
+
320
+ /** Public FormBuilder component. Wraps in FormStoreProvider if store prop is provided. */
321
+ export const FormBuilder: React.FC<FormBuilderProps> = ({ store, ...props }) => {
322
+ if (store) {
323
+ return (
324
+ <FormStoreProvider store={store}>
325
+ <FormBuilderInner {...props} />
326
+ </FormStoreProvider>
327
+ );
328
+ }
329
+ return <FormBuilderInner {...props} />;
330
+ };
331
+
@@ -2,7 +2,8 @@ import React from 'react';
2
2
  import { observer } from 'mobx-react-lite';
3
3
  import { Box, type SxProps, type Theme } from '@mui/material';
4
4
  import { SortableContext, rectSortingStrategy } from '@dnd-kit/sortable';
5
- import { formStore, type SchemaNode } from '../../store/FormStore';
5
+ import type { SchemaNode } from '../../store/FormStore';
6
+ import { useFormStore } from '../../store/FormStoreContext';
6
7
  import { FormRenderer, DropIndicator } from '../FormRenderer';
7
8
 
8
9
  interface FormChildrenRendererProps {
@@ -20,6 +21,7 @@ export const FormChildrenRenderer: React.FC<FormChildrenRendererProps> = observe
20
21
  layout = 'horizontal',
21
22
  path
22
23
  }) => {
24
+ const formStore = useFormStore();
23
25
  const childIds = children?.map(c => c.id) || [];
24
26
  const { dropIndicator } = formStore;
25
27
 
@@ -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
+ };
@@ -9,9 +9,11 @@ import DeleteIcon from '@mui/icons-material/Delete';
9
9
  import AddIcon from '@mui/icons-material/Add';
10
10
  import SyncIcon from '@mui/icons-material/Sync';
11
11
  import { observer } from 'mobx-react-lite';
12
- import { formStore, type APIIntegration } from '../../store/FormStore';
12
+ import type { APIIntegration } from '../../store/FormStore';
13
+ import { useFormStore } from '../../store/FormStoreContext';
13
14
 
14
15
  export const IntegrationSettings: React.FC = observer(() => {
16
+ const formStore = useFormStore();
15
17
  const [open, setOpen] = useState(false);
16
18
  const [currentIntegration, setCurrentIntegration] = useState<Partial<APIIntegration>>({
17
19
  method: 'POST',
@@ -0,0 +1,90 @@
1
+ import React from 'react';
2
+ import { Box, TextField, IconButton, Button, Typography } from '@mui/material';
3
+ import DeleteIcon from '@mui/icons-material/Delete';
4
+ import AddIcon from '@mui/icons-material/Add';
5
+ import { useTranslation } from 'react-i18next';
6
+
7
+ interface Option {
8
+ label: string;
9
+ value: string;
10
+ }
11
+
12
+ interface OptionsEditorProps {
13
+ options: Option[];
14
+ onChange: (options: Option[]) => void;
15
+ disabled?: boolean;
16
+ }
17
+
18
+ export const OptionsEditor: React.FC<OptionsEditorProps> = ({ options, onChange, disabled }) => {
19
+ const { t } = useTranslation();
20
+
21
+ const handleAddOption = () => {
22
+ const newOptions = [...options, { label: `Option ${options.length + 1}`, value: `opt${options.length + 1}` }];
23
+ onChange(newOptions);
24
+ };
25
+
26
+ const handleRemoveOption = (index: number) => {
27
+ const newOptions = options.filter((_, i) => i !== index);
28
+ onChange(newOptions);
29
+ };
30
+
31
+ const handleUpdateOption = (index: number, key: keyof Option, value: string) => {
32
+ const newOptions = options.map((opt, i) => i === index ? { ...opt, [key]: value } : opt);
33
+ onChange(newOptions);
34
+ };
35
+
36
+ if (disabled) {
37
+ return (
38
+ <Box>
39
+ <Typography variant="body2" color="text.secondary">
40
+ {t('properties.selectOptionsManagedByAPI') || "Options are managed by API integration"}
41
+ </Typography>
42
+ <Box sx={{ mt: 1, opacity: 0.7 }}>
43
+ {options.map((opt, i) => (
44
+ <Typography key={i} variant="caption" display="block">• {opt.label} ({opt.value})</Typography>
45
+ ))}
46
+ </Box>
47
+ </Box>
48
+ );
49
+ }
50
+
51
+ return (
52
+ <Box>
53
+ <Typography variant="caption" color="text.secondary" sx={{ mb: 1, display: 'block' }}>
54
+ {t('properties.selectOptions')}
55
+ </Typography>
56
+ <Box display="flex" flexDirection="column" gap={1}>
57
+ {options.map((opt, index) => (
58
+ <Box key={index} display="flex" gap={1} alignItems="center">
59
+ <TextField
60
+ label={t('properties.optionLabel')}
61
+ size="small"
62
+ value={opt.label}
63
+ onChange={(e) => handleUpdateOption(index, 'label', e.target.value)}
64
+ sx={{ flexGrow: 1 }}
65
+ />
66
+ <TextField
67
+ label={t('properties.optionValue')}
68
+ size="small"
69
+ value={opt.value}
70
+ onChange={(e) => handleUpdateOption(index, 'value', e.target.value)}
71
+ sx={{ flexGrow: 1 }}
72
+ />
73
+ <IconButton size="small" color="error" onClick={() => handleRemoveOption(index)}>
74
+ <DeleteIcon fontSize="small" />
75
+ </IconButton>
76
+ </Box>
77
+ ))}
78
+ <Button
79
+ startIcon={<AddIcon />}
80
+ onClick={handleAddOption}
81
+ sx={{ mt: 1, alignSelf: 'flex-start' }}
82
+ variant="outlined"
83
+ size="small"
84
+ >
85
+ {t('properties.addOption')}
86
+ </Button>
87
+ </Box>
88
+ </Box>
89
+ );
90
+ };
@@ -13,7 +13,7 @@ import {
13
13
  } from '@mui/material';
14
14
  import { observer } from 'mobx-react-lite';
15
15
  import { useTranslation } from 'react-i18next';
16
- import { formStore } from '../../store/FormStore';
16
+ import { useFormStore } from '../../store/FormStoreContext';
17
17
  import CloseIcon from '@mui/icons-material/Close';
18
18
  import { PropertiesPanel } from './PropertiesPanel'; // We reuse the inner content for now
19
19
  // Actually PropertiesPanel has its own layout, we should extract the content
@@ -23,6 +23,7 @@ import { PropertiesPanel } from './PropertiesPanel'; // We reuse the inner conte
23
23
  // Let's modify PropertiesPanel to accept a "mode" or just use it as content.
24
24
 
25
25
  export const PropertiesModal: React.FC = observer(() => {
26
+ const formStore = useFormStore();
26
27
  const { t } = useTranslation();
27
28
  const theme = useTheme();
28
29
  const fullScreen = useMediaQuery(theme.breakpoints.down('md'));