@stormlmd/form-builder 0.1.0 → 0.3.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/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/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 +41 -4
- 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 -13
- 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 +32 -23
- package/src/store/FormStoreContext.tsx +66 -0
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
|
|
|
@@ -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, Paper } 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'));
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { Box, Typography, TextField, FormControl, FormLabel, Select, MenuItem, Button, Popper, Paper, List, ListItemButton, ListItemText, ClickAwayListener, Tabs, Tab, InputLabel } from '@mui/material';
|
|
3
3
|
import { observer } from 'mobx-react-lite';
|
|
4
|
-
import {
|
|
4
|
+
import { useFormStore } from '../../store/FormStoreContext';
|
|
5
5
|
import type { SchemaNode } from '../../store/FormStore';
|
|
6
6
|
import DeleteIcon from '@mui/icons-material/Delete';
|
|
7
7
|
import { useTranslation } from 'react-i18next';
|
|
8
|
+
import { getPlugin } from '../../plugins/PluginRegistry';
|
|
8
9
|
|
|
9
10
|
interface TabPanelProps {
|
|
10
11
|
children?: React.ReactNode;
|
|
@@ -46,11 +47,14 @@ function a11yProps(index: number) {
|
|
|
46
47
|
}
|
|
47
48
|
|
|
48
49
|
export const PropertiesPanel: React.FC = observer(() => {
|
|
50
|
+
const formStore = useFormStore();
|
|
49
51
|
const { t } = useTranslation();
|
|
50
52
|
const node = formStore.selectedNode;
|
|
51
53
|
const [tabValue, setTabValue] = React.useState(0);
|
|
52
54
|
|
|
53
|
-
const
|
|
55
|
+
const BUILT_IN_LAYOUT_TYPES = ['root', 'row', 'col', 'tabs', 'tab', 'paper', 'repeater', 'table', 'cell', 'divider'];
|
|
56
|
+
const isLayout = node ? BUILT_IN_LAYOUT_TYPES.includes(node.type) : false;
|
|
57
|
+
const plugin = node ? getPlugin(node.type) : undefined;
|
|
54
58
|
|
|
55
59
|
const allTabs = [
|
|
56
60
|
{ label: t("properties.tabView"), index: 0, visible: true },
|
|
@@ -416,13 +420,31 @@ export const PropertiesPanel: React.FC = observer(() => {
|
|
|
416
420
|
|
|
417
421
|
{node.type === 'select' && (
|
|
418
422
|
<Box display="flex" flexDirection="column" gap={2} mt={2}>
|
|
423
|
+
<TextField
|
|
424
|
+
label={t('properties.optionsSource') || 'Options Source (Constant Name)'}
|
|
425
|
+
size="small"
|
|
426
|
+
fullWidth
|
|
427
|
+
value={node.props?.optionsSource || ''}
|
|
428
|
+
onChange={(e) => handleChange('optionsSource', e.target.value || undefined)}
|
|
429
|
+
helperText={
|
|
430
|
+
node.props?.optionsSource
|
|
431
|
+
? `${t('properties.optionsSourceActive') || 'Dynamic list active'}: formStore.setConstant('${node.props.optionsSource}', [...])`
|
|
432
|
+
: (t('properties.optionsSourceHelper') || 'Name of a constant set via setConstant(). Leave empty for static options.')
|
|
433
|
+
}
|
|
434
|
+
placeholder="e.g. userList"
|
|
435
|
+
/>
|
|
436
|
+
{!!node.props?.optionsSource && (
|
|
437
|
+
<Typography variant="caption" color="info.main">
|
|
438
|
+
{t('properties.optionsSourceHint') || 'Static options below are ignored when Options Source is set.'}
|
|
439
|
+
</Typography>
|
|
440
|
+
)}
|
|
419
441
|
<TextField
|
|
420
442
|
label={t('properties.selectOptions')}
|
|
421
443
|
multiline
|
|
422
444
|
rows={6}
|
|
423
445
|
size="small"
|
|
424
446
|
fullWidth
|
|
425
|
-
disabled={!!node.props?.dictionaryInfo}
|
|
447
|
+
disabled={!!node.props?.dictionaryInfo || !!node.props?.optionsSource}
|
|
426
448
|
value={(node.props?.options || []).map((opt: any) => opt.label).join('\n')}
|
|
427
449
|
onChange={(e) => {
|
|
428
450
|
const lines = e.target.value.split('\n');
|
|
@@ -433,7 +455,9 @@ export const PropertiesPanel: React.FC = observer(() => {
|
|
|
433
455
|
}}
|
|
434
456
|
helperText={node.props?.dictionaryInfo
|
|
435
457
|
? "Options are managed by API dictionary"
|
|
436
|
-
:
|
|
458
|
+
: node.props?.optionsSource
|
|
459
|
+
? (t('properties.optionsSourceOverride') || 'Overridden by Options Source above')
|
|
460
|
+
: t('properties.selectOptionsHelper')}
|
|
437
461
|
/>
|
|
438
462
|
<Box display="flex" alignItems="center" mt={1}>
|
|
439
463
|
<input
|
|
@@ -595,6 +619,19 @@ export const PropertiesPanel: React.FC = observer(() => {
|
|
|
595
619
|
/>
|
|
596
620
|
</Box>
|
|
597
621
|
)}
|
|
622
|
+
|
|
623
|
+
{/* Plugin custom properties editor */}
|
|
624
|
+
{plugin?.propertiesEditor && (
|
|
625
|
+
<Box mt={2}>
|
|
626
|
+
<Typography variant="caption" color="text.secondary" display="block" mb={1}>
|
|
627
|
+
{plugin.label} Settings
|
|
628
|
+
</Typography>
|
|
629
|
+
{React.createElement(plugin.propertiesEditor, {
|
|
630
|
+
node,
|
|
631
|
+
onChange: handleChange,
|
|
632
|
+
})}
|
|
633
|
+
</Box>
|
|
634
|
+
)}
|
|
598
635
|
</CustomTabPanel>
|
|
599
636
|
|
|
600
637
|
{/* Validation Tab */}
|
|
@@ -4,7 +4,7 @@ import { CSS } from '@dnd-kit/utilities';
|
|
|
4
4
|
import { Box, type SxProps, type Theme } from '@mui/material';
|
|
5
5
|
|
|
6
6
|
import { observer } from 'mobx-react-lite';
|
|
7
|
-
import {
|
|
7
|
+
import { useFormStore } from '../../store/FormStoreContext';
|
|
8
8
|
|
|
9
9
|
interface SortableNodeProps {
|
|
10
10
|
id: string;
|
|
@@ -13,6 +13,7 @@ interface SortableNodeProps {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
export const SortableNode: React.FC<SortableNodeProps> = observer(({ id, children, sx }) => {
|
|
16
|
+
const formStore = useFormStore();
|
|
16
17
|
const {
|
|
17
18
|
attributes,
|
|
18
19
|
listeners,
|
|
@@ -1,68 +1,35 @@
|
|
|
1
1
|
import React, { useState } from 'react';
|
|
2
2
|
import { Box, List, ListItem, Tabs, Tab } from '@mui/material';
|
|
3
|
-
import {
|
|
3
|
+
import { useFormStore } from '../../store/FormStoreContext';
|
|
4
4
|
import { generateId } from '../../utils/idGenerator';
|
|
5
5
|
import { DraggableTool } from './DraggableTool';
|
|
6
6
|
import { useTranslation } from 'react-i18next';
|
|
7
|
-
import
|
|
8
|
-
import
|
|
9
|
-
import
|
|
10
|
-
import ArrowDropDownCircleIcon from '@mui/icons-material/ArrowDropDownCircle';
|
|
11
|
-
import CalendarTodayIcon from '@mui/icons-material/CalendarToday';
|
|
12
|
-
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
|
|
13
|
-
import FormatQuoteIcon from '@mui/icons-material/FormatQuote';
|
|
14
|
-
import LabelIcon from '@mui/icons-material/Label';
|
|
15
|
-
import HorizontalRuleIcon from '@mui/icons-material/HorizontalRule';
|
|
16
|
-
import ViewColumnIcon from '@mui/icons-material/ViewColumn';
|
|
17
|
-
import TabIcon from '@mui/icons-material/Tab';
|
|
18
|
-
import RepeatIcon from '@mui/icons-material/Repeat';
|
|
19
|
-
import TableChartIcon from '@mui/icons-material/TableChart';
|
|
20
|
-
import LayersIcon from '@mui/icons-material/Layers';
|
|
21
|
-
|
|
22
|
-
interface ToolItem {
|
|
23
|
-
type: NodeType;
|
|
24
|
-
label: string;
|
|
25
|
-
icon: React.ReactNode;
|
|
26
|
-
defaultProps?: Record<string, any>;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
const TOOLS: ToolItem[] = [
|
|
30
|
-
{ type: 'text', label: 'Text Field', icon: <TextFieldsIcon />, defaultProps: { label: 'New Text Field', width: 6 } },
|
|
31
|
-
{ type: 'number', label: 'Number Field', icon: <NumbersIcon />, defaultProps: { label: 'New Number Field', width: 6 } },
|
|
32
|
-
{ type: 'checkbox', label: 'Checkbox', icon: <CheckBoxIcon />, defaultProps: { label: 'New Checkbox', width: 6 } },
|
|
33
|
-
{ type: 'select', label: 'Select', icon: <ArrowDropDownCircleIcon />, defaultProps: { label: 'New Select', width: 6, options: [{ label: 'Option 1', value: '1' }] } },
|
|
34
|
-
{ type: 'date', label: 'Date Picker', icon: <CalendarTodayIcon />, defaultProps: { label: 'New Date', width: 6 } },
|
|
35
|
-
{ type: 'file', label: 'File Upload', icon: <CloudUploadIcon />, defaultProps: { label: 'New File Upload', width: 6 } },
|
|
36
|
-
{ type: 'richtext', label: 'Rich Text', icon: <FormatQuoteIcon />, defaultProps: { label: 'New Rich Text', width: 6 } },
|
|
37
|
-
{ type: 'label', label: 'Label', icon: <LabelIcon />, defaultProps: { text: 'Static Tex', variant: 'body1', width: 6 } },
|
|
38
|
-
];
|
|
39
|
-
|
|
40
|
-
const LAYOUT_TOOLS: ToolItem[] = [
|
|
41
|
-
{ type: 'divider', label: 'Divider', icon: <HorizontalRuleIcon />, defaultProps: { text: '' } },
|
|
42
|
-
{ type: 'col', label: 'Column', icon: <ViewColumnIcon />, defaultProps: { cols: 6 } },
|
|
43
|
-
{ type: 'tabs', label: 'Tabs', icon: <TabIcon />, defaultProps: {} },
|
|
44
|
-
{ type: 'repeater', label: 'Repeater', icon: <RepeatIcon />, defaultProps: { label: 'Repeater', addLabel: 'addItem' } },
|
|
45
|
-
{ type: 'table', label: 'Table', icon: <TableChartIcon />, defaultProps: { rows: 2, cols: 2, width: 12 } },
|
|
46
|
-
{ type: 'paper', label: 'Paper', icon: <LayersIcon />, defaultProps: { label: 'Paper Group', padding: 2, variant: 'elevation', elevation: 1 } },
|
|
47
|
-
];
|
|
7
|
+
import { getToolboxItems } from '../../plugins/PluginRegistry';
|
|
8
|
+
import type { FieldPlugin } from '../../plugins/FieldPlugin';
|
|
9
|
+
import ExtensionIcon from '@mui/icons-material/Extension';
|
|
48
10
|
|
|
49
11
|
export const Toolbox: React.FC = () => {
|
|
12
|
+
const formStore = useFormStore();
|
|
50
13
|
const { t } = useTranslation();
|
|
51
14
|
const [activeTab, setActiveTab] = useState(0);
|
|
52
15
|
|
|
53
|
-
|
|
16
|
+
// Get all plugins that should appear in the Toolbox
|
|
17
|
+
const fieldPlugins = getToolboxItems('field');
|
|
18
|
+
const layoutPlugins = getToolboxItems('layout');
|
|
19
|
+
|
|
20
|
+
const handleAdd = (plugin: FieldPlugin) => {
|
|
54
21
|
const parentId = formStore.selectedNodeId || 'root';
|
|
55
22
|
const newNode = {
|
|
56
|
-
id: generateId(
|
|
57
|
-
type:
|
|
58
|
-
props: { ...
|
|
23
|
+
id: generateId(plugin.type),
|
|
24
|
+
type: plugin.type,
|
|
25
|
+
props: { ...plugin.defaultProps, name: generateId('field'), label: t(`toolbox.${plugin.type}`, plugin.label) },
|
|
59
26
|
children: [] as any[]
|
|
60
27
|
};
|
|
61
28
|
|
|
62
29
|
// Special initialization for Table
|
|
63
|
-
if (
|
|
64
|
-
const rows =
|
|
65
|
-
const cols =
|
|
30
|
+
if (plugin.type === 'table') {
|
|
31
|
+
const rows = plugin.defaultProps?.rows || 2;
|
|
32
|
+
const cols = plugin.defaultProps?.cols || 2;
|
|
66
33
|
const cells = [];
|
|
67
34
|
for (let i = 0; i < rows * cols; i++) {
|
|
68
35
|
cells.push({
|
|
@@ -89,14 +56,14 @@ export const Toolbox: React.FC = () => {
|
|
|
89
56
|
<Box flexGrow={1} overflow="auto">
|
|
90
57
|
{activeTab === 0 && (
|
|
91
58
|
<List sx={{ pt: 1 }}>
|
|
92
|
-
{
|
|
93
|
-
<ListItem key={
|
|
59
|
+
{fieldPlugins.map((plugin) => (
|
|
60
|
+
<ListItem key={plugin.type} disablePadding sx={{ px: 2, py: 0.5 }}>
|
|
94
61
|
<DraggableTool
|
|
95
|
-
type={
|
|
96
|
-
label={t(`toolbox.${
|
|
97
|
-
icon={
|
|
98
|
-
defaultProps={
|
|
99
|
-
onClick={() => handleAdd(
|
|
62
|
+
type={plugin.type}
|
|
63
|
+
label={t(`toolbox.${plugin.type}`, plugin.label)}
|
|
64
|
+
icon={plugin.icon || <ExtensionIcon />}
|
|
65
|
+
defaultProps={plugin.defaultProps}
|
|
66
|
+
onClick={() => handleAdd(plugin)}
|
|
100
67
|
/>
|
|
101
68
|
</ListItem>
|
|
102
69
|
))}
|
|
@@ -104,14 +71,14 @@ export const Toolbox: React.FC = () => {
|
|
|
104
71
|
)}
|
|
105
72
|
{activeTab === 1 && (
|
|
106
73
|
<List sx={{ pt: 1 }}>
|
|
107
|
-
{
|
|
108
|
-
<ListItem key={
|
|
74
|
+
{layoutPlugins.map((plugin) => (
|
|
75
|
+
<ListItem key={plugin.type} disablePadding sx={{ px: 2, py: 0.5 }}>
|
|
109
76
|
<DraggableTool
|
|
110
|
-
type={
|
|
111
|
-
label={t(`toolbox.${
|
|
112
|
-
icon={
|
|
113
|
-
defaultProps={
|
|
114
|
-
onClick={() => handleAdd(
|
|
77
|
+
type={plugin.type}
|
|
78
|
+
label={t(`toolbox.${plugin.type}`, plugin.label)}
|
|
79
|
+
icon={plugin.icon || <ExtensionIcon />}
|
|
80
|
+
defaultProps={plugin.defaultProps}
|
|
81
|
+
onClick={() => handleAdd(plugin)}
|
|
115
82
|
/>
|
|
116
83
|
</ListItem>
|
|
117
84
|
))}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { Checkbox, FormControlLabel, FormGroup, Box, Typography } from '@mui/material';
|
|
3
3
|
import { observer } from 'mobx-react-lite';
|
|
4
|
-
import {
|
|
4
|
+
import { useFormStore } from '../../store/FormStoreContext';
|
|
5
5
|
import type { FieldProps } from '../FieldRegistry';
|
|
6
6
|
|
|
7
7
|
export const CheckboxField: React.FC<FieldProps> = observer(({ node, path, isEditing }) => {
|
|
8
|
+
const formStore = useFormStore();
|
|
8
9
|
const { name, label } = node.props || {};
|
|
9
10
|
const fullPath = path && name ? `${path}.${name}` : name;
|
|
10
11
|
const value = fullPath ? formStore.getValue(fullPath) : false;
|
|
@@ -3,11 +3,12 @@ import { DatePicker } from '@mui/x-date-pickers/DatePicker';
|
|
|
3
3
|
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
|
|
4
4
|
import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns';
|
|
5
5
|
import { observer } from 'mobx-react-lite';
|
|
6
|
-
import {
|
|
6
|
+
import { useFormStore } from '../../store/FormStoreContext';
|
|
7
7
|
import type { FieldProps } from '../FieldRegistry';
|
|
8
8
|
import { Box, Typography, TextField } from '@mui/material';
|
|
9
9
|
|
|
10
10
|
export const DateField: React.FC<FieldProps> = observer(({ node, path, isEditing }) => {
|
|
11
|
+
const formStore = useFormStore();
|
|
11
12
|
const { name, label, placeholder } = node.props || {};
|
|
12
13
|
const fullPath = path && name ? `${path}.${name}` : name;
|
|
13
14
|
const value = fullPath ? formStore.getValue(fullPath) : null;
|
|
@@ -2,10 +2,11 @@ import React from 'react';
|
|
|
2
2
|
import { Box, Button, Typography, Input, FormHelperText } from '@mui/material';
|
|
3
3
|
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
|
|
4
4
|
import { observer } from 'mobx-react-lite';
|
|
5
|
-
import {
|
|
5
|
+
import { useFormStore } from '../../store/FormStoreContext';
|
|
6
6
|
import type { FieldProps } from '../FieldRegistry';
|
|
7
7
|
|
|
8
8
|
export const FileUploadField: React.FC<FieldProps> = observer(({ node, path, isEditing }) => {
|
|
9
|
+
const formStore = useFormStore();
|
|
9
10
|
const { name, label } = node.props || {};
|
|
10
11
|
const fullPath = path && name ? `${path}.${name}` : name;
|
|
11
12
|
const value = fullPath ? formStore.getValue(fullPath) : null;
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { Typography, Box } from '@mui/material';
|
|
3
3
|
import { observer } from 'mobx-react-lite';
|
|
4
|
-
import {
|
|
4
|
+
import { useFormStore } from '../../store/FormStoreContext';
|
|
5
5
|
import type { FieldProps } from '../FieldRegistry';
|
|
6
6
|
|
|
7
7
|
export const LabelField: React.FC<FieldProps> = observer(({ node }) => {
|
|
8
|
+
const formStore = useFormStore();
|
|
8
9
|
const { name, label = '', align = 'left', variant = 'body1' } = node.props || {};
|
|
9
10
|
|
|
10
11
|
const value = name ? formStore.getValue(name) : undefined;
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { TextField as MuiTextField, Box, Typography } from '@mui/material';
|
|
3
3
|
import { observer } from 'mobx-react-lite';
|
|
4
|
-
import {
|
|
4
|
+
import { useFormStore } from '../../store/FormStoreContext';
|
|
5
5
|
import type { FieldProps } from '../FieldRegistry';
|
|
6
6
|
|
|
7
7
|
export const NumberField: React.FC<FieldProps> = observer(({ node, path, isEditing }) => {
|
|
8
|
+
const formStore = useFormStore();
|
|
8
9
|
const { name, label } = node.props || {};
|
|
9
10
|
|
|
10
11
|
const fullPath = path && name ? `${path}.${name}` : name;
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { TextField as MuiTextField, Typography, Box } from '@mui/material';
|
|
3
3
|
import { observer } from 'mobx-react-lite';
|
|
4
|
-
import {
|
|
4
|
+
import { useFormStore } from '../../store/FormStoreContext';
|
|
5
5
|
import type { FieldProps } from '../FieldRegistry';
|
|
6
6
|
|
|
7
7
|
export const RichTextField: React.FC<FieldProps> = observer(({ node, path, isEditing }) => {
|
|
8
|
+
const formStore = useFormStore();
|
|
8
9
|
const { name, label, placeholder } = node.props || {};
|
|
9
10
|
const fullPath = path && name ? `${path}.${name}` : name;
|
|
10
11
|
const value = fullPath ? formStore.getValue(fullPath) : '';
|
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { FormControl, Select, MenuItem, Box, Typography, FormHelperText, Autocomplete, TextField as MuiTextField } from '@mui/material';
|
|
3
3
|
import { observer } from 'mobx-react-lite';
|
|
4
|
-
import {
|
|
4
|
+
import { useFormStore } from '../../store/FormStoreContext';
|
|
5
5
|
import type { FieldProps } from '../FieldRegistry';
|
|
6
6
|
|
|
7
7
|
export const SelectField: React.FC<FieldProps> = observer(({ node, path, isEditing }) => {
|
|
8
|
-
const
|
|
8
|
+
const formStore = useFormStore();
|
|
9
|
+
const { name, label, options: staticOptions = [], optionsSource, enableAutocomplete = false, placeholder } = node.props || {};
|
|
10
|
+
|
|
11
|
+
// Resolve dynamic options if optionsSource is provided
|
|
12
|
+
const dynamicOptions = optionsSource ? (formStore.externalConstants[optionsSource] || []) : [];
|
|
13
|
+
const options = optionsSource ? dynamicOptions : staticOptions;
|
|
14
|
+
|
|
9
15
|
const fullPath = path && name ? `${path}.${name}` : name;
|
|
10
16
|
const value = fullPath ? formStore.getValue(fullPath) : '';
|
|
11
17
|
const error = fullPath ? formStore.errors[fullPath] : undefined;
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { TextField as MuiTextField, Box, Typography } from '@mui/material';
|
|
3
3
|
import { observer } from 'mobx-react-lite';
|
|
4
|
-
import {
|
|
4
|
+
import { useFormStore } from '../../store/FormStoreContext';
|
|
5
5
|
import type { FieldProps } from '../FieldRegistry';
|
|
6
6
|
|
|
7
7
|
export const TextField: React.FC<FieldProps> = observer(({ node, path, isEditing }) => {
|
|
8
|
+
const formStore = useFormStore();
|
|
8
9
|
const { name, label, placeholder } = node.props || {};
|
|
9
10
|
|
|
10
11
|
// Resolve full path: path.name or just name
|
|
@@ -3,12 +3,13 @@ import { Box, Button, Typography, Paper, IconButton } from '@mui/material';
|
|
|
3
3
|
import DeleteIcon from '@mui/icons-material/Delete';
|
|
4
4
|
import AddIcon from '@mui/icons-material/Add';
|
|
5
5
|
import { observer } from 'mobx-react-lite';
|
|
6
|
-
import {
|
|
6
|
+
import { useFormStore } from '../../store/FormStoreContext';
|
|
7
7
|
import type { FieldProps } from '../FieldRegistry';
|
|
8
8
|
import { LayoutPlaceholder } from './LayoutPlaceholder';
|
|
9
9
|
import { FormChildrenRenderer } from '../builder/FormChildrenRenderer';
|
|
10
10
|
|
|
11
11
|
export const FormRepeater: React.FC<FieldProps> = observer(({ node, path, isEditing }) => {
|
|
12
|
+
const formStore = useFormStore();
|
|
12
13
|
const { name, label, addLabel = 'Add Item' } = node.props || {};
|
|
13
14
|
const fullPath = path && name ? `${path}.${name}` : name;
|
|
14
15
|
const items: any[] = (fullPath ? formStore.getValue(fullPath) : []) || [];
|
|
@@ -3,10 +3,11 @@ import { Box, Tabs, Tab, Paper } from '@mui/material';
|
|
|
3
3
|
import { observer } from 'mobx-react-lite';
|
|
4
4
|
import type { FieldProps } from '../FieldRegistry';
|
|
5
5
|
import { FormRenderer } from '../FormRenderer';
|
|
6
|
-
import {
|
|
6
|
+
import { useFormStore } from '../../store/FormStoreContext';
|
|
7
7
|
import { LayoutPlaceholder } from './LayoutPlaceholder';
|
|
8
8
|
|
|
9
9
|
export const FormTabs: React.FC<FieldProps> = observer(({ node, path, isEditing }) => {
|
|
10
|
+
const formStore = useFormStore();
|
|
10
11
|
const [activeTab, setActiveTab] = React.useState(0);
|
|
11
12
|
const allTabs = node.children || [];
|
|
12
13
|
const tabs = isEditing
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
+
import { registerPlugin } from '../plugins/PluginRegistry';
|
|
1
2
|
import { registerComponent } from './FieldRegistry';
|
|
3
|
+
import type { FieldPlugin } from '../plugins/FieldPlugin';
|
|
4
|
+
|
|
5
|
+
// Field components
|
|
2
6
|
import { TextField } from './fields/TextField';
|
|
3
7
|
import { NumberField } from './fields/NumberField';
|
|
4
8
|
import { CheckboxField } from './fields/CheckboxField';
|
|
@@ -6,25 +10,77 @@ import { SelectField } from './fields/SelectField';
|
|
|
6
10
|
import { DateField } from './fields/DateField';
|
|
7
11
|
import { FileUploadField } from './fields/FileUploadField';
|
|
8
12
|
import { RichTextField } from './fields/RichTextField';
|
|
13
|
+
import { LabelField } from './fields/LabelField';
|
|
14
|
+
|
|
15
|
+
// Layout components
|
|
9
16
|
import { FormRow } from './layout/FormRow';
|
|
10
17
|
import { FormCol } from './layout/FormCol';
|
|
11
18
|
import { FormTabs } from './layout/FormTabs';
|
|
12
19
|
import { FormTab } from './layout/FormTab';
|
|
13
20
|
import { FormRepeater } from './layout/FormRepeater';
|
|
14
21
|
import { FormPaper } from './layout/FormPaper';
|
|
22
|
+
import { FormTable } from './layout/FormTable';
|
|
23
|
+
import { FormTableCell } from './layout/FormTableCell';
|
|
24
|
+
import { FormDivider } from './layout/FormDivider';
|
|
25
|
+
|
|
26
|
+
// Icons
|
|
27
|
+
import TextFieldsIcon from '@mui/icons-material/TextFields';
|
|
28
|
+
import NumbersIcon from '@mui/icons-material/Numbers';
|
|
29
|
+
import CheckBoxIcon from '@mui/icons-material/CheckBox';
|
|
30
|
+
import ArrowDropDownCircleIcon from '@mui/icons-material/ArrowDropDownCircle';
|
|
31
|
+
import CalendarTodayIcon from '@mui/icons-material/CalendarToday';
|
|
32
|
+
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
|
|
33
|
+
import FormatQuoteIcon from '@mui/icons-material/FormatQuote';
|
|
34
|
+
import LabelIcon from '@mui/icons-material/Label';
|
|
35
|
+
import HorizontalRuleIcon from '@mui/icons-material/HorizontalRule';
|
|
36
|
+
import ViewColumnIcon from '@mui/icons-material/ViewColumn';
|
|
37
|
+
import TabIcon from '@mui/icons-material/Tab';
|
|
38
|
+
import RepeatIcon from '@mui/icons-material/Repeat';
|
|
39
|
+
import TableChartIcon from '@mui/icons-material/TableChart';
|
|
40
|
+
import LayersIcon from '@mui/icons-material/Layers';
|
|
41
|
+
import React from 'react';
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Built-in field plugins.
|
|
45
|
+
*/
|
|
46
|
+
const BUILT_IN_FIELD_PLUGINS: FieldPlugin[] = [
|
|
47
|
+
{ type: 'text', label: 'Text Field', icon: React.createElement(TextFieldsIcon), category: 'field', defaultProps: { label: 'New Text Field', width: 6 }, component: TextField },
|
|
48
|
+
{ type: 'number', label: 'Number Field', icon: React.createElement(NumbersIcon), category: 'field', defaultProps: { label: 'New Number Field', width: 6 }, component: NumberField },
|
|
49
|
+
{ type: 'checkbox', label: 'Checkbox', icon: React.createElement(CheckBoxIcon), category: 'field', defaultProps: { label: 'New Checkbox', width: 6 }, component: CheckboxField },
|
|
50
|
+
{ type: 'select', label: 'Select', icon: React.createElement(ArrowDropDownCircleIcon), category: 'field', defaultProps: { label: 'New Select', width: 6, options: [{ label: 'Option 1', value: '1' }] }, component: SelectField },
|
|
51
|
+
{ type: 'date', label: 'Date Picker', icon: React.createElement(CalendarTodayIcon), category: 'field', defaultProps: { label: 'New Date', width: 6 }, component: DateField },
|
|
52
|
+
{ type: 'file', label: 'File Upload', icon: React.createElement(CloudUploadIcon), category: 'field', defaultProps: { label: 'New File Upload', width: 6 }, component: FileUploadField },
|
|
53
|
+
{ type: 'richtext', label: 'Rich Text', icon: React.createElement(FormatQuoteIcon), category: 'field', defaultProps: { label: 'New Rich Text', width: 6 }, component: RichTextField },
|
|
54
|
+
{ type: 'label', label: 'Label', icon: React.createElement(LabelIcon), category: 'field', defaultProps: { text: 'Static Text', variant: 'body1', width: 6 }, component: LabelField },
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Built-in layout plugins.
|
|
59
|
+
*/
|
|
60
|
+
const BUILT_IN_LAYOUT_PLUGINS: FieldPlugin[] = [
|
|
61
|
+
{ type: 'divider', label: 'Divider', icon: React.createElement(HorizontalRuleIcon), category: 'layout', defaultProps: { text: '' }, component: FormDivider },
|
|
62
|
+
{ type: 'col', label: 'Column', icon: React.createElement(ViewColumnIcon), category: 'layout', isContainer: true, defaultProps: { cols: 6 }, component: FormCol },
|
|
63
|
+
{ type: 'tabs', label: 'Tabs', icon: React.createElement(TabIcon), category: 'layout', isContainer: true, defaultProps: {}, component: FormTabs },
|
|
64
|
+
{ type: 'repeater', label: 'Repeater', icon: React.createElement(RepeatIcon), category: 'layout', isContainer: true, defaultProps: { label: 'Repeater', addLabel: 'addItem' }, component: FormRepeater },
|
|
65
|
+
{ type: 'table', label: 'Table', icon: React.createElement(TableChartIcon), category: 'layout', isContainer: true, defaultProps: { rows: 2, cols: 2, width: 12 }, component: FormTable },
|
|
66
|
+
{ type: 'paper', label: 'Paper', icon: React.createElement(LayersIcon), category: 'layout', isContainer: true, defaultProps: { label: 'Paper Group', padding: 2, variant: 'elevation', elevation: 1 }, component: FormPaper },
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Internal-only plugins (not shown in Toolbox but needed for rendering).
|
|
71
|
+
*/
|
|
72
|
+
const INTERNAL_PLUGINS: FieldPlugin[] = [
|
|
73
|
+
{ type: 'tab', label: 'Tab', category: 'layout', isContainer: true, showInToolbox: false, component: FormTab },
|
|
74
|
+
{ type: 'cell', label: 'Cell', category: 'layout', isContainer: true, showInToolbox: false, component: FormTableCell },
|
|
75
|
+
{ type: 'row', label: 'Row', category: 'layout', isContainer: true, showInToolbox: false, defaultProps: {}, component: FormRow },
|
|
76
|
+
];
|
|
15
77
|
|
|
78
|
+
/**
|
|
79
|
+
* Registers all built-in components as full plugins.
|
|
80
|
+
* Call this once at app startup. Backward compatible.
|
|
81
|
+
*/
|
|
16
82
|
export const registerAllComponents = () => {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
registerComponent('select', SelectField);
|
|
21
|
-
registerComponent('date', DateField);
|
|
22
|
-
registerComponent('file', FileUploadField);
|
|
23
|
-
registerComponent('richtext', RichTextField);
|
|
24
|
-
registerComponent('row', FormRow);
|
|
25
|
-
registerComponent('col', FormCol);
|
|
26
|
-
registerComponent('tabs', FormTabs);
|
|
27
|
-
registerComponent('tab', FormTab);
|
|
28
|
-
registerComponent('repeater', FormRepeater);
|
|
29
|
-
registerComponent('paper', FormPaper);
|
|
83
|
+
for (const plugin of [...BUILT_IN_FIELD_PLUGINS, ...BUILT_IN_LAYOUT_PLUGINS, ...INTERNAL_PLUGINS]) {
|
|
84
|
+
registerPlugin(plugin);
|
|
85
|
+
}
|
|
30
86
|
};
|
package/src/index.ts
CHANGED
|
@@ -3,3 +3,10 @@ export * from './components/FormRenderer';
|
|
|
3
3
|
export * from './components/FieldRegistry';
|
|
4
4
|
export * from './components/registerComponents';
|
|
5
5
|
export { FormBuilder } from './components/builder/FormBuilder';
|
|
6
|
+
|
|
7
|
+
// Plugin system
|
|
8
|
+
export { registerPlugin, registerPlugins, getPlugin, getAllPlugins, getToolboxItems, isContainerType } from './plugins/PluginRegistry';
|
|
9
|
+
export type { FieldPlugin, FieldProps, PluginPropertiesEditorProps } from './plugins/FieldPlugin';
|
|
10
|
+
|
|
11
|
+
// Context-bound state management
|
|
12
|
+
export { FormStoreProvider, useFormStore, createFormStore } from './store/FormStoreContext';
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { SchemaNode, ValidationRules } from '../store/FormStore';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Props passed to every field component (built-in or plugin).
|
|
6
|
+
* Re-exported here for convenience when writing plugins.
|
|
7
|
+
*/
|
|
8
|
+
export interface FieldProps {
|
|
9
|
+
node: SchemaNode;
|
|
10
|
+
path?: string;
|
|
11
|
+
isEditing?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Props passed to a plugin's custom properties editor.
|
|
16
|
+
*/
|
|
17
|
+
export interface PluginPropertiesEditorProps {
|
|
18
|
+
node: SchemaNode;
|
|
19
|
+
onChange: (key: string, value: any) => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Describes a full plugin that integrates into both
|
|
24
|
+
* the FormBuilder (constructor) and FormRenderer (viewer).
|
|
25
|
+
*/
|
|
26
|
+
export interface FieldPlugin {
|
|
27
|
+
/** Unique type identifier (e.g. 'rating', 'signature', 'address') */
|
|
28
|
+
type: string;
|
|
29
|
+
|
|
30
|
+
/** Display name shown in the Toolbox */
|
|
31
|
+
label: string;
|
|
32
|
+
|
|
33
|
+
/** Icon for the Toolbox (React element, e.g. MUI icon) */
|
|
34
|
+
icon?: React.ReactNode;
|
|
35
|
+
|
|
36
|
+
/** Category in the Toolbox: 'field' (default) or 'layout' */
|
|
37
|
+
category?: 'field' | 'layout';
|
|
38
|
+
|
|
39
|
+
/** Whether to show in Toolbox (default: true). Set to false for internal types. */
|
|
40
|
+
showInToolbox?: boolean;
|
|
41
|
+
|
|
42
|
+
/** Whether this type is a container that can hold children */
|
|
43
|
+
isContainer?: boolean;
|
|
44
|
+
|
|
45
|
+
/** Default props applied when a new node of this type is created */
|
|
46
|
+
defaultProps?: Record<string, any>;
|
|
47
|
+
|
|
48
|
+
/** React component used to render the field */
|
|
49
|
+
component: React.FC<FieldProps>;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Optional React component rendered in the Properties Panel
|
|
53
|
+
* when a node of this type is selected.
|
|
54
|
+
*/
|
|
55
|
+
propertiesEditor?: React.FC<PluginPropertiesEditorProps>;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Optional custom validation function.
|
|
59
|
+
* Called after standard validation. Return an error message string
|
|
60
|
+
* or null if valid.
|
|
61
|
+
*/
|
|
62
|
+
validate?: (value: any, rules: ValidationRules) => string | null;
|
|
63
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { FieldPlugin, FieldProps } from './FieldPlugin';
|
|
2
|
+
import type React from 'react';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Central plugin registry.
|
|
6
|
+
* Stores all registered plugins (both built-in and user-defined).
|
|
7
|
+
*/
|
|
8
|
+
const plugins: Map<string, FieldPlugin> = new Map();
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Built-in container types that are always recognized.
|
|
12
|
+
* User plugins can also set isContainer: true.
|
|
13
|
+
*/
|
|
14
|
+
const BUILT_IN_CONTAINERS = new Set([
|
|
15
|
+
'root', 'row', 'col', 'tabs', 'tab', 'paper', 'repeater', 'table', 'cell'
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Register a single plugin. If a plugin with the same type already exists,
|
|
20
|
+
* it will be overwritten (allowing user overrides of built-in types).
|
|
21
|
+
*/
|
|
22
|
+
export function registerPlugin(plugin: FieldPlugin): void {
|
|
23
|
+
plugins.set(plugin.type, plugin);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Register multiple plugins at once.
|
|
28
|
+
*/
|
|
29
|
+
export function registerPlugins(pluginList: FieldPlugin[]): void {
|
|
30
|
+
for (const plugin of pluginList) {
|
|
31
|
+
registerPlugin(plugin);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Get a plugin by its type identifier.
|
|
37
|
+
*/
|
|
38
|
+
export function getPlugin(type: string): FieldPlugin | undefined {
|
|
39
|
+
return plugins.get(type);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Get all registered plugins.
|
|
44
|
+
*/
|
|
45
|
+
export function getAllPlugins(): FieldPlugin[] {
|
|
46
|
+
return Array.from(plugins.values());
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Get plugins for the Toolbox, filtered by category.
|
|
51
|
+
*/
|
|
52
|
+
export function getToolboxItems(category: 'field' | 'layout'): FieldPlugin[] {
|
|
53
|
+
return getAllPlugins().filter(p =>
|
|
54
|
+
(p.category || 'field') === category && p.showInToolbox !== false
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Check if a type is a container (can hold children).
|
|
60
|
+
* Returns true for built-in containers and user plugins with isContainer: true.
|
|
61
|
+
*/
|
|
62
|
+
export function isContainerType(type: string): boolean {
|
|
63
|
+
if (BUILT_IN_CONTAINERS.has(type)) return true;
|
|
64
|
+
const plugin = getPlugin(type);
|
|
65
|
+
return plugin?.isContainer === true;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Backward-compatible bridge: register a bare component as a minimal plugin.
|
|
70
|
+
* Used by the legacy registerComponent() API.
|
|
71
|
+
*/
|
|
72
|
+
export function registerComponentAsPlugin(type: string, component: React.FC<FieldProps>): void {
|
|
73
|
+
// Only create a new plugin if one doesn't already exist for this type
|
|
74
|
+
// (prevents overwriting a full plugin with a bare component)
|
|
75
|
+
if (!plugins.has(type)) {
|
|
76
|
+
registerPlugin({
|
|
77
|
+
type,
|
|
78
|
+
label: type,
|
|
79
|
+
component,
|
|
80
|
+
});
|
|
81
|
+
} else {
|
|
82
|
+
// Update just the component on the existing plugin
|
|
83
|
+
const existing = plugins.get(type)!;
|
|
84
|
+
existing.component = component;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Get the React component for a given type.
|
|
90
|
+
* Backward-compatible replacement for the old getComponent().
|
|
91
|
+
*/
|
|
92
|
+
export function getComponentForType(type: string): React.FC<FieldProps> | undefined {
|
|
93
|
+
return plugins.get(type)?.component;
|
|
94
|
+
}
|
package/src/store/FormStore.ts
CHANGED
|
@@ -1,26 +1,15 @@
|
|
|
1
1
|
import { makeAutoObservable, toJS } from "mobx";
|
|
2
2
|
import i18next from 'i18next';
|
|
3
3
|
import { transformAPIDataToSchema, transformSchemaToSubmission, transformDictionaryToOptions } from '../utils/apiTransformer';
|
|
4
|
+
import { getPlugin, isContainerType } from '../plugins/PluginRegistry';
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
| 'col'
|
|
13
|
-
| 'tabs' // container for tabs
|
|
14
|
-
| 'tab' // individual tab
|
|
15
|
-
| 'table'
|
|
16
|
-
| 'cell'
|
|
17
|
-
| 'paper'
|
|
18
|
-
| 'repeater'
|
|
19
|
-
| 'date'
|
|
20
|
-
| 'file'
|
|
21
|
-
| 'richtext'
|
|
22
|
-
| 'label'
|
|
23
|
-
| 'divider';
|
|
6
|
+
/**
|
|
7
|
+
* Node type identifier. Open string type to support plugin-defined types.
|
|
8
|
+
* Built-in types: 'root', 'text', 'number', 'checkbox', 'select', 'row',
|
|
9
|
+
* 'col', 'tabs', 'tab', 'table', 'cell', 'paper', 'repeater', 'date',
|
|
10
|
+
* 'file', 'richtext', 'label', 'divider'.
|
|
11
|
+
*/
|
|
12
|
+
export type NodeType = string;
|
|
24
13
|
|
|
25
14
|
export interface FieldOption {
|
|
26
15
|
label: string;
|
|
@@ -101,6 +90,9 @@ export class FormStore {
|
|
|
101
90
|
// Session data (user info, report IDs, etc.)
|
|
102
91
|
sessionVariables: Record<string, any> = {};
|
|
103
92
|
|
|
93
|
+
// External constants for formulas and lists
|
|
94
|
+
externalConstants: Record<string, any> = {};
|
|
95
|
+
|
|
104
96
|
// Pool of available API columns for mapping
|
|
105
97
|
availableApiColumns: any[] = [];
|
|
106
98
|
|
|
@@ -113,6 +105,10 @@ export class FormStore {
|
|
|
113
105
|
this.isEditMode = mode;
|
|
114
106
|
}
|
|
115
107
|
|
|
108
|
+
setConstant(name: string, value: any) {
|
|
109
|
+
this.externalConstants[name] = value;
|
|
110
|
+
}
|
|
111
|
+
|
|
116
112
|
// --- Actions ---
|
|
117
113
|
|
|
118
114
|
selectNode(id: string | null) {
|
|
@@ -308,7 +304,7 @@ export class FormStore {
|
|
|
308
304
|
|
|
309
305
|
// --- Validation ---
|
|
310
306
|
|
|
311
|
-
validateField(path: string, value: any, rules?: ValidationRules) {
|
|
307
|
+
validateField(path: string, value: any, rules?: ValidationRules, nodeType?: string) {
|
|
312
308
|
if (!rules) return;
|
|
313
309
|
|
|
314
310
|
let error: string | null = null;
|
|
@@ -369,6 +365,14 @@ export class FormStore {
|
|
|
369
365
|
}
|
|
370
366
|
}
|
|
371
367
|
|
|
368
|
+
// Plugin custom validation
|
|
369
|
+
if (!error && nodeType) {
|
|
370
|
+
const plugin = getPlugin(nodeType);
|
|
371
|
+
if (plugin?.validate) {
|
|
372
|
+
error = plugin.validate(value, rules);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
372
376
|
if (error) {
|
|
373
377
|
this.errors[path] = error;
|
|
374
378
|
} else {
|
|
@@ -420,7 +424,13 @@ export class FormStore {
|
|
|
420
424
|
return JSON.stringify(new Date().toISOString());
|
|
421
425
|
}
|
|
422
426
|
|
|
423
|
-
|
|
427
|
+
let value = this.getValue(fieldName);
|
|
428
|
+
|
|
429
|
+
// Fallback to external constants if not found in form values
|
|
430
|
+
if (value === undefined && this.externalConstants[fieldName] !== undefined) {
|
|
431
|
+
value = this.externalConstants[fieldName];
|
|
432
|
+
}
|
|
433
|
+
|
|
424
434
|
// If it's a number, return it raw. If it's a string, JSON.stringify it.
|
|
425
435
|
// If undefined/null, return '0' for safety in math.
|
|
426
436
|
if (value === undefined || value === null) return '0';
|
|
@@ -648,8 +658,7 @@ export class FormStore {
|
|
|
648
658
|
const overNode = this.findNode(this.rootNode, overId);
|
|
649
659
|
|
|
650
660
|
// If we drop on a container, the parent is the container itself
|
|
651
|
-
const
|
|
652
|
-
const isOverContainer = overNode && containerTypes.includes(overNode.type);
|
|
661
|
+
const isOverContainer = overNode && isContainerType(overNode.type);
|
|
653
662
|
const overParent = isOverContainer ? overNode : this.findParent(this.rootNode, overId);
|
|
654
663
|
|
|
655
664
|
if (!activeNode || !activeParent || !activeParent.children) {
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import React, { createContext, useContext } from 'react';
|
|
2
|
+
import { FormStore, formStore as defaultFormStore } from './FormStore';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* React Context for providing FormStore instances to the component tree.
|
|
6
|
+
* When null, components fall back to the default singleton.
|
|
7
|
+
*/
|
|
8
|
+
const FormStoreContext = createContext<FormStore | null>(null);
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Hook to get the current FormStore from context.
|
|
12
|
+
* Falls back to the default singleton if no provider is found.
|
|
13
|
+
*/
|
|
14
|
+
export function useFormStore(): FormStore {
|
|
15
|
+
const store = useContext(FormStoreContext);
|
|
16
|
+
return store ?? defaultFormStore;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Factory function to create a new isolated FormStore instance.
|
|
21
|
+
* Use this when you need multiple independent form instances.
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* const myStore = createFormStore();
|
|
25
|
+
* <FormStoreProvider store={myStore}>
|
|
26
|
+
* <FormBuilder />
|
|
27
|
+
* </FormStoreProvider>
|
|
28
|
+
*/
|
|
29
|
+
export function createFormStore(): FormStore {
|
|
30
|
+
return new FormStore();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface FormStoreProviderProps {
|
|
34
|
+
/** FormStore instance to provide. If omitted, creates a new one. */
|
|
35
|
+
store?: FormStore;
|
|
36
|
+
children: React.ReactNode;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Provider component that supplies a FormStore instance to all descendant
|
|
41
|
+
* form components. Enables multiple independent form instances on the same page.
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* // Using an external store
|
|
45
|
+
* const store = createFormStore();
|
|
46
|
+
* <FormStoreProvider store={store}>
|
|
47
|
+
* <FormBuilder />
|
|
48
|
+
* </FormStoreProvider>
|
|
49
|
+
*
|
|
50
|
+
* @example
|
|
51
|
+
* // Auto-creates an internal store
|
|
52
|
+
* <FormStoreProvider>
|
|
53
|
+
* <FormBuilder />
|
|
54
|
+
* </FormStoreProvider>
|
|
55
|
+
*/
|
|
56
|
+
export const FormStoreProvider: React.FC<FormStoreProviderProps> = ({ store, children }) => {
|
|
57
|
+
const [internalStore] = React.useState(() => store ?? createFormStore());
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<FormStoreContext.Provider value={store ?? internalStore}>
|
|
61
|
+
{children}
|
|
62
|
+
</FormStoreContext.Provider>
|
|
63
|
+
);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export { FormStoreContext };
|