@object-ui/plugin-form 0.5.0 → 3.0.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/.turbo/turbo-build.log +17 -6
- package/CHANGELOG.md +36 -0
- package/dist/index.js +1388 -283
- package/dist/index.umd.cjs +2 -2
- package/dist/packages/plugin-form/src/DrawerForm.d.ts +61 -0
- package/dist/packages/plugin-form/src/FormSection.d.ts +49 -0
- package/dist/packages/plugin-form/src/FormVariants.test.d.ts +0 -0
- package/dist/packages/plugin-form/src/ModalForm.d.ts +60 -0
- package/dist/packages/plugin-form/src/ObjectForm.stories.d.ts +23 -0
- package/dist/packages/plugin-form/src/SplitForm.d.ts +50 -0
- package/dist/packages/plugin-form/src/TabbedForm.d.ts +123 -0
- package/dist/packages/plugin-form/src/WizardForm.d.ts +112 -0
- package/dist/packages/plugin-form/src/__tests__/NewVariants.test.d.ts +8 -0
- package/dist/packages/plugin-form/src/__tests__/form-validation-submit.test.d.ts +8 -0
- package/dist/packages/plugin-form/src/index.d.ts +12 -0
- package/package.json +9 -9
- package/src/DrawerForm.tsx +385 -0
- package/src/FormSection.tsx +144 -0
- package/src/FormVariants.test.tsx +219 -0
- package/src/ModalForm.tsx +379 -0
- package/src/ObjectForm.msw.test.tsx +29 -2
- package/src/ObjectForm.stories.tsx +85 -0
- package/src/ObjectForm.tsx +206 -8
- package/src/SplitForm.tsx +299 -0
- package/src/TabbedForm.tsx +394 -0
- package/src/WizardForm.tsx +501 -0
- package/src/__tests__/NewVariants.test.tsx +488 -0
- package/src/__tests__/form-validation-submit.test.tsx +286 -0
- package/src/index.tsx +60 -3
- package/vitest.config.ts +12 -0
- package/vitest.setup.ts +1 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
2
|
+
import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react';
|
|
3
|
+
import type { BaseSchema } from '@object-ui/types';
|
|
4
|
+
import { createStorybookDataSource } from '@storybook-config/datasource';
|
|
5
|
+
|
|
6
|
+
const meta = {
|
|
7
|
+
title: 'Plugins/ObjectForm',
|
|
8
|
+
component: SchemaRenderer,
|
|
9
|
+
parameters: {
|
|
10
|
+
layout: 'centered',
|
|
11
|
+
},
|
|
12
|
+
tags: ['autodocs'],
|
|
13
|
+
argTypes: {
|
|
14
|
+
schema: { table: { disable: true } },
|
|
15
|
+
},
|
|
16
|
+
} satisfies Meta<any>;
|
|
17
|
+
|
|
18
|
+
export default meta;
|
|
19
|
+
type Story = StoryObj<typeof meta>;
|
|
20
|
+
|
|
21
|
+
const dataSource = createStorybookDataSource();
|
|
22
|
+
|
|
23
|
+
const renderStory = (args: any) => (
|
|
24
|
+
<SchemaRendererProvider dataSource={dataSource}>
|
|
25
|
+
<SchemaRenderer schema={args as unknown as BaseSchema} />
|
|
26
|
+
</SchemaRendererProvider>
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
export const Default: Story = {
|
|
30
|
+
render: renderStory,
|
|
31
|
+
args: {
|
|
32
|
+
type: 'object-form',
|
|
33
|
+
objectName: 'Contact',
|
|
34
|
+
customFields: [
|
|
35
|
+
{ name: 'firstName', label: 'First Name', type: 'text', required: true },
|
|
36
|
+
{ name: 'lastName', label: 'Last Name', type: 'text', required: true },
|
|
37
|
+
{ name: 'email', label: 'Email', type: 'email', required: true },
|
|
38
|
+
{ name: 'phone', label: 'Phone', type: 'tel' },
|
|
39
|
+
],
|
|
40
|
+
className: 'w-full max-w-2xl',
|
|
41
|
+
} as any,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export const WithSections: Story = {
|
|
45
|
+
render: renderStory,
|
|
46
|
+
args: {
|
|
47
|
+
type: 'object-form',
|
|
48
|
+
objectName: 'Employee',
|
|
49
|
+
sections: [
|
|
50
|
+
{
|
|
51
|
+
title: 'Personal Information',
|
|
52
|
+
fields: [
|
|
53
|
+
{ name: 'firstName', label: 'First Name', type: 'text', required: true },
|
|
54
|
+
{ name: 'lastName', label: 'Last Name', type: 'text', required: true },
|
|
55
|
+
{ name: 'dateOfBirth', label: 'Date of Birth', type: 'date' },
|
|
56
|
+
],
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
title: 'Work Details',
|
|
60
|
+
fields: [
|
|
61
|
+
{ name: 'department', label: 'Department', type: 'select', options: ['Engineering', 'Marketing', 'Sales', 'HR'] },
|
|
62
|
+
{ name: 'role', label: 'Role', type: 'text' },
|
|
63
|
+
{ name: 'startDate', label: 'Start Date', type: 'date' },
|
|
64
|
+
],
|
|
65
|
+
},
|
|
66
|
+
],
|
|
67
|
+
className: 'w-full max-w-2xl',
|
|
68
|
+
} as any,
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export const ComplexFields: Story = {
|
|
72
|
+
render: renderStory,
|
|
73
|
+
args: {
|
|
74
|
+
type: 'object-form',
|
|
75
|
+
objectName: 'Product',
|
|
76
|
+
customFields: [
|
|
77
|
+
{ name: 'name', label: 'Product Name', type: 'text', required: true },
|
|
78
|
+
{ name: 'category', label: 'Category', type: 'select', options: ['Electronics', 'Clothing', 'Food', 'Books'], required: true },
|
|
79
|
+
{ name: 'price', label: 'Price', type: 'number', required: true },
|
|
80
|
+
{ name: 'inStock', label: 'In Stock', type: 'checkbox' },
|
|
81
|
+
{ name: 'description', label: 'Description', type: 'textarea', rows: 4 },
|
|
82
|
+
],
|
|
83
|
+
className: 'w-full max-w-2xl',
|
|
84
|
+
} as any,
|
|
85
|
+
};
|
package/src/ObjectForm.tsx
CHANGED
|
@@ -17,6 +17,12 @@ import React, { useEffect, useState, useCallback } from 'react';
|
|
|
17
17
|
import type { ObjectFormSchema, FormField, FormSchema, DataSource } from '@object-ui/types';
|
|
18
18
|
import { SchemaRenderer } from '@object-ui/react';
|
|
19
19
|
import { mapFieldTypeToFormType, buildValidationRules, evaluateCondition, formatFileSize } from '@object-ui/fields';
|
|
20
|
+
import { TabbedForm } from './TabbedForm';
|
|
21
|
+
import { WizardForm } from './WizardForm';
|
|
22
|
+
import { SplitForm } from './SplitForm';
|
|
23
|
+
import { DrawerForm } from './DrawerForm';
|
|
24
|
+
import { ModalForm } from './ModalForm';
|
|
25
|
+
import { FormSection } from './FormSection';
|
|
20
26
|
|
|
21
27
|
export interface ObjectFormProps {
|
|
22
28
|
/**
|
|
@@ -59,6 +65,143 @@ export const ObjectForm: React.FC<ObjectFormProps> = ({
|
|
|
59
65
|
dataSource,
|
|
60
66
|
}) => {
|
|
61
67
|
|
|
68
|
+
// Route to specialized form variant based on formType
|
|
69
|
+
if (schema.formType === 'tabbed' && schema.sections?.length) {
|
|
70
|
+
return (
|
|
71
|
+
<TabbedForm
|
|
72
|
+
schema={{
|
|
73
|
+
...schema,
|
|
74
|
+
formType: 'tabbed',
|
|
75
|
+
sections: schema.sections.map(s => ({
|
|
76
|
+
name: s.name,
|
|
77
|
+
label: s.label,
|
|
78
|
+
description: s.description,
|
|
79
|
+
columns: s.columns,
|
|
80
|
+
fields: s.fields,
|
|
81
|
+
})),
|
|
82
|
+
defaultTab: schema.defaultTab,
|
|
83
|
+
tabPosition: schema.tabPosition,
|
|
84
|
+
}}
|
|
85
|
+
dataSource={dataSource}
|
|
86
|
+
className={schema.className}
|
|
87
|
+
/>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (schema.formType === 'wizard' && schema.sections?.length) {
|
|
92
|
+
return (
|
|
93
|
+
<WizardForm
|
|
94
|
+
schema={{
|
|
95
|
+
...schema,
|
|
96
|
+
formType: 'wizard',
|
|
97
|
+
sections: schema.sections.map(s => ({
|
|
98
|
+
name: s.name,
|
|
99
|
+
label: s.label,
|
|
100
|
+
description: s.description,
|
|
101
|
+
columns: s.columns,
|
|
102
|
+
fields: s.fields,
|
|
103
|
+
})),
|
|
104
|
+
allowSkip: schema.allowSkip,
|
|
105
|
+
showStepIndicator: schema.showStepIndicator,
|
|
106
|
+
nextText: schema.nextText,
|
|
107
|
+
prevText: schema.prevText,
|
|
108
|
+
onStepChange: schema.onStepChange,
|
|
109
|
+
}}
|
|
110
|
+
dataSource={dataSource}
|
|
111
|
+
className={schema.className}
|
|
112
|
+
/>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (schema.formType === 'split' && schema.sections?.length) {
|
|
117
|
+
return (
|
|
118
|
+
<SplitForm
|
|
119
|
+
schema={{
|
|
120
|
+
...schema,
|
|
121
|
+
formType: 'split',
|
|
122
|
+
sections: schema.sections.map(s => ({
|
|
123
|
+
name: s.name,
|
|
124
|
+
label: s.label,
|
|
125
|
+
description: s.description,
|
|
126
|
+
columns: s.columns,
|
|
127
|
+
fields: s.fields,
|
|
128
|
+
})),
|
|
129
|
+
splitDirection: schema.splitDirection,
|
|
130
|
+
splitSize: schema.splitSize,
|
|
131
|
+
splitResizable: schema.splitResizable,
|
|
132
|
+
}}
|
|
133
|
+
dataSource={dataSource}
|
|
134
|
+
className={schema.className}
|
|
135
|
+
/>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (schema.formType === 'drawer') {
|
|
140
|
+
const { layout: _layout, ...drawerRest } = schema;
|
|
141
|
+
const drawerLayout = (schema.layout === 'vertical' || schema.layout === 'horizontal') ? schema.layout : undefined;
|
|
142
|
+
return (
|
|
143
|
+
<DrawerForm
|
|
144
|
+
schema={{
|
|
145
|
+
...drawerRest,
|
|
146
|
+
layout: drawerLayout,
|
|
147
|
+
formType: 'drawer',
|
|
148
|
+
sections: schema.sections?.map(s => ({
|
|
149
|
+
name: s.name,
|
|
150
|
+
label: s.label,
|
|
151
|
+
description: s.description,
|
|
152
|
+
columns: s.columns,
|
|
153
|
+
fields: s.fields,
|
|
154
|
+
})),
|
|
155
|
+
open: schema.open,
|
|
156
|
+
onOpenChange: schema.onOpenChange,
|
|
157
|
+
drawerSide: schema.drawerSide,
|
|
158
|
+
drawerWidth: schema.drawerWidth,
|
|
159
|
+
}}
|
|
160
|
+
dataSource={dataSource}
|
|
161
|
+
className={schema.className}
|
|
162
|
+
/>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (schema.formType === 'modal') {
|
|
167
|
+
const { layout: _layout2, ...modalRest } = schema;
|
|
168
|
+
const modalLayout = (schema.layout === 'vertical' || schema.layout === 'horizontal') ? schema.layout : undefined;
|
|
169
|
+
return (
|
|
170
|
+
<ModalForm
|
|
171
|
+
schema={{
|
|
172
|
+
...modalRest,
|
|
173
|
+
layout: modalLayout,
|
|
174
|
+
formType: 'modal',
|
|
175
|
+
sections: schema.sections?.map(s => ({
|
|
176
|
+
name: s.name,
|
|
177
|
+
label: s.label,
|
|
178
|
+
description: s.description,
|
|
179
|
+
columns: s.columns,
|
|
180
|
+
fields: s.fields,
|
|
181
|
+
})),
|
|
182
|
+
open: schema.open,
|
|
183
|
+
onOpenChange: schema.onOpenChange,
|
|
184
|
+
modalSize: schema.modalSize,
|
|
185
|
+
modalCloseButton: schema.modalCloseButton,
|
|
186
|
+
}}
|
|
187
|
+
dataSource={dataSource}
|
|
188
|
+
className={schema.className}
|
|
189
|
+
/>
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Default: simple form
|
|
194
|
+
return <SimpleObjectForm schema={schema} dataSource={dataSource} />;
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* SimpleObjectForm — default form variant with auto-generated fields from ObjectQL schema.
|
|
199
|
+
*/
|
|
200
|
+
const SimpleObjectForm: React.FC<ObjectFormProps> = ({
|
|
201
|
+
schema,
|
|
202
|
+
dataSource,
|
|
203
|
+
}) => {
|
|
204
|
+
|
|
62
205
|
const [objectSchema, setObjectSchema] = useState<any>(null);
|
|
63
206
|
const [formFields, setFormFields] = useState<FormField[]>([]);
|
|
64
207
|
const [initialData, setInitialData] = useState<any>(null);
|
|
@@ -84,10 +227,13 @@ export const ObjectForm: React.FC<ObjectFormProps> = ({
|
|
|
84
227
|
throw new Error('DataSource is required when using ObjectQL schema fetching (inline fields not provided)');
|
|
85
228
|
}
|
|
86
229
|
const schemaData = await dataSource.getObjectSchema(schema.objectName);
|
|
230
|
+
if (!schemaData) {
|
|
231
|
+
throw new Error(`No schema found for object "${schema.objectName}"`);
|
|
232
|
+
}
|
|
87
233
|
setObjectSchema(schemaData);
|
|
88
234
|
} catch (err) {
|
|
89
|
-
console.error('Failed to fetch object schema:', err);
|
|
90
235
|
setError(err as Error);
|
|
236
|
+
setLoading(false);
|
|
91
237
|
}
|
|
92
238
|
};
|
|
93
239
|
|
|
@@ -100,6 +246,9 @@ export const ObjectForm: React.FC<ObjectFormProps> = ({
|
|
|
100
246
|
});
|
|
101
247
|
} else if (schema.objectName && dataSource) {
|
|
102
248
|
fetchObjectSchema();
|
|
249
|
+
} else if (!hasInlineFields) {
|
|
250
|
+
// No objectName or dataSource and no inline fields — cannot proceed
|
|
251
|
+
setLoading(false);
|
|
103
252
|
}
|
|
104
253
|
}, [schema.objectName, dataSource, hasInlineFields]);
|
|
105
254
|
|
|
@@ -270,8 +419,14 @@ export const ObjectForm: React.FC<ObjectFormProps> = ({
|
|
|
270
419
|
});
|
|
271
420
|
|
|
272
421
|
setFormFields(generatedFields);
|
|
273
|
-
|
|
274
|
-
|
|
422
|
+
|
|
423
|
+
// Only set loading to false if we are not going to fetch data
|
|
424
|
+
// This prevents a flash of empty form before data is loaded in edit mode
|
|
425
|
+
const willFetchData = !hasInlineFields && (schema.recordId && schema.mode !== 'create' && dataSource);
|
|
426
|
+
if (!willFetchData) {
|
|
427
|
+
setLoading(false);
|
|
428
|
+
}
|
|
429
|
+
}, [objectSchema, schema.fields, schema.customFields, schema.readOnly, schema.mode, hasInlineFields, schema.recordId, dataSource]);
|
|
275
430
|
|
|
276
431
|
// Handle form submission
|
|
277
432
|
const handleSubmit = useCallback(async (formData: any, e?: any) => {
|
|
@@ -356,7 +511,7 @@ export const ObjectForm: React.FC<ObjectFormProps> = ({
|
|
|
356
511
|
// Render error state
|
|
357
512
|
if (error) {
|
|
358
513
|
return (
|
|
359
|
-
<div className="p-4 border border-red-300 bg-red-50 rounded-md">
|
|
514
|
+
<div className="p-3 sm:p-4 border border-red-300 bg-red-50 rounded-md">
|
|
360
515
|
<h3 className="text-red-800 font-semibold">Error loading form</h3>
|
|
361
516
|
<p className="text-red-600 text-sm mt-1">{error.message}</p>
|
|
362
517
|
</div>
|
|
@@ -366,7 +521,7 @@ export const ObjectForm: React.FC<ObjectFormProps> = ({
|
|
|
366
521
|
// Render loading state
|
|
367
522
|
if (loading) {
|
|
368
523
|
return (
|
|
369
|
-
<div className="p-8 text-center">
|
|
524
|
+
<div className="p-4 sm:p-8 text-center">
|
|
370
525
|
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900"></div>
|
|
371
526
|
<p className="mt-2 text-sm text-gray-600">Loading form...</p>
|
|
372
527
|
</div>
|
|
@@ -376,12 +531,55 @@ export const ObjectForm: React.FC<ObjectFormProps> = ({
|
|
|
376
531
|
// Convert to FormSchema
|
|
377
532
|
// Note: FormSchema currently only supports 'vertical' and 'horizontal' layouts
|
|
378
533
|
// Map 'grid' and 'inline' to 'vertical' as fallback
|
|
534
|
+
const formLayout = (schema.layout === 'vertical' || schema.layout === 'horizontal')
|
|
535
|
+
? schema.layout
|
|
536
|
+
: 'vertical';
|
|
537
|
+
|
|
538
|
+
// If sections are provided for the simple form, render with FormSection grouping
|
|
539
|
+
if (schema.sections?.length && (!schema.formType || schema.formType === 'simple')) {
|
|
540
|
+
return (
|
|
541
|
+
<div className="w-full space-y-6">
|
|
542
|
+
{schema.sections.map((section, index) => {
|
|
543
|
+
// Filter formFields to only include fields in this section
|
|
544
|
+
const sectionFieldNames = section.fields.map(f => typeof f === 'string' ? f : f.name);
|
|
545
|
+
const sectionFields = formFields.filter(f => sectionFieldNames.includes(f.name));
|
|
546
|
+
|
|
547
|
+
return (
|
|
548
|
+
<FormSection
|
|
549
|
+
key={section.name || section.label || index}
|
|
550
|
+
label={section.label}
|
|
551
|
+
description={section.description}
|
|
552
|
+
collapsible={section.collapsible}
|
|
553
|
+
collapsed={section.collapsed}
|
|
554
|
+
columns={section.columns}
|
|
555
|
+
>
|
|
556
|
+
<SchemaRenderer
|
|
557
|
+
schema={{
|
|
558
|
+
type: 'form',
|
|
559
|
+
fields: sectionFields,
|
|
560
|
+
layout: formLayout,
|
|
561
|
+
defaultValues: finalDefaultValues,
|
|
562
|
+
// Only show action buttons after the last section
|
|
563
|
+
showSubmit: index === schema.sections!.length - 1 && schema.showSubmit !== false && schema.mode !== 'view',
|
|
564
|
+
showCancel: index === schema.sections!.length - 1 && schema.showCancel !== false,
|
|
565
|
+
submitLabel: schema.submitText || (schema.mode === 'create' ? 'Create' : 'Update'),
|
|
566
|
+
cancelLabel: schema.cancelText,
|
|
567
|
+
onSubmit: handleSubmit,
|
|
568
|
+
onCancel: handleCancel,
|
|
569
|
+
} as FormSchema}
|
|
570
|
+
/>
|
|
571
|
+
</FormSection>
|
|
572
|
+
);
|
|
573
|
+
})}
|
|
574
|
+
</div>
|
|
575
|
+
);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// Default flat form (no sections)
|
|
379
579
|
const formSchema: FormSchema = {
|
|
380
580
|
type: 'form',
|
|
381
581
|
fields: formFields,
|
|
382
|
-
layout:
|
|
383
|
-
? schema.layout
|
|
384
|
-
: 'vertical',
|
|
582
|
+
layout: formLayout,
|
|
385
583
|
columns: schema.columns,
|
|
386
584
|
submitLabel: schema.submitText || (schema.mode === 'create' ? 'Create' : 'Update'),
|
|
387
585
|
cancelLabel: schema.cancelText,
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI
|
|
3
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
+
*
|
|
5
|
+
* This source code is licensed under the MIT license found in the
|
|
6
|
+
* LICENSE file in the root directory of this source tree.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* SplitForm Component
|
|
11
|
+
*
|
|
12
|
+
* A form variant that displays sections in a resizable split-panel layout.
|
|
13
|
+
* The first section renders in the left/top panel, remaining sections in the right/bottom panel.
|
|
14
|
+
* Aligns with @objectstack/spec FormView type: 'split'
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import React, { useState, useCallback, useEffect, useMemo } from 'react';
|
|
18
|
+
import type { FormField, DataSource } from '@object-ui/types';
|
|
19
|
+
import {
|
|
20
|
+
ResizablePanelGroup,
|
|
21
|
+
ResizablePanel,
|
|
22
|
+
ResizableHandle,
|
|
23
|
+
cn,
|
|
24
|
+
} from '@object-ui/components';
|
|
25
|
+
import { FormSection } from './FormSection';
|
|
26
|
+
import { SchemaRenderer } from '@object-ui/react';
|
|
27
|
+
import { mapFieldTypeToFormType, buildValidationRules } from '@object-ui/fields';
|
|
28
|
+
|
|
29
|
+
export interface SplitFormSectionConfig {
|
|
30
|
+
name?: string;
|
|
31
|
+
label?: string;
|
|
32
|
+
description?: string;
|
|
33
|
+
columns?: 1 | 2 | 3 | 4;
|
|
34
|
+
fields: (string | FormField)[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface SplitFormSchema {
|
|
38
|
+
type: 'object-form';
|
|
39
|
+
formType: 'split';
|
|
40
|
+
objectName: string;
|
|
41
|
+
mode: 'create' | 'edit' | 'view';
|
|
42
|
+
recordId?: string | number;
|
|
43
|
+
sections: SplitFormSectionConfig[];
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Split direction.
|
|
47
|
+
* @default 'horizontal'
|
|
48
|
+
*/
|
|
49
|
+
splitDirection?: 'horizontal' | 'vertical';
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Size of the first panel (percentage 1-99).
|
|
53
|
+
* @default 50
|
|
54
|
+
*/
|
|
55
|
+
splitSize?: number;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Whether panels can be resized.
|
|
59
|
+
* @default true
|
|
60
|
+
*/
|
|
61
|
+
splitResizable?: boolean;
|
|
62
|
+
|
|
63
|
+
// Common form props
|
|
64
|
+
showSubmit?: boolean;
|
|
65
|
+
submitText?: string;
|
|
66
|
+
showCancel?: boolean;
|
|
67
|
+
cancelText?: string;
|
|
68
|
+
initialValues?: Record<string, any>;
|
|
69
|
+
initialData?: Record<string, any>;
|
|
70
|
+
readOnly?: boolean;
|
|
71
|
+
onSuccess?: (data: any) => void | Promise<void>;
|
|
72
|
+
onError?: (error: Error) => void;
|
|
73
|
+
onCancel?: () => void;
|
|
74
|
+
className?: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface SplitFormProps {
|
|
78
|
+
schema: SplitFormSchema;
|
|
79
|
+
dataSource?: DataSource;
|
|
80
|
+
className?: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export const SplitForm: React.FC<SplitFormProps> = ({
|
|
84
|
+
schema,
|
|
85
|
+
dataSource,
|
|
86
|
+
className,
|
|
87
|
+
}) => {
|
|
88
|
+
const [objectSchema, setObjectSchema] = useState<any>(null);
|
|
89
|
+
const [formData, setFormData] = useState<Record<string, any>>({});
|
|
90
|
+
const [loading, setLoading] = useState(true);
|
|
91
|
+
const [error, setError] = useState<Error | null>(null);
|
|
92
|
+
|
|
93
|
+
// Fetch object schema
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
const fetchSchema = async () => {
|
|
96
|
+
if (!dataSource) {
|
|
97
|
+
setLoading(false);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
const data = await dataSource.getObjectSchema(schema.objectName);
|
|
102
|
+
setObjectSchema(data);
|
|
103
|
+
} catch (err) {
|
|
104
|
+
setError(err as Error);
|
|
105
|
+
setLoading(false);
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
fetchSchema();
|
|
109
|
+
}, [schema.objectName, dataSource]);
|
|
110
|
+
|
|
111
|
+
// Fetch initial data
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
const fetchData = async () => {
|
|
114
|
+
if (schema.mode === 'create' || !schema.recordId) {
|
|
115
|
+
setFormData(schema.initialData || schema.initialValues || {});
|
|
116
|
+
setLoading(false);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (!dataSource) {
|
|
121
|
+
setFormData(schema.initialData || schema.initialValues || {});
|
|
122
|
+
setLoading(false);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
const data = await dataSource.findOne(schema.objectName, schema.recordId);
|
|
128
|
+
setFormData(data || {});
|
|
129
|
+
} catch (err) {
|
|
130
|
+
setError(err as Error);
|
|
131
|
+
} finally {
|
|
132
|
+
setLoading(false);
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
if (objectSchema || !dataSource) {
|
|
137
|
+
fetchData();
|
|
138
|
+
}
|
|
139
|
+
}, [objectSchema, schema.mode, schema.recordId, schema.initialData, schema.initialValues, dataSource, schema.objectName]);
|
|
140
|
+
|
|
141
|
+
// Build form fields from section config
|
|
142
|
+
const buildSectionFields = useCallback((section: SplitFormSectionConfig): FormField[] => {
|
|
143
|
+
const fields: FormField[] = [];
|
|
144
|
+
|
|
145
|
+
for (const fieldDef of section.fields) {
|
|
146
|
+
const fieldName = typeof fieldDef === 'string' ? fieldDef : fieldDef.name;
|
|
147
|
+
|
|
148
|
+
if (typeof fieldDef === 'object') {
|
|
149
|
+
fields.push(fieldDef);
|
|
150
|
+
} else if (objectSchema?.fields?.[fieldName]) {
|
|
151
|
+
const field = objectSchema.fields[fieldName];
|
|
152
|
+
fields.push({
|
|
153
|
+
name: fieldName,
|
|
154
|
+
label: field.label || fieldName,
|
|
155
|
+
type: mapFieldTypeToFormType(field.type),
|
|
156
|
+
required: field.required || false,
|
|
157
|
+
disabled: schema.readOnly || schema.mode === 'view' || field.readonly,
|
|
158
|
+
placeholder: field.placeholder,
|
|
159
|
+
description: field.help || field.description,
|
|
160
|
+
validation: buildValidationRules(field),
|
|
161
|
+
field: field,
|
|
162
|
+
options: field.options,
|
|
163
|
+
multiple: field.multiple,
|
|
164
|
+
});
|
|
165
|
+
} else {
|
|
166
|
+
fields.push({
|
|
167
|
+
name: fieldName,
|
|
168
|
+
label: fieldName,
|
|
169
|
+
type: 'input',
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return fields;
|
|
175
|
+
}, [objectSchema, schema.readOnly, schema.mode]);
|
|
176
|
+
|
|
177
|
+
// Handle form submission
|
|
178
|
+
const handleSubmit = useCallback(async (data: Record<string, any>) => {
|
|
179
|
+
if (!dataSource) {
|
|
180
|
+
if (schema.onSuccess) {
|
|
181
|
+
await schema.onSuccess(data);
|
|
182
|
+
}
|
|
183
|
+
return data;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
try {
|
|
187
|
+
let result;
|
|
188
|
+
if (schema.mode === 'create') {
|
|
189
|
+
result = await dataSource.create(schema.objectName, data);
|
|
190
|
+
} else if (schema.mode === 'edit' && schema.recordId) {
|
|
191
|
+
result = await dataSource.update(schema.objectName, schema.recordId, data);
|
|
192
|
+
}
|
|
193
|
+
if (schema.onSuccess) {
|
|
194
|
+
await schema.onSuccess(result);
|
|
195
|
+
}
|
|
196
|
+
return result;
|
|
197
|
+
} catch (err) {
|
|
198
|
+
if (schema.onError) {
|
|
199
|
+
schema.onError(err as Error);
|
|
200
|
+
}
|
|
201
|
+
throw err;
|
|
202
|
+
}
|
|
203
|
+
}, [schema, dataSource]);
|
|
204
|
+
|
|
205
|
+
// Handle cancel
|
|
206
|
+
const handleCancel = useCallback(() => {
|
|
207
|
+
if (schema.onCancel) {
|
|
208
|
+
schema.onCancel();
|
|
209
|
+
}
|
|
210
|
+
}, [schema]);
|
|
211
|
+
|
|
212
|
+
// Split sections: first section in panel 1, rest in panel 2
|
|
213
|
+
const leftSections = useMemo(() => schema.sections.slice(0, 1), [schema.sections]);
|
|
214
|
+
const rightSections = useMemo(() => schema.sections.slice(1), [schema.sections]);
|
|
215
|
+
|
|
216
|
+
// Collect all fields for a unified form submission
|
|
217
|
+
const allFields: FormField[] = useMemo(
|
|
218
|
+
() => schema.sections.flatMap(section => buildSectionFields(section)),
|
|
219
|
+
[schema.sections, buildSectionFields]
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
const direction = schema.splitDirection || 'horizontal';
|
|
223
|
+
const panelSize = schema.splitSize || 50;
|
|
224
|
+
|
|
225
|
+
if (error) {
|
|
226
|
+
return (
|
|
227
|
+
<div className="p-4 border border-red-300 bg-red-50 rounded-md">
|
|
228
|
+
<h3 className="text-red-800 font-semibold">Error loading form</h3>
|
|
229
|
+
<p className="text-red-600 text-sm mt-1">{error.message}</p>
|
|
230
|
+
</div>
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (loading) {
|
|
235
|
+
return (
|
|
236
|
+
<div className="p-8 text-center">
|
|
237
|
+
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900"></div>
|
|
238
|
+
<p className="mt-2 text-sm text-gray-600">Loading form...</p>
|
|
239
|
+
</div>
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Build base form schema for SchemaRenderer
|
|
244
|
+
const baseFormSchema = {
|
|
245
|
+
type: 'form' as const,
|
|
246
|
+
layout: 'vertical' as const,
|
|
247
|
+
defaultValues: formData,
|
|
248
|
+
onSubmit: handleSubmit,
|
|
249
|
+
onCancel: handleCancel,
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
const renderSections = (sections: SplitFormSectionConfig[], showButtons: boolean) => (
|
|
253
|
+
<div className="space-y-4 p-4">
|
|
254
|
+
{sections.map((section, index) => (
|
|
255
|
+
<FormSection
|
|
256
|
+
key={section.name || section.label || index}
|
|
257
|
+
label={section.label}
|
|
258
|
+
description={section.description}
|
|
259
|
+
columns={section.columns || 1}
|
|
260
|
+
>
|
|
261
|
+
<SchemaRenderer
|
|
262
|
+
schema={{
|
|
263
|
+
...baseFormSchema,
|
|
264
|
+
fields: buildSectionFields(section),
|
|
265
|
+
showSubmit: showButtons && schema.showSubmit !== false && schema.mode !== 'view',
|
|
266
|
+
showCancel: showButtons && schema.showCancel !== false,
|
|
267
|
+
submitLabel: schema.submitText || (schema.mode === 'create' ? 'Create' : 'Update'),
|
|
268
|
+
cancelLabel: schema.cancelText,
|
|
269
|
+
}}
|
|
270
|
+
/>
|
|
271
|
+
</FormSection>
|
|
272
|
+
))}
|
|
273
|
+
</div>
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
return (
|
|
277
|
+
<div className={cn('w-full', className, schema.className)}>
|
|
278
|
+
<ResizablePanelGroup orientation={direction as 'horizontal' | 'vertical'} className="min-h-[300px] rounded-lg border">
|
|
279
|
+
{/* Left / Top Panel */}
|
|
280
|
+
<ResizablePanel defaultSize={panelSize} minSize={20}>
|
|
281
|
+
{renderSections(leftSections, rightSections.length === 0)}
|
|
282
|
+
</ResizablePanel>
|
|
283
|
+
|
|
284
|
+
{rightSections.length > 0 && (
|
|
285
|
+
<>
|
|
286
|
+
<ResizableHandle withHandle={schema.splitResizable !== false} />
|
|
287
|
+
|
|
288
|
+
{/* Right / Bottom Panel */}
|
|
289
|
+
<ResizablePanel defaultSize={100 - panelSize} minSize={20}>
|
|
290
|
+
{renderSections(rightSections, true)}
|
|
291
|
+
</ResizablePanel>
|
|
292
|
+
</>
|
|
293
|
+
)}
|
|
294
|
+
</ResizablePanelGroup>
|
|
295
|
+
</div>
|
|
296
|
+
);
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
export default SplitForm;
|