@stormlmd/form-builder 0.1.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build_output.txt +0 -0
- package/package.json +1 -1
- package/src/components/FieldRegistry.ts +16 -18
- package/src/components/FormRenderer.tsx +4 -2
- package/src/components/builder/EditorWrapper.tsx +3 -1
- package/src/components/builder/FormBuilder.tsx +20 -2
- package/src/components/builder/FormChildrenRenderer.tsx +3 -1
- package/src/components/builder/FormulaHelp.tsx +116 -0
- package/src/components/builder/IntegrationSettings.tsx +3 -1
- package/src/components/builder/OptionsEditor.tsx +90 -0
- package/src/components/builder/PropertiesModal.tsx +2 -1
- package/src/components/builder/PropertiesPanel.tsx +155 -62
- package/src/components/builder/SortableNode.tsx +2 -1
- package/src/components/builder/Toolbox.tsx +30 -63
- package/src/components/fields/CheckboxField.tsx +2 -1
- package/src/components/fields/DateField.tsx +2 -1
- package/src/components/fields/FileUploadField.tsx +2 -1
- package/src/components/fields/LabelField.tsx +2 -1
- package/src/components/fields/NumberField.tsx +2 -1
- package/src/components/fields/RichTextField.tsx +2 -1
- package/src/components/fields/SelectField.tsx +8 -2
- package/src/components/fields/TextField.tsx +2 -1
- package/src/components/layout/FormRepeater.tsx +2 -1
- package/src/components/layout/FormTabs.tsx +2 -1
- package/src/components/registerComponents.ts +69 -14
- package/src/index.ts +7 -0
- package/src/plugins/FieldPlugin.ts +63 -0
- package/src/plugins/PluginRegistry.ts +94 -0
- package/src/store/FormStore.ts +72 -24
- package/src/store/FormStoreContext.tsx +66 -0
|
@@ -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,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { registerPlugin } from '../plugins/PluginRegistry';
|
|
2
|
+
import type { FieldPlugin } from '../plugins/FieldPlugin';
|
|
3
|
+
|
|
4
|
+
// Field components
|
|
2
5
|
import { TextField } from './fields/TextField';
|
|
3
6
|
import { NumberField } from './fields/NumberField';
|
|
4
7
|
import { CheckboxField } from './fields/CheckboxField';
|
|
@@ -6,25 +9,77 @@ import { SelectField } from './fields/SelectField';
|
|
|
6
9
|
import { DateField } from './fields/DateField';
|
|
7
10
|
import { FileUploadField } from './fields/FileUploadField';
|
|
8
11
|
import { RichTextField } from './fields/RichTextField';
|
|
12
|
+
import { LabelField } from './fields/LabelField';
|
|
13
|
+
|
|
14
|
+
// Layout components
|
|
9
15
|
import { FormRow } from './layout/FormRow';
|
|
10
16
|
import { FormCol } from './layout/FormCol';
|
|
11
17
|
import { FormTabs } from './layout/FormTabs';
|
|
12
18
|
import { FormTab } from './layout/FormTab';
|
|
13
19
|
import { FormRepeater } from './layout/FormRepeater';
|
|
14
20
|
import { FormPaper } from './layout/FormPaper';
|
|
21
|
+
import { FormTable } from './layout/FormTable';
|
|
22
|
+
import { FormTableCell } from './layout/FormTableCell';
|
|
23
|
+
import { FormDivider } from './layout/FormDivider';
|
|
24
|
+
|
|
25
|
+
// Icons
|
|
26
|
+
import TextFieldsIcon from '@mui/icons-material/TextFields';
|
|
27
|
+
import NumbersIcon from '@mui/icons-material/Numbers';
|
|
28
|
+
import CheckBoxIcon from '@mui/icons-material/CheckBox';
|
|
29
|
+
import ArrowDropDownCircleIcon from '@mui/icons-material/ArrowDropDownCircle';
|
|
30
|
+
import CalendarTodayIcon from '@mui/icons-material/CalendarToday';
|
|
31
|
+
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
|
|
32
|
+
import FormatQuoteIcon from '@mui/icons-material/FormatQuote';
|
|
33
|
+
import LabelIcon from '@mui/icons-material/Label';
|
|
34
|
+
import HorizontalRuleIcon from '@mui/icons-material/HorizontalRule';
|
|
35
|
+
import ViewColumnIcon from '@mui/icons-material/ViewColumn';
|
|
36
|
+
import TabIcon from '@mui/icons-material/Tab';
|
|
37
|
+
import RepeatIcon from '@mui/icons-material/Repeat';
|
|
38
|
+
import TableChartIcon from '@mui/icons-material/TableChart';
|
|
39
|
+
import LayersIcon from '@mui/icons-material/Layers';
|
|
40
|
+
import React from 'react';
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Built-in field plugins.
|
|
44
|
+
*/
|
|
45
|
+
const BUILT_IN_FIELD_PLUGINS: FieldPlugin[] = [
|
|
46
|
+
{ type: 'text', label: 'Text Field', icon: React.createElement(TextFieldsIcon), category: 'field', defaultProps: { label: 'New Text Field', width: 6 }, component: TextField },
|
|
47
|
+
{ type: 'number', label: 'Number Field', icon: React.createElement(NumbersIcon), category: 'field', defaultProps: { label: 'New Number Field', width: 6 }, component: NumberField },
|
|
48
|
+
{ type: 'checkbox', label: 'Checkbox', icon: React.createElement(CheckBoxIcon), category: 'field', defaultProps: { label: 'New Checkbox', width: 6 }, component: CheckboxField },
|
|
49
|
+
{ type: 'select', label: 'Select', icon: React.createElement(ArrowDropDownCircleIcon), category: 'field', defaultProps: { label: 'New Select', width: 6, options: [{ label: 'Option 1', value: '1' }] }, component: SelectField },
|
|
50
|
+
{ type: 'date', label: 'Date Picker', icon: React.createElement(CalendarTodayIcon), category: 'field', defaultProps: { label: 'New Date', width: 6 }, component: DateField },
|
|
51
|
+
{ type: 'file', label: 'File Upload', icon: React.createElement(CloudUploadIcon), category: 'field', defaultProps: { label: 'New File Upload', width: 6 }, component: FileUploadField },
|
|
52
|
+
{ type: 'richtext', label: 'Rich Text', icon: React.createElement(FormatQuoteIcon), category: 'field', defaultProps: { label: 'New Rich Text', width: 6 }, component: RichTextField },
|
|
53
|
+
{ type: 'label', label: 'Label', icon: React.createElement(LabelIcon), category: 'field', defaultProps: { text: 'Static Text', variant: 'body1', width: 6 }, component: LabelField },
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Built-in layout plugins.
|
|
58
|
+
*/
|
|
59
|
+
const BUILT_IN_LAYOUT_PLUGINS: FieldPlugin[] = [
|
|
60
|
+
{ type: 'divider', label: 'Divider', icon: React.createElement(HorizontalRuleIcon), category: 'layout', defaultProps: { text: '' }, component: FormDivider },
|
|
61
|
+
{ type: 'col', label: 'Column', icon: React.createElement(ViewColumnIcon), category: 'layout', isContainer: true, defaultProps: { cols: 6 }, component: FormCol },
|
|
62
|
+
{ type: 'tabs', label: 'Tabs', icon: React.createElement(TabIcon), category: 'layout', isContainer: true, defaultProps: {}, component: FormTabs },
|
|
63
|
+
{ type: 'repeater', label: 'Repeater', icon: React.createElement(RepeatIcon), category: 'layout', isContainer: true, defaultProps: { label: 'Repeater', addLabel: 'addItem' }, component: FormRepeater },
|
|
64
|
+
{ type: 'table', label: 'Table', icon: React.createElement(TableChartIcon), category: 'layout', isContainer: true, defaultProps: { rows: 2, cols: 2, width: 12 }, component: FormTable },
|
|
65
|
+
{ type: 'paper', label: 'Paper', icon: React.createElement(LayersIcon), category: 'layout', isContainer: true, defaultProps: { label: 'Paper Group', padding: 2, variant: 'elevation', elevation: 1 }, component: FormPaper },
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Internal-only plugins (not shown in Toolbox but needed for rendering).
|
|
70
|
+
*/
|
|
71
|
+
const INTERNAL_PLUGINS: FieldPlugin[] = [
|
|
72
|
+
{ type: 'tab', label: 'Tab', category: 'layout', isContainer: true, showInToolbox: false, component: FormTab },
|
|
73
|
+
{ type: 'cell', label: 'Cell', category: 'layout', isContainer: true, showInToolbox: false, component: FormTableCell },
|
|
74
|
+
{ type: 'row', label: 'Row', category: 'layout', isContainer: true, showInToolbox: false, defaultProps: {}, component: FormRow },
|
|
75
|
+
];
|
|
15
76
|
|
|
77
|
+
/**
|
|
78
|
+
* Registers all built-in components as full plugins.
|
|
79
|
+
* Call this once at app startup. Backward compatible.
|
|
80
|
+
*/
|
|
16
81
|
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);
|
|
82
|
+
for (const plugin of [...BUILT_IN_FIELD_PLUGINS, ...BUILT_IN_LAYOUT_PLUGINS, ...INTERNAL_PLUGINS]) {
|
|
83
|
+
registerPlugin(plugin);
|
|
84
|
+
}
|
|
30
85
|
};
|
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,16 @@
|
|
|
1
1
|
import { makeAutoObservable, toJS } from "mobx";
|
|
2
2
|
import i18next from 'i18next';
|
|
3
|
+
import { addDays, differenceInDays, parse, format, parseISO, isValid } from 'date-fns';
|
|
3
4
|
import { transformAPIDataToSchema, transformSchemaToSubmission, transformDictionaryToOptions } from '../utils/apiTransformer';
|
|
5
|
+
import { getPlugin, isContainerType } from '../plugins/PluginRegistry';
|
|
4
6
|
|
|
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';
|
|
7
|
+
/**
|
|
8
|
+
* Node type identifier. Open string type to support plugin-defined types.
|
|
9
|
+
* Built-in types: 'root', 'text', 'number', 'checkbox', 'select', 'row',
|
|
10
|
+
* 'col', 'tabs', 'tab', 'table', 'cell', 'paper', 'repeater', 'date',
|
|
11
|
+
* 'file', 'richtext', 'label', 'divider'.
|
|
12
|
+
*/
|
|
13
|
+
export type NodeType = string;
|
|
24
14
|
|
|
25
15
|
export interface FieldOption {
|
|
26
16
|
label: string;
|
|
@@ -101,6 +91,9 @@ export class FormStore {
|
|
|
101
91
|
// Session data (user info, report IDs, etc.)
|
|
102
92
|
sessionVariables: Record<string, any> = {};
|
|
103
93
|
|
|
94
|
+
// External constants for formulas and lists
|
|
95
|
+
externalConstants: Record<string, any> = {};
|
|
96
|
+
|
|
104
97
|
// Pool of available API columns for mapping
|
|
105
98
|
availableApiColumns: any[] = [];
|
|
106
99
|
|
|
@@ -113,6 +106,10 @@ export class FormStore {
|
|
|
113
106
|
this.isEditMode = mode;
|
|
114
107
|
}
|
|
115
108
|
|
|
109
|
+
setConstant(name: string, value: any) {
|
|
110
|
+
this.externalConstants[name] = value;
|
|
111
|
+
}
|
|
112
|
+
|
|
116
113
|
// --- Actions ---
|
|
117
114
|
|
|
118
115
|
selectNode(id: string | null) {
|
|
@@ -144,6 +141,7 @@ export class FormStore {
|
|
|
144
141
|
const response = await fetch(`${this.apiBase}/schema/${formId}`);
|
|
145
142
|
if (!response.ok) throw new Error("Failed to fetch schema");
|
|
146
143
|
const schema = await response.json();
|
|
144
|
+
if (schema && !schema.id) schema.id = 'root'; // Enforce root ID
|
|
147
145
|
this.rootNode = schema;
|
|
148
146
|
this.applyDefaultValues();
|
|
149
147
|
} catch (error) {
|
|
@@ -308,7 +306,7 @@ export class FormStore {
|
|
|
308
306
|
|
|
309
307
|
// --- Validation ---
|
|
310
308
|
|
|
311
|
-
validateField(path: string, value: any, rules?: ValidationRules) {
|
|
309
|
+
validateField(path: string, value: any, rules?: ValidationRules, nodeType?: string) {
|
|
312
310
|
if (!rules) return;
|
|
313
311
|
|
|
314
312
|
let error: string | null = null;
|
|
@@ -369,6 +367,14 @@ export class FormStore {
|
|
|
369
367
|
}
|
|
370
368
|
}
|
|
371
369
|
|
|
370
|
+
// Plugin custom validation
|
|
371
|
+
if (!error && nodeType) {
|
|
372
|
+
const plugin = getPlugin(nodeType);
|
|
373
|
+
if (plugin?.validate) {
|
|
374
|
+
error = plugin.validate(value, rules);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
372
378
|
if (error) {
|
|
373
379
|
this.errors[path] = error;
|
|
374
380
|
} else {
|
|
@@ -420,7 +426,13 @@ export class FormStore {
|
|
|
420
426
|
return JSON.stringify(new Date().toISOString());
|
|
421
427
|
}
|
|
422
428
|
|
|
423
|
-
|
|
429
|
+
let value = this.getValue(fieldName);
|
|
430
|
+
|
|
431
|
+
// Fallback to external constants if not found in form values
|
|
432
|
+
if (value === undefined && this.externalConstants[fieldName] !== undefined) {
|
|
433
|
+
value = this.externalConstants[fieldName];
|
|
434
|
+
}
|
|
435
|
+
|
|
424
436
|
// If it's a number, return it raw. If it's a string, JSON.stringify it.
|
|
425
437
|
// If undefined/null, return '0' for safety in math.
|
|
426
438
|
if (value === undefined || value === null) return '0';
|
|
@@ -438,6 +450,40 @@ export class FormStore {
|
|
|
438
450
|
ROUND: (v: any, p: number = 0) => {
|
|
439
451
|
const factor = Math.pow(10, p);
|
|
440
452
|
return Math.round(v * factor) / factor;
|
|
453
|
+
},
|
|
454
|
+
DATE_ADD: (date: any, days: any) => {
|
|
455
|
+
if (!date) return '';
|
|
456
|
+
const daysNum = Number(days) || 0;
|
|
457
|
+
let d = parseISO(date);
|
|
458
|
+
let isISO = true;
|
|
459
|
+
if (!isValid(d)) {
|
|
460
|
+
d = parse(date, 'dd-MM-yyyy', new Date());
|
|
461
|
+
isISO = false;
|
|
462
|
+
}
|
|
463
|
+
if (!isValid(d)) return date;
|
|
464
|
+
const result = addDays(d, daysNum);
|
|
465
|
+
return isISO ? result.toISOString() : format(result, 'dd-MM-yyyy');
|
|
466
|
+
},
|
|
467
|
+
DATE_DIFF: (date1: any, date2: any) => {
|
|
468
|
+
if (!date1 || !date2) return 0;
|
|
469
|
+
let d1 = parseISO(date1);
|
|
470
|
+
if (!isValid(d1)) d1 = parse(date1, 'dd-MM-yyyy', new Date());
|
|
471
|
+
let d2 = parseISO(date2);
|
|
472
|
+
if (!isValid(d2)) d2 = parse(date2, 'dd-MM-yyyy', new Date());
|
|
473
|
+
if (!isValid(d1) || !isValid(d2)) return 0;
|
|
474
|
+
return differenceInDays(d1, d2);
|
|
475
|
+
},
|
|
476
|
+
SWITCH: (val: any, ...args: any[]) => {
|
|
477
|
+
for (let i = 0; i < args.length - 1; i += 2) {
|
|
478
|
+
if (val === args[i]) return args[i + 1];
|
|
479
|
+
}
|
|
480
|
+
return args.length % 2 === 1 ? args[args.length - 1] : undefined;
|
|
481
|
+
},
|
|
482
|
+
IFS: (...args: any[]) => {
|
|
483
|
+
for (let i = 0; i < args.length - 1; i += 2) {
|
|
484
|
+
if (args[i]) return args[i + 1];
|
|
485
|
+
}
|
|
486
|
+
return args.length % 2 === 1 ? args[args.length - 1] : undefined;
|
|
441
487
|
}
|
|
442
488
|
};
|
|
443
489
|
|
|
@@ -648,8 +694,7 @@ export class FormStore {
|
|
|
648
694
|
const overNode = this.findNode(this.rootNode, overId);
|
|
649
695
|
|
|
650
696
|
// If we drop on a container, the parent is the container itself
|
|
651
|
-
const
|
|
652
|
-
const isOverContainer = overNode && containerTypes.includes(overNode.type);
|
|
697
|
+
const isOverContainer = overNode && isContainerType(overNode.type);
|
|
653
698
|
const overParent = isOverContainer ? overNode : this.findParent(this.rootNode, overId);
|
|
654
699
|
|
|
655
700
|
if (!activeNode || !activeParent || !activeParent.children) {
|
|
@@ -730,7 +775,9 @@ export class FormStore {
|
|
|
730
775
|
try {
|
|
731
776
|
const stored = localStorage.getItem('form_schema');
|
|
732
777
|
if (stored) {
|
|
733
|
-
|
|
778
|
+
const schema = JSON.parse(stored);
|
|
779
|
+
if (schema && !schema.id) schema.id = 'root'; // Enforce root ID
|
|
780
|
+
this.rootNode = schema;
|
|
734
781
|
this.applyDefaultValues();
|
|
735
782
|
console.log('Schema loaded from localStorage');
|
|
736
783
|
}
|
|
@@ -771,6 +818,7 @@ export class FormStore {
|
|
|
771
818
|
}
|
|
772
819
|
|
|
773
820
|
findNode(root: SchemaNode, id: string): SchemaNode | null {
|
|
821
|
+
if (id === 'root' && (root === this.rootNode || root.id === 'root')) return root;
|
|
774
822
|
if (root.id === id) return root;
|
|
775
823
|
if (root.children) {
|
|
776
824
|
for (const child of root.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 };
|