@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,87 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Box, IconButton } from '@mui/material';
|
|
3
|
+
import SettingsIcon from '@mui/icons-material/Settings';
|
|
4
|
+
import { observer } from 'mobx-react-lite';
|
|
5
|
+
import { formStore, type SchemaNode } from '../../store/FormStore';
|
|
6
|
+
|
|
7
|
+
interface EditorWrapperProps {
|
|
8
|
+
node: SchemaNode;
|
|
9
|
+
children: React.ReactNode;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const EditorWrapper: React.FC<EditorWrapperProps> = observer(({ node, children }) => {
|
|
13
|
+
const isSelected = formStore.selectedNodeId === node.id;
|
|
14
|
+
|
|
15
|
+
const handleClick = (e: React.MouseEvent) => {
|
|
16
|
+
e.stopPropagation();
|
|
17
|
+
formStore.selectNode(node.id);
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<Box
|
|
22
|
+
onClick={handleClick}
|
|
23
|
+
onDoubleClick={(e) => {
|
|
24
|
+
e.stopPropagation();
|
|
25
|
+
formStore.openPropertiesModal(node.id);
|
|
26
|
+
}}
|
|
27
|
+
sx={{
|
|
28
|
+
display: 'flex',
|
|
29
|
+
flexDirection: 'column',
|
|
30
|
+
flexGrow: 1,
|
|
31
|
+
height: '100%',
|
|
32
|
+
position: 'relative',
|
|
33
|
+
outline: isSelected ? '2px solid #1976d2' : '1px dashed transparent',
|
|
34
|
+
outlineOffset: '-2px', // Inset outline to avoid overflow issues
|
|
35
|
+
cursor: 'pointer',
|
|
36
|
+
'&:hover': {
|
|
37
|
+
outline: !isSelected ? '1px dashed #999' : '2px solid #1976d2',
|
|
38
|
+
backgroundColor: !isSelected ? 'rgba(0, 0, 0, 0.02)' : undefined,
|
|
39
|
+
},
|
|
40
|
+
transition: 'all 0.2s',
|
|
41
|
+
}}
|
|
42
|
+
>
|
|
43
|
+
{isSelected && (
|
|
44
|
+
<Box
|
|
45
|
+
sx={{
|
|
46
|
+
position: 'absolute',
|
|
47
|
+
top: -24,
|
|
48
|
+
left: -2,
|
|
49
|
+
backgroundColor: '#1976d2',
|
|
50
|
+
color: 'white',
|
|
51
|
+
padding: '2px 8px',
|
|
52
|
+
borderRadius: '4px 4px 0 0',
|
|
53
|
+
fontSize: '0.75rem',
|
|
54
|
+
fontWeight: 'bold',
|
|
55
|
+
zIndex: 10,
|
|
56
|
+
textTransform: 'uppercase',
|
|
57
|
+
boxShadow: 1
|
|
58
|
+
}}
|
|
59
|
+
>
|
|
60
|
+
{node.type}
|
|
61
|
+
</Box>
|
|
62
|
+
)}
|
|
63
|
+
{isSelected && (
|
|
64
|
+
<Box
|
|
65
|
+
sx={{
|
|
66
|
+
position: 'absolute',
|
|
67
|
+
top: -15,
|
|
68
|
+
right: -10,
|
|
69
|
+
zIndex: 11,
|
|
70
|
+
backgroundColor: 'background.paper',
|
|
71
|
+
borderRadius: '50%',
|
|
72
|
+
boxShadow: 2
|
|
73
|
+
}}
|
|
74
|
+
onClick={(e) => {
|
|
75
|
+
e.stopPropagation();
|
|
76
|
+
formStore.openPropertiesModal(node.id);
|
|
77
|
+
}}
|
|
78
|
+
>
|
|
79
|
+
<IconButton size="small" color="primary">
|
|
80
|
+
<SettingsIcon fontSize="small" />
|
|
81
|
+
</IconButton>
|
|
82
|
+
</Box>
|
|
83
|
+
)}
|
|
84
|
+
{children}
|
|
85
|
+
</Box>
|
|
86
|
+
);
|
|
87
|
+
});
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { useTranslation } from 'react-i18next';
|
|
3
|
+
import { Box, Paper, Typography, AppBar, Toolbar, Button, Tabs, Tab } from '@mui/material';
|
|
4
|
+
import SettingsIcon from '@mui/icons-material/Settings';
|
|
5
|
+
import SaveIcon from '@mui/icons-material/Save';
|
|
6
|
+
import FileOpenIcon from '@mui/icons-material/FileOpen';
|
|
7
|
+
import DownloadIcon from '@mui/icons-material/Download';
|
|
8
|
+
import { observer } from 'mobx-react-lite';
|
|
9
|
+
import {
|
|
10
|
+
DndContext,
|
|
11
|
+
type DragEndEvent,
|
|
12
|
+
type DragStartEvent,
|
|
13
|
+
pointerWithin,
|
|
14
|
+
PointerSensor,
|
|
15
|
+
KeyboardSensor,
|
|
16
|
+
useSensor,
|
|
17
|
+
useSensors,
|
|
18
|
+
DragOverlay,
|
|
19
|
+
defaultDropAnimationSideEffects
|
|
20
|
+
} from '@dnd-kit/core';
|
|
21
|
+
import {
|
|
22
|
+
sortableKeyboardCoordinates
|
|
23
|
+
} from '@dnd-kit/sortable';
|
|
24
|
+
import {
|
|
25
|
+
formStore,
|
|
26
|
+
type NodeType
|
|
27
|
+
} from '../../store/FormStore';
|
|
28
|
+
import { FormRenderer } from '../FormRenderer';
|
|
29
|
+
import { Toolbox } from './Toolbox';
|
|
30
|
+
import { IntegrationSettings } from './IntegrationSettings';
|
|
31
|
+
import { PropertiesModal } from './PropertiesModal';
|
|
32
|
+
import { generateId } from '../../utils/idGenerator';
|
|
33
|
+
import { DroppableCanvas } from './DroppableCanvas';
|
|
34
|
+
|
|
35
|
+
interface FormBuilderProps {
|
|
36
|
+
onCancel?: () => void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const FormBuilder: React.FC<FormBuilderProps> = observer(({ onCancel }) => {
|
|
40
|
+
const { t } = useTranslation();
|
|
41
|
+
const [activeId, setActiveId] = React.useState<string | null>(null);
|
|
42
|
+
const [activeData, setActiveData] = React.useState<any>(null);
|
|
43
|
+
const [leftTab, setLeftTab] = React.useState<'toolbox' | 'integrations'>('toolbox');
|
|
44
|
+
|
|
45
|
+
const sensors = useSensors(
|
|
46
|
+
useSensor(PointerSensor, {
|
|
47
|
+
activationConstraint: {
|
|
48
|
+
distance: 10,
|
|
49
|
+
},
|
|
50
|
+
}),
|
|
51
|
+
useSensor(KeyboardSensor, {
|
|
52
|
+
coordinateGetter: sortableKeyboardCoordinates,
|
|
53
|
+
})
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
const handleDragStart = (event: DragStartEvent) => {
|
|
57
|
+
setActiveId(event.active.id as string);
|
|
58
|
+
setActiveData(event.active.data.current);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const getDropTarget = (overId: string | undefined): { parentId: string; index?: number } | null => {
|
|
62
|
+
if (!overId) return null;
|
|
63
|
+
if (overId === 'canvas-drop-zone') return { parentId: 'root' };
|
|
64
|
+
|
|
65
|
+
const overNode = formStore.findNode(formStore.rootNode, overId);
|
|
66
|
+
if (!overNode) return null;
|
|
67
|
+
|
|
68
|
+
const containerTypes: NodeType[] = ['root', 'row', 'col', 'tabs', 'tab', 'paper', 'repeater', 'table', 'cell'];
|
|
69
|
+
|
|
70
|
+
if (containerTypes.includes(overNode.type)) {
|
|
71
|
+
// If dropping on a container, check if it has children.
|
|
72
|
+
// If it's empty, we append (index undefined).
|
|
73
|
+
// If it's not empty, it's safer to append, unless we hit a specific child.
|
|
74
|
+
// But if we are ON the container itself, appending is the most natural behavior.
|
|
75
|
+
return { parentId: overId, index: -1 }; // -1 signifies "at the end" for my indicator logic
|
|
76
|
+
} else {
|
|
77
|
+
const parentNode = formStore.findParent(formStore.rootNode, overId);
|
|
78
|
+
if (parentNode) {
|
|
79
|
+
const overIndex = parentNode.children?.findIndex(c => c.id === overId) ?? -1;
|
|
80
|
+
return { parentId: parentNode.id, index: overIndex !== -1 ? overIndex : undefined };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const handleDragOver = (event: any) => {
|
|
87
|
+
const { over } = event;
|
|
88
|
+
const target = getDropTarget(over?.id);
|
|
89
|
+
if (target) {
|
|
90
|
+
formStore.setDropIndicator({ parentId: target.parentId, index: target.index ?? -1 });
|
|
91
|
+
} else {
|
|
92
|
+
formStore.setDropIndicator(null);
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const handleDragEnd = (event: DragEndEvent) => {
|
|
97
|
+
const { active, over } = event;
|
|
98
|
+
setActiveId(null);
|
|
99
|
+
setActiveData(null);
|
|
100
|
+
formStore.setDropIndicator(null);
|
|
101
|
+
|
|
102
|
+
if (!over) return;
|
|
103
|
+
|
|
104
|
+
const target = getDropTarget(over.id as string);
|
|
105
|
+
if (!target) return;
|
|
106
|
+
|
|
107
|
+
const isToolboxItem = active.data.current?.isToolboxItem;
|
|
108
|
+
|
|
109
|
+
if (isToolboxItem) {
|
|
110
|
+
const toolData = active.data.current as { type: NodeType; defaultProps: Record<string, any> };
|
|
111
|
+
const { type, defaultProps } = toolData;
|
|
112
|
+
|
|
113
|
+
const newNode: any = {
|
|
114
|
+
id: generateId(type),
|
|
115
|
+
type: type,
|
|
116
|
+
props: { width: 6, ...defaultProps, name: generateId('field'), label: t(`toolbox.${type}`) },
|
|
117
|
+
children: []
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
// Special initialization for Table
|
|
121
|
+
if (type === 'table') {
|
|
122
|
+
if (!newNode.props.width) newNode.props.width = 12;
|
|
123
|
+
const rows = defaultProps?.rows || 2;
|
|
124
|
+
const cols = defaultProps?.cols || 2;
|
|
125
|
+
const cells = [];
|
|
126
|
+
for (let i = 0; i < rows * cols; i++) {
|
|
127
|
+
cells.push({ id: generateId('cell'), type: 'cell', children: [] });
|
|
128
|
+
}
|
|
129
|
+
newNode.children = cells;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
formStore.addNode(target.parentId, newNode, target.index === -1 ? undefined : target.index);
|
|
133
|
+
} else if (active.id !== over.id) {
|
|
134
|
+
formStore.moveNode(active.id as string, over.id === 'canvas-drop-zone' ? 'root' : over.id as string);
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
return (
|
|
142
|
+
<DndContext
|
|
143
|
+
sensors={sensors}
|
|
144
|
+
collisionDetection={pointerWithin}
|
|
145
|
+
onDragStart={handleDragStart}
|
|
146
|
+
onDragOver={handleDragOver}
|
|
147
|
+
onDragEnd={handleDragEnd}
|
|
148
|
+
>
|
|
149
|
+
<Box display="flex" flexDirection="column" height="100vh">
|
|
150
|
+
<AppBar position="static" color="default" elevation={1}>
|
|
151
|
+
<Toolbar variant="dense">
|
|
152
|
+
{/* Title removed per request */}
|
|
153
|
+
<Box sx={{ display: 'flex', mx: 2, p: 0.5, backgroundColor: 'action.hover', borderRadius: 1 }}>
|
|
154
|
+
<Button
|
|
155
|
+
onClick={() => formStore.setEditMode(true)}
|
|
156
|
+
variant={formStore.isEditMode ? 'contained' : 'text'}
|
|
157
|
+
size="small"
|
|
158
|
+
sx={{ px: 2 }}
|
|
159
|
+
>
|
|
160
|
+
{t('app.builder')}
|
|
161
|
+
</Button>
|
|
162
|
+
<Button
|
|
163
|
+
onClick={() => formStore.setEditMode(false)}
|
|
164
|
+
variant={!formStore.isEditMode ? 'contained' : 'text'}
|
|
165
|
+
size="small"
|
|
166
|
+
sx={{ px: 2 }}
|
|
167
|
+
>
|
|
168
|
+
{t('app.preview')}
|
|
169
|
+
</Button>
|
|
170
|
+
</Box>
|
|
171
|
+
|
|
172
|
+
<Box sx={{ flexGrow: 1 }} />
|
|
173
|
+
|
|
174
|
+
<Button
|
|
175
|
+
color="inherit"
|
|
176
|
+
startIcon={<SettingsIcon />}
|
|
177
|
+
onClick={() => formStore.openPropertiesModal('root')}
|
|
178
|
+
>
|
|
179
|
+
{t("app.formSettings")}
|
|
180
|
+
</Button>
|
|
181
|
+
<Button color="inherit" startIcon={<FileOpenIcon />} onClick={() => formStore.loadFromStorage()}>
|
|
182
|
+
{t("app.load")}
|
|
183
|
+
</Button>
|
|
184
|
+
{onCancel && (
|
|
185
|
+
<Button color="inherit" onClick={onCancel} sx={{ ml: 1 }}>
|
|
186
|
+
{t("app.cancel") || "Cancel"}
|
|
187
|
+
</Button>
|
|
188
|
+
)}
|
|
189
|
+
<Button color="primary" variant="contained" startIcon={<SaveIcon />} onClick={() => formStore.saveToStorage()} sx={{ ml: 1 }}>
|
|
190
|
+
{t("app.save")}
|
|
191
|
+
</Button>
|
|
192
|
+
<Button color="inherit" startIcon={<DownloadIcon />} onClick={() => {
|
|
193
|
+
const data = JSON.stringify(formStore.rootNode, null, 2);
|
|
194
|
+
const blob = new Blob([data], { type: 'application/json' });
|
|
195
|
+
const url = URL.createObjectURL(blob);
|
|
196
|
+
const a = document.createElement('a');
|
|
197
|
+
a.href = url;
|
|
198
|
+
a.download = 'form-schema.json';
|
|
199
|
+
document.body.appendChild(a);
|
|
200
|
+
a.click();
|
|
201
|
+
document.body.removeChild(a);
|
|
202
|
+
URL.revokeObjectURL(url);
|
|
203
|
+
}}>
|
|
204
|
+
{t("app.exportJson")}
|
|
205
|
+
</Button>
|
|
206
|
+
</Toolbar>
|
|
207
|
+
</AppBar>
|
|
208
|
+
|
|
209
|
+
<Box display="flex" flexGrow={1} overflow="hidden">
|
|
210
|
+
{/* Left Sidebar with Tabs */}
|
|
211
|
+
{formStore.isEditMode && (
|
|
212
|
+
<Paper elevation={2} sx={{ width: 320, display: 'flex', flexDirection: 'column', zIndex: 1, borderRight: 1, borderColor: 'divider' }}>
|
|
213
|
+
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
|
|
214
|
+
<Tabs value={leftTab} onChange={(_, v) => setLeftTab(v)} variant="fullWidth">
|
|
215
|
+
<Tab label={t("app.toolbox")} value="toolbox" />
|
|
216
|
+
<Tab label={t("app.integrations")} value="integrations" />
|
|
217
|
+
</Tabs>
|
|
218
|
+
</Box>
|
|
219
|
+
<Box sx={{ flexGrow: 1, overflow: 'auto' }}>
|
|
220
|
+
{leftTab === 'toolbox' ? <Toolbox /> : <IntegrationSettings />}
|
|
221
|
+
</Box>
|
|
222
|
+
</Paper>
|
|
223
|
+
)}
|
|
224
|
+
|
|
225
|
+
{/* Canvas - Center Area */}
|
|
226
|
+
<Box flexGrow={1} bgcolor="#f0f2f5" p={4} overflow="auto" display="flex" justifyContent="center">
|
|
227
|
+
<Paper
|
|
228
|
+
elevation={formStore.isEditMode ? 0 : 3}
|
|
229
|
+
sx={{
|
|
230
|
+
width: '100%',
|
|
231
|
+
maxWidth: 1400,
|
|
232
|
+
minHeight: '100%',
|
|
233
|
+
p: formStore.isEditMode ? 4 : 6,
|
|
234
|
+
px: formStore.isEditMode ? 6 : 8,
|
|
235
|
+
bgcolor: 'background.paper',
|
|
236
|
+
borderRadius: 2
|
|
237
|
+
}}
|
|
238
|
+
>
|
|
239
|
+
{formStore.isEditMode ? (
|
|
240
|
+
<DroppableCanvas>
|
|
241
|
+
<FormRenderer node={formStore.rootNode} path="" isEditing={true} />
|
|
242
|
+
</DroppableCanvas>
|
|
243
|
+
) : (
|
|
244
|
+
<Box>
|
|
245
|
+
<FormRenderer node={formStore.rootNode} path="" isEditing={false} />
|
|
246
|
+
<Box mt={6} pt={3} borderTop={1} borderColor="divider" display="flex" justifyContent="flex-end" gap={2}>
|
|
247
|
+
{onCancel && (
|
|
248
|
+
<Button
|
|
249
|
+
variant="outlined"
|
|
250
|
+
size="large"
|
|
251
|
+
sx={{ px: 4, borderRadius: 2 }}
|
|
252
|
+
onClick={onCancel}
|
|
253
|
+
>
|
|
254
|
+
{t('app.cancel') || "Cancel"}
|
|
255
|
+
</Button>
|
|
256
|
+
)}
|
|
257
|
+
<Button
|
|
258
|
+
variant="contained"
|
|
259
|
+
color="success"
|
|
260
|
+
size="large"
|
|
261
|
+
sx={{ px: 4, borderRadius: 2 }}
|
|
262
|
+
onClick={async () => {
|
|
263
|
+
try {
|
|
264
|
+
await formStore.submitForm();
|
|
265
|
+
alert(t('app.submissionSuccess'));
|
|
266
|
+
} catch (e) {
|
|
267
|
+
console.error(e);
|
|
268
|
+
alert(t('app.submissionFailed'));
|
|
269
|
+
}
|
|
270
|
+
}}
|
|
271
|
+
>
|
|
272
|
+
{t('app.submitForm')}
|
|
273
|
+
</Button>
|
|
274
|
+
</Box>
|
|
275
|
+
</Box>
|
|
276
|
+
)}
|
|
277
|
+
</Paper>
|
|
278
|
+
</Box>
|
|
279
|
+
|
|
280
|
+
{/* Properties Modal (Replaces Sidebar) */}
|
|
281
|
+
<PropertiesModal />
|
|
282
|
+
</Box>
|
|
283
|
+
</Box>
|
|
284
|
+
<DragOverlay dropAnimation={{
|
|
285
|
+
sideEffects: defaultDropAnimationSideEffects({
|
|
286
|
+
styles: {
|
|
287
|
+
active: {
|
|
288
|
+
opacity: '0.5',
|
|
289
|
+
},
|
|
290
|
+
},
|
|
291
|
+
}),
|
|
292
|
+
}}>
|
|
293
|
+
{activeId ? (
|
|
294
|
+
<Box sx={{
|
|
295
|
+
p: 2,
|
|
296
|
+
bgcolor: 'background.paper',
|
|
297
|
+
border: '2px solid #1976d2',
|
|
298
|
+
borderRadius: 1,
|
|
299
|
+
boxShadow: 3,
|
|
300
|
+
opacity: 0.9,
|
|
301
|
+
minWidth: 100
|
|
302
|
+
}}>
|
|
303
|
+
{activeData?.isToolboxItem ? (
|
|
304
|
+
<Typography>{activeData.type.toUpperCase()}</Typography>
|
|
305
|
+
) : (
|
|
306
|
+
<Typography>{t("app.movingElement")}</Typography>
|
|
307
|
+
)}
|
|
308
|
+
</Box>
|
|
309
|
+
) : null}
|
|
310
|
+
</DragOverlay>
|
|
311
|
+
</DndContext>
|
|
312
|
+
);
|
|
313
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { observer } from 'mobx-react-lite';
|
|
3
|
+
import { Box, type SxProps, type Theme } from '@mui/material';
|
|
4
|
+
import { SortableContext, rectSortingStrategy } from '@dnd-kit/sortable';
|
|
5
|
+
import { formStore, type SchemaNode } from '../../store/FormStore';
|
|
6
|
+
import { FormRenderer, DropIndicator } from '../FormRenderer';
|
|
7
|
+
|
|
8
|
+
interface FormChildrenRendererProps {
|
|
9
|
+
parentId: string;
|
|
10
|
+
children: SchemaNode[] | undefined;
|
|
11
|
+
isEditing: boolean;
|
|
12
|
+
layout?: 'horizontal' | 'vertical';
|
|
13
|
+
path?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const FormChildrenRenderer: React.FC<FormChildrenRendererProps> = observer(({
|
|
17
|
+
parentId,
|
|
18
|
+
children,
|
|
19
|
+
isEditing,
|
|
20
|
+
layout = 'horizontal',
|
|
21
|
+
path
|
|
22
|
+
}) => {
|
|
23
|
+
const childIds = children?.map(c => c.id) || [];
|
|
24
|
+
const { dropIndicator } = formStore;
|
|
25
|
+
|
|
26
|
+
const isHorizontal = layout === 'horizontal';
|
|
27
|
+
|
|
28
|
+
const containerSx: SxProps<Theme> = {
|
|
29
|
+
display: 'flex',
|
|
30
|
+
flexWrap: isHorizontal ? 'wrap' : 'nowrap',
|
|
31
|
+
flexDirection: isHorizontal ? 'row' : 'column',
|
|
32
|
+
width: '100%',
|
|
33
|
+
m: 0,
|
|
34
|
+
gap: 0,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
if (!isEditing) {
|
|
38
|
+
return (
|
|
39
|
+
<Box sx={containerSx}>
|
|
40
|
+
{children?.map(child => (
|
|
41
|
+
<FormRenderer key={child.id} node={child} path={path} isEditing={false} />
|
|
42
|
+
))}
|
|
43
|
+
</Box>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<Box sx={containerSx}>
|
|
49
|
+
<SortableContext items={childIds} strategy={rectSortingStrategy}>
|
|
50
|
+
{children?.map((child, index) => (
|
|
51
|
+
<React.Fragment key={child.id}>
|
|
52
|
+
{dropIndicator?.parentId === parentId && dropIndicator.index === index && (
|
|
53
|
+
<Box sx={{ width: isHorizontal ? '100%' : 'auto', p: isHorizontal ? 1 : 0 }}>
|
|
54
|
+
<DropIndicator />
|
|
55
|
+
</Box>
|
|
56
|
+
)}
|
|
57
|
+
<FormRenderer node={child} path={path} isEditing={true} />
|
|
58
|
+
</React.Fragment>
|
|
59
|
+
))}
|
|
60
|
+
{dropIndicator?.parentId === parentId && dropIndicator.index === -1 && (
|
|
61
|
+
<Box sx={{ width: isHorizontal ? '100%' : 'auto', p: isHorizontal ? 1 : 0 }}>
|
|
62
|
+
<DropIndicator />
|
|
63
|
+
</Box>
|
|
64
|
+
)}
|
|
65
|
+
</SortableContext>
|
|
66
|
+
</Box>
|
|
67
|
+
);
|
|
68
|
+
});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
Box, Button, TextField, Typography, List, ListItem,
|
|
4
|
+
ListItemText, ListItemSecondaryAction, IconButton,
|
|
5
|
+
Dialog, DialogTitle, DialogContent, DialogActions,
|
|
6
|
+
Select, MenuItem, FormControl, InputLabel
|
|
7
|
+
} from '@mui/material';
|
|
8
|
+
import DeleteIcon from '@mui/icons-material/Delete';
|
|
9
|
+
import AddIcon from '@mui/icons-material/Add';
|
|
10
|
+
import SyncIcon from '@mui/icons-material/Sync';
|
|
11
|
+
import { observer } from 'mobx-react-lite';
|
|
12
|
+
import { formStore, type APIIntegration } from '../../store/FormStore';
|
|
13
|
+
|
|
14
|
+
export const IntegrationSettings: React.FC = observer(() => {
|
|
15
|
+
const [open, setOpen] = useState(false);
|
|
16
|
+
const [currentIntegration, setCurrentIntegration] = useState<Partial<APIIntegration>>({
|
|
17
|
+
method: 'POST',
|
|
18
|
+
headers: {}
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const handleAdd = () => {
|
|
22
|
+
setCurrentIntegration({ id: Math.random().toString(36).substr(2, 9), method: 'POST', headers: {}, name: '', fetchFieldsURL: '', submitURL: '', availableFields: [] });
|
|
23
|
+
setOpen(true);
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const handleSave = () => {
|
|
27
|
+
if (currentIntegration.id) {
|
|
28
|
+
formStore.addIntegration(currentIntegration as APIIntegration);
|
|
29
|
+
setOpen(false);
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const integrations = formStore.rootNode.props?.integrations || [];
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<Box sx={{ p: 2 }}>
|
|
37
|
+
<Typography variant="h6" gutterBottom>API Integrations</Typography>
|
|
38
|
+
<Button
|
|
39
|
+
variant="contained"
|
|
40
|
+
startIcon={<AddIcon />}
|
|
41
|
+
onClick={handleAdd}
|
|
42
|
+
sx={{ mb: 2 }}
|
|
43
|
+
>
|
|
44
|
+
Add Integration
|
|
45
|
+
</Button>
|
|
46
|
+
|
|
47
|
+
<List>
|
|
48
|
+
{integrations.map((integration: APIIntegration) => (
|
|
49
|
+
<ListItem key={integration.id} divider>
|
|
50
|
+
<ListItemText
|
|
51
|
+
primary={integration.name}
|
|
52
|
+
secondary={`${integration.method} ${integration.submitURL}`}
|
|
53
|
+
/>
|
|
54
|
+
<ListItemSecondaryAction>
|
|
55
|
+
<IconButton onClick={() => formStore.fetchExternalFields(integration.id)} title="Fetch Fields">
|
|
56
|
+
<SyncIcon />
|
|
57
|
+
</IconButton>
|
|
58
|
+
<IconButton edge="end" onClick={() => formStore.removeIntegration(integration.id)}>
|
|
59
|
+
<DeleteIcon />
|
|
60
|
+
</IconButton>
|
|
61
|
+
</ListItemSecondaryAction>
|
|
62
|
+
</ListItem>
|
|
63
|
+
))}
|
|
64
|
+
</List>
|
|
65
|
+
|
|
66
|
+
<Dialog open={open} onClose={() => setOpen(false)} fullWidth maxWidth="sm">
|
|
67
|
+
<DialogTitle>Add API Integration</DialogTitle>
|
|
68
|
+
<DialogContent>
|
|
69
|
+
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
|
|
70
|
+
<TextField
|
|
71
|
+
label="Name"
|
|
72
|
+
fullWidth
|
|
73
|
+
value={currentIntegration.name}
|
|
74
|
+
onChange={(e) => setCurrentIntegration({ ...currentIntegration, name: e.target.value })}
|
|
75
|
+
/>
|
|
76
|
+
<TextField
|
|
77
|
+
label="Fetch Fields URL"
|
|
78
|
+
fullWidth
|
|
79
|
+
value={currentIntegration.fetchFieldsURL}
|
|
80
|
+
onChange={(e) => setCurrentIntegration({ ...currentIntegration, fetchFieldsURL: e.target.value })}
|
|
81
|
+
placeholder="http://localhost:8000/api/mock-external/fields"
|
|
82
|
+
/>
|
|
83
|
+
<TextField
|
|
84
|
+
label="Submit URL"
|
|
85
|
+
fullWidth
|
|
86
|
+
value={currentIntegration.submitURL}
|
|
87
|
+
onChange={(e) => setCurrentIntegration({ ...currentIntegration, submitURL: e.target.value })}
|
|
88
|
+
placeholder="http://localhost:8000/api/mock-external/submit"
|
|
89
|
+
/>
|
|
90
|
+
<FormControl fullWidth>
|
|
91
|
+
<InputLabel>Method</InputLabel>
|
|
92
|
+
<Select
|
|
93
|
+
value={currentIntegration.method}
|
|
94
|
+
label="Method"
|
|
95
|
+
onChange={(e) => setCurrentIntegration({ ...currentIntegration, method: e.target.value as any })}
|
|
96
|
+
>
|
|
97
|
+
<MenuItem value="POST">POST</MenuItem>
|
|
98
|
+
<MenuItem value="PUT">PUT</MenuItem>
|
|
99
|
+
</Select>
|
|
100
|
+
</FormControl>
|
|
101
|
+
</Box>
|
|
102
|
+
</DialogContent>
|
|
103
|
+
<DialogActions>
|
|
104
|
+
<Button onClick={() => setOpen(false)}>Cancel</Button>
|
|
105
|
+
<Button onClick={handleSave} variant="contained">Save</Button>
|
|
106
|
+
</DialogActions>
|
|
107
|
+
</Dialog>
|
|
108
|
+
</Box>
|
|
109
|
+
);
|
|
110
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import {
|
|
3
|
+
Dialog,
|
|
4
|
+
DialogTitle,
|
|
5
|
+
DialogContent,
|
|
6
|
+
DialogActions,
|
|
7
|
+
Button,
|
|
8
|
+
Box,
|
|
9
|
+
Typography,
|
|
10
|
+
IconButton,
|
|
11
|
+
useTheme,
|
|
12
|
+
useMediaQuery
|
|
13
|
+
} from '@mui/material';
|
|
14
|
+
import { observer } from 'mobx-react-lite';
|
|
15
|
+
import { useTranslation } from 'react-i18next';
|
|
16
|
+
import { formStore } from '../../store/FormStore';
|
|
17
|
+
import CloseIcon from '@mui/icons-material/Close';
|
|
18
|
+
import { PropertiesPanel } from './PropertiesPanel'; // We reuse the inner content for now
|
|
19
|
+
// Actually PropertiesPanel has its own layout, we should extract the content
|
|
20
|
+
// OR just render PropertiesPanel inside the modal and strip its outer layout in refactor.
|
|
21
|
+
// For now, let's wrap PropertiesPanel and modify it to not have the header?
|
|
22
|
+
// Or better: Clone the logic from PropertiesPanel into here or make PropertiesPanel adapting.
|
|
23
|
+
// Let's modify PropertiesPanel to accept a "mode" or just use it as content.
|
|
24
|
+
|
|
25
|
+
export const PropertiesModal: React.FC = observer(() => {
|
|
26
|
+
const { t } = useTranslation();
|
|
27
|
+
const theme = useTheme();
|
|
28
|
+
const fullScreen = useMediaQuery(theme.breakpoints.down('md'));
|
|
29
|
+
const isOpen = formStore.isPropertiesModalOpen;
|
|
30
|
+
const node = formStore.selectedNode;
|
|
31
|
+
|
|
32
|
+
if (!node) return null;
|
|
33
|
+
|
|
34
|
+
const handleClose = () => {
|
|
35
|
+
formStore.closePropertiesModal();
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<Dialog
|
|
40
|
+
open={isOpen}
|
|
41
|
+
onClose={handleClose}
|
|
42
|
+
fullScreen={fullScreen}
|
|
43
|
+
maxWidth="sm"
|
|
44
|
+
fullWidth
|
|
45
|
+
scroll="paper"
|
|
46
|
+
PaperProps={{
|
|
47
|
+
sx: { minHeight: '600px', maxHeight: '80vh' }
|
|
48
|
+
}}
|
|
49
|
+
>
|
|
50
|
+
<DialogTitle sx={{ m: 0, p: 2, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
51
|
+
<Typography variant="h6" component="div">
|
|
52
|
+
{node.type === 'root' ? t('app.formSettings') : t('app.elementSettings')}
|
|
53
|
+
</Typography>
|
|
54
|
+
<IconButton
|
|
55
|
+
aria-label="close"
|
|
56
|
+
onClick={handleClose}
|
|
57
|
+
sx={{ color: (theme) => theme.palette.grey[500] }}
|
|
58
|
+
>
|
|
59
|
+
<CloseIcon />
|
|
60
|
+
</IconButton>
|
|
61
|
+
</DialogTitle>
|
|
62
|
+
<DialogContent dividers sx={{ p: 0 }}>
|
|
63
|
+
{/* We render the existing panel. We might need to adjust styling later to remove duplicate headers */}
|
|
64
|
+
<Box sx={{ '& > div': { height: 'auto', border: 'none' } }}>
|
|
65
|
+
<PropertiesPanel />
|
|
66
|
+
</Box>
|
|
67
|
+
</DialogContent>
|
|
68
|
+
<DialogActions>
|
|
69
|
+
<Button onClick={handleClose}>
|
|
70
|
+
{t('properties.delete').toLowerCase() === 'delete' ? 'Close' : 'Закрыть'}
|
|
71
|
+
</Button>
|
|
72
|
+
</DialogActions>
|
|
73
|
+
</Dialog>
|
|
74
|
+
);
|
|
75
|
+
});
|