@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.
- package/build_output.txt +0 -0
- package/package.json +1 -1
- package/src/components/FieldRegistry.ts +16 -18
- package/src/components/FormRenderer.tsx +4 -2
- package/src/components/builder/EditorWrapper.tsx +3 -1
- package/src/components/builder/FormBuilder.tsx +20 -2
- package/src/components/builder/FormChildrenRenderer.tsx +3 -1
- package/src/components/builder/FormulaHelp.tsx +116 -0
- package/src/components/builder/IntegrationSettings.tsx +3 -1
- package/src/components/builder/OptionsEditor.tsx +90 -0
- package/src/components/builder/PropertiesModal.tsx +2 -1
- package/src/components/builder/PropertiesPanel.tsx +155 -62
- package/src/components/builder/SortableNode.tsx +2 -1
- package/src/components/builder/Toolbox.tsx +30 -63
- package/src/components/fields/CheckboxField.tsx +2 -1
- package/src/components/fields/DateField.tsx +2 -1
- package/src/components/fields/FileUploadField.tsx +2 -1
- package/src/components/fields/LabelField.tsx +2 -1
- package/src/components/fields/NumberField.tsx +2 -1
- package/src/components/fields/RichTextField.tsx +2 -1
- package/src/components/fields/SelectField.tsx +8 -2
- package/src/components/fields/TextField.tsx +2 -1
- package/src/components/layout/FormRepeater.tsx +2 -1
- package/src/components/layout/FormTabs.tsx +2 -1
- package/src/components/registerComponents.ts +69 -14
- package/src/index.ts +7 -0
- package/src/plugins/FieldPlugin.ts +63 -0
- package/src/plugins/PluginRegistry.ts +94 -0
- package/src/store/FormStore.ts +72 -24
- package/src/store/FormStoreContext.tsx +66 -0
package/build_output.txt
ADDED
|
Binary file
|
package/package.json
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
24
|
+
registerComponentAsPlugin(type, component);
|
|
20
25
|
};
|
|
21
26
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
27
|
+
/**
|
|
28
|
+
* Get the React component for a given type.
|
|
29
|
+
*/
|
|
25
30
|
export const getComponent = (type: string): ComponentType | undefined => {
|
|
26
|
-
return
|
|
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 {
|
|
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 =
|
|
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 {
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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'));
|