@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stormlmd/form-builder",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "module": "./src/index.ts",
@@ -1,9 +1,12 @@
1
+ /**
2
+ * FieldRegistry — backward-compatible facade over PluginRegistry.
3
+ *
4
+ * Existing code that imports registerComponent/getComponent from here
5
+ * will continue to work unchanged.
6
+ */
1
7
  import React from 'react';
2
8
  import type { SchemaNode } from '../store/FormStore';
3
- import { FormTable } from './layout/FormTable';
4
- import { FormTableCell } from './layout/FormTableCell';
5
- import { FormTabs } from './layout/FormTabs';
6
- import { FormTab } from './layout/FormTab';
9
+ import { registerComponentAsPlugin, getComponentForType } from '../plugins/PluginRegistry';
7
10
 
8
11
  export interface FieldProps {
9
12
  node: SchemaNode;
@@ -13,22 +16,17 @@ export interface FieldProps {
13
16
 
14
17
  type ComponentType = React.FC<FieldProps>;
15
18
 
16
- const registry: Record<string, ComponentType> = {};
17
-
19
+ /**
20
+ * @deprecated Use registerPlugin() for new code. This function is kept
21
+ * for backward compatibility and creates a minimal plugin entry.
22
+ */
18
23
  export const registerComponent = (type: string, component: ComponentType) => {
19
- registry[type] = component;
24
+ registerComponentAsPlugin(type, component);
20
25
  };
21
26
 
22
- import { LabelField } from './fields/LabelField';
23
- import { FormDivider } from './layout/FormDivider';
24
-
27
+ /**
28
+ * Get the React component for a given type.
29
+ */
25
30
  export const getComponent = (type: string): ComponentType | undefined => {
26
- return registry[type];
31
+ return getComponentForType(type);
27
32
  };
28
-
29
- registerComponent('table', FormTable);
30
- registerComponent('cell', FormTableCell);
31
- registerComponent('label', LabelField);
32
- registerComponent('divider', FormDivider);
33
- registerComponent('tabs', FormTabs);
34
- registerComponent('tab', FormTab);
@@ -1,9 +1,10 @@
1
1
  import React from 'react';
2
2
  import { observer } from 'mobx-react-lite';
3
3
  import { Box, Typography, Tooltip } from '@mui/material';
4
- import { formStore } from '../store/FormStore';
4
+ import { useFormStore } from '../store/FormStoreContext';
5
5
  import type { SchemaNode } from '../store/FormStore';
6
6
  import { getComponent } from './FieldRegistry';
7
+ import { isContainerType } from '../plugins/PluginRegistry';
7
8
  import { FormChildrenRenderer } from './builder/FormChildrenRenderer';
8
9
  import { EditorWrapper } from './builder/EditorWrapper';
9
10
  import { SortableNode } from './builder/SortableNode';
@@ -27,6 +28,7 @@ export const DropIndicator = () => (
27
28
  );
28
29
 
29
30
  export const FormRenderer: React.FC<FormRendererProps> = observer(({ node, path, isEditing }) => {
31
+ const formStore = useFormStore();
30
32
  // Evaluate condition if present (skip in editing mode)
31
33
  if (!isEditing && node.condition) {
32
34
  const conditionMet = formStore.evaluateCondition(node.condition);
@@ -76,7 +78,7 @@ export const FormRenderer: React.FC<FormRendererProps> = observer(({ node, path,
76
78
 
77
79
  const width = `${(colSpan / 12) * 100}%`;
78
80
 
79
- const isContainer = ['root', 'row', 'col', 'tab', 'tabs', 'paper', 'table', 'cell', 'repeater'].includes(node.type);
81
+ const isContainer = isContainerType(node.type);
80
82
 
81
83
  const layoutSx = {
82
84
  width: width,
@@ -2,7 +2,8 @@ import React from 'react';
2
2
  import { Box, IconButton } from '@mui/material';
3
3
  import SettingsIcon from '@mui/icons-material/Settings';
4
4
  import { observer } from 'mobx-react-lite';
5
- import { formStore, type SchemaNode } from '../../store/FormStore';
5
+ import { type SchemaNode } from '../../store/FormStore';
6
+ import { useFormStore } from '../../store/FormStoreContext';
6
7
 
7
8
  interface EditorWrapperProps {
8
9
  node: SchemaNode;
@@ -10,6 +11,7 @@ interface EditorWrapperProps {
10
11
  }
11
12
 
12
13
  export const EditorWrapper: React.FC<EditorWrapperProps> = observer(({ node, children }) => {
14
+ const formStore = useFormStore();
13
15
  const isSelected = formStore.selectedNodeId === node.id;
14
16
 
15
17
  const handleClick = (e: React.MouseEvent) => {
@@ -22,9 +22,11 @@ import {
22
22
  sortableKeyboardCoordinates
23
23
  } from '@dnd-kit/sortable';
24
24
  import {
25
- formStore,
26
25
  type NodeType
27
26
  } from '../../store/FormStore';
27
+ import { useFormStore } from '../../store/FormStoreContext';
28
+ import { FormStoreProvider } from '../../store/FormStoreContext';
29
+ import type { FormStore } from '../../store/FormStore';
28
30
  import { FormRenderer } from '../FormRenderer';
29
31
  import { Toolbox } from './Toolbox';
30
32
  import { IntegrationSettings } from './IntegrationSettings';
@@ -34,9 +36,12 @@ import { DroppableCanvas } from './DroppableCanvas';
34
36
 
35
37
  interface FormBuilderProps {
36
38
  onCancel?: () => void;
39
+ /** Optional FormStore instance. If omitted, the default singleton is used. */
40
+ store?: FormStore;
37
41
  }
38
42
 
39
- export const FormBuilder: React.FC<FormBuilderProps> = observer(({ onCancel }) => {
43
+ const FormBuilderInner: React.FC<FormBuilderProps> = observer(({ onCancel }) => {
44
+ const formStore = useFormStore();
40
45
  const { t } = useTranslation();
41
46
  const [activeId, setActiveId] = React.useState<string | null>(null);
42
47
  const [activeData, setActiveData] = React.useState<any>(null);
@@ -311,3 +316,16 @@ export const FormBuilder: React.FC<FormBuilderProps> = observer(({ onCancel }) =
311
316
  </DndContext>
312
317
  );
313
318
  });
319
+
320
+ /** Public FormBuilder component. Wraps in FormStoreProvider if store prop is provided. */
321
+ export const FormBuilder: React.FC<FormBuilderProps> = ({ store, ...props }) => {
322
+ if (store) {
323
+ return (
324
+ <FormStoreProvider store={store}>
325
+ <FormBuilderInner {...props} />
326
+ </FormStoreProvider>
327
+ );
328
+ }
329
+ return <FormBuilderInner {...props} />;
330
+ };
331
+
@@ -2,7 +2,8 @@ import React from 'react';
2
2
  import { observer } from 'mobx-react-lite';
3
3
  import { Box, type SxProps, type Theme } from '@mui/material';
4
4
  import { SortableContext, rectSortingStrategy } from '@dnd-kit/sortable';
5
- import { formStore, type SchemaNode } from '../../store/FormStore';
5
+ import type { SchemaNode } from '../../store/FormStore';
6
+ import { useFormStore } from '../../store/FormStoreContext';
6
7
  import { FormRenderer, DropIndicator } from '../FormRenderer';
7
8
 
8
9
  interface FormChildrenRendererProps {
@@ -20,6 +21,7 @@ export const FormChildrenRenderer: React.FC<FormChildrenRendererProps> = observe
20
21
  layout = 'horizontal',
21
22
  path
22
23
  }) => {
24
+ const formStore = useFormStore();
23
25
  const childIds = children?.map(c => c.id) || [];
24
26
  const { dropIndicator } = formStore;
25
27
 
@@ -9,9 +9,11 @@ import DeleteIcon from '@mui/icons-material/Delete';
9
9
  import AddIcon from '@mui/icons-material/Add';
10
10
  import SyncIcon from '@mui/icons-material/Sync';
11
11
  import { observer } from 'mobx-react-lite';
12
- import { formStore, type APIIntegration } from '../../store/FormStore';
12
+ import type { APIIntegration } from '../../store/FormStore';
13
+ import { useFormStore } from '../../store/FormStoreContext';
13
14
 
14
15
  export const IntegrationSettings: React.FC = observer(() => {
16
+ const formStore = useFormStore();
15
17
  const [open, setOpen] = useState(false);
16
18
  const [currentIntegration, setCurrentIntegration] = useState<Partial<APIIntegration>>({
17
19
  method: 'POST',
@@ -0,0 +1,90 @@
1
+ import React from 'react';
2
+ import { Box, TextField, IconButton, Button, Typography, 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 { formStore } from '../../store/FormStore';
16
+ import { useFormStore } from '../../store/FormStoreContext';
17
17
  import CloseIcon from '@mui/icons-material/Close';
18
18
  import { PropertiesPanel } from './PropertiesPanel'; // We reuse the inner content for now
19
19
  // Actually PropertiesPanel has its own layout, we should extract the content
@@ -23,6 +23,7 @@ import { PropertiesPanel } from './PropertiesPanel'; // We reuse the inner conte
23
23
  // Let's modify PropertiesPanel to accept a "mode" or just use it as content.
24
24
 
25
25
  export const PropertiesModal: React.FC = observer(() => {
26
+ const formStore = useFormStore();
26
27
  const { t } = useTranslation();
27
28
  const theme = useTheme();
28
29
  const fullScreen = useMediaQuery(theme.breakpoints.down('md'));
@@ -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 { formStore } from '../../store/FormStore';
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 isLayout = node ? ['root', 'row', 'col', 'tabs', 'tab', 'paper', 'repeater', 'table', 'cell', 'divider'].includes(node.type) : false;
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
- : t('properties.selectOptionsHelper')}
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 { formStore } from '../../store/FormStore';
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 { formStore, type NodeType } from '../../store/FormStore';
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 TextFieldsIcon from '@mui/icons-material/TextFields';
8
- import NumbersIcon from '@mui/icons-material/Numbers';
9
- import CheckBoxIcon from '@mui/icons-material/CheckBox';
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
- const handleAdd = (tool: ToolItem) => {
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(tool.type),
57
- type: tool.type,
58
- props: { ...tool.defaultProps, name: generateId('field'), label: t(`toolbox.${tool.type}`) },
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 (tool.type === 'table') {
64
- const rows = tool.defaultProps?.rows || 2;
65
- const cols = tool.defaultProps?.cols || 2;
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
- {TOOLS.map((tool) => (
93
- <ListItem key={tool.type} disablePadding sx={{ px: 2, py: 0.5 }}>
59
+ {fieldPlugins.map((plugin) => (
60
+ <ListItem key={plugin.type} disablePadding sx={{ px: 2, py: 0.5 }}>
94
61
  <DraggableTool
95
- type={tool.type}
96
- label={t(`toolbox.${tool.type}`)}
97
- icon={tool.icon}
98
- defaultProps={tool.defaultProps}
99
- onClick={() => handleAdd(tool)}
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
- {LAYOUT_TOOLS.map((tool) => (
108
- <ListItem key={tool.type} disablePadding sx={{ px: 2, py: 0.5 }}>
74
+ {layoutPlugins.map((plugin) => (
75
+ <ListItem key={plugin.type} disablePadding sx={{ px: 2, py: 0.5 }}>
109
76
  <DraggableTool
110
- type={tool.type}
111
- label={t(`toolbox.${tool.type}`)}
112
- icon={tool.icon}
113
- defaultProps={tool.defaultProps}
114
- onClick={() => handleAdd(tool)}
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 { formStore } from '../../store/FormStore';
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 { formStore } from '../../store/FormStore';
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 { formStore } from '../../store/FormStore';
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 { formStore } from '../../store/FormStore';
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 { formStore } from '../../store/FormStore';
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 { formStore } from '../../store/FormStore';
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 { formStore } from '../../store/FormStore';
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 { name, label, options = [], enableAutocomplete = false, placeholder } = node.props || {};
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 { formStore } from '../../store/FormStore';
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 { formStore } from '../../store/FormStore';
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 { formStore } from '../../store/FormStore';
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
- registerComponent('text', TextField);
18
- registerComponent('number', NumberField);
19
- registerComponent('checkbox', CheckboxField);
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
+ }
@@ -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
- export type NodeType =
6
- | 'root'
7
- | 'text'
8
- | 'number'
9
- | 'checkbox'
10
- | 'select'
11
- | 'row'
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
- const value = this.getValue(fieldName);
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 containerTypes: NodeType[] = ['root', 'row', 'col', 'tabs', 'tab', 'paper', 'repeater', 'table', 'cell'];
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 };