@object-ui/plugin-form 0.3.1 → 2.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 +21 -0
- package/CHANGELOG.md +15 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1400 -280
- 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.msw.test.d.ts +0 -0
- package/dist/packages/plugin-form/src/ObjectForm.test.d.ts +1 -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/index.d.ts +15 -0
- package/package.json +10 -8
- 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 +156 -0
- package/src/ObjectForm.test.tsx +61 -0
- package/src/ObjectForm.tsx +267 -15
- 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/index.tsx +62 -2
- package/vite.config.ts +18 -0
- package/vitest.config.ts +12 -0
- package/vitest.setup.ts +1 -0
- package/dist/plugin-form/src/index.d.ts +0 -3
- /package/dist/{plugin-form → packages/plugin-form}/src/ObjectForm.d.ts +0 -0
|
@@ -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;
|
|
@@ -0,0 +1,394 @@
|
|
|
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
|
+
* TabbedForm Component
|
|
11
|
+
*
|
|
12
|
+
* A form component that organizes sections into tabs.
|
|
13
|
+
* Aligns with @objectstack/spec FormView type: 'tabbed'
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import React, { useState, useCallback } from 'react';
|
|
17
|
+
import type { FormField, DataSource } from '@object-ui/types';
|
|
18
|
+
import { Tabs, TabsContent, TabsList, TabsTrigger, cn } from '@object-ui/components';
|
|
19
|
+
import { FormSection } from './FormSection';
|
|
20
|
+
import { SchemaRenderer } from '@object-ui/react';
|
|
21
|
+
import { mapFieldTypeToFormType, buildValidationRules } from '@object-ui/fields';
|
|
22
|
+
|
|
23
|
+
export interface FormSectionConfig {
|
|
24
|
+
/**
|
|
25
|
+
* Section identifier (used as tab value)
|
|
26
|
+
*/
|
|
27
|
+
name?: string;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Section label (used as tab trigger text)
|
|
31
|
+
*/
|
|
32
|
+
label?: string;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Section description
|
|
36
|
+
*/
|
|
37
|
+
description?: string;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Number of columns in the section
|
|
41
|
+
* @default 1
|
|
42
|
+
*/
|
|
43
|
+
columns?: 1 | 2 | 3 | 4;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Field names or configurations in this section
|
|
47
|
+
*/
|
|
48
|
+
fields: (string | FormField)[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface TabbedFormSchema {
|
|
52
|
+
type: 'object-form';
|
|
53
|
+
formType: 'tabbed';
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Object name for ObjectQL schema lookup
|
|
57
|
+
*/
|
|
58
|
+
objectName: string;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Form mode
|
|
62
|
+
*/
|
|
63
|
+
mode: 'create' | 'edit' | 'view';
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Record ID (for edit/view modes)
|
|
67
|
+
*/
|
|
68
|
+
recordId?: string | number;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Tab sections configuration
|
|
72
|
+
*/
|
|
73
|
+
sections: FormSectionConfig[];
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Default active tab (section name)
|
|
77
|
+
*/
|
|
78
|
+
defaultTab?: string;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Tab position
|
|
82
|
+
* @default 'top'
|
|
83
|
+
*/
|
|
84
|
+
tabPosition?: 'top' | 'bottom' | 'left' | 'right';
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Show submit button
|
|
88
|
+
* @default true
|
|
89
|
+
*/
|
|
90
|
+
showSubmit?: boolean;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Submit button text
|
|
94
|
+
*/
|
|
95
|
+
submitText?: string;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Show cancel button
|
|
99
|
+
* @default true
|
|
100
|
+
*/
|
|
101
|
+
showCancel?: boolean;
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Cancel button text
|
|
105
|
+
*/
|
|
106
|
+
cancelText?: string;
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Initial values
|
|
110
|
+
*/
|
|
111
|
+
initialValues?: Record<string, any>;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Initial data (alias for initialValues)
|
|
115
|
+
*/
|
|
116
|
+
initialData?: Record<string, any>;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Read-only mode
|
|
120
|
+
*/
|
|
121
|
+
readOnly?: boolean;
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Callbacks
|
|
125
|
+
*/
|
|
126
|
+
onSuccess?: (data: any) => void | Promise<void>;
|
|
127
|
+
onError?: (error: Error) => void;
|
|
128
|
+
onCancel?: () => void;
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* CSS class
|
|
132
|
+
*/
|
|
133
|
+
className?: string;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export interface TabbedFormProps {
|
|
137
|
+
schema: TabbedFormSchema;
|
|
138
|
+
dataSource?: DataSource;
|
|
139
|
+
className?: string;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* TabbedForm Component
|
|
144
|
+
*
|
|
145
|
+
* Renders a form with sections organized as tabs.
|
|
146
|
+
*
|
|
147
|
+
* @example
|
|
148
|
+
* ```tsx
|
|
149
|
+
* <TabbedForm
|
|
150
|
+
* schema={{
|
|
151
|
+
* type: 'object-form',
|
|
152
|
+
* formType: 'tabbed',
|
|
153
|
+
* objectName: 'contacts',
|
|
154
|
+
* mode: 'create',
|
|
155
|
+
* sections: [
|
|
156
|
+
* { label: 'Basic Info', fields: ['firstName', 'lastName', 'email'] },
|
|
157
|
+
* { label: 'Address', fields: ['street', 'city', 'country'] },
|
|
158
|
+
* ]
|
|
159
|
+
* }}
|
|
160
|
+
* dataSource={dataSource}
|
|
161
|
+
* />
|
|
162
|
+
* ```
|
|
163
|
+
*/
|
|
164
|
+
export const TabbedForm: React.FC<TabbedFormProps> = ({
|
|
165
|
+
schema,
|
|
166
|
+
dataSource,
|
|
167
|
+
className,
|
|
168
|
+
}) => {
|
|
169
|
+
const [objectSchema, setObjectSchema] = useState<any>(null);
|
|
170
|
+
const [formData, setFormData] = useState<Record<string, any>>({});
|
|
171
|
+
const [loading, setLoading] = useState(true);
|
|
172
|
+
const [error, setError] = useState<Error | null>(null);
|
|
173
|
+
const [activeTab, setActiveTab] = useState<string>(
|
|
174
|
+
schema.defaultTab || schema.sections[0]?.name || schema.sections[0]?.label || 'tab-0'
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
// Fetch object schema
|
|
178
|
+
React.useEffect(() => {
|
|
179
|
+
const fetchSchema = async () => {
|
|
180
|
+
if (!dataSource) {
|
|
181
|
+
setLoading(false);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
try {
|
|
186
|
+
const schemaData = await dataSource.getObjectSchema(schema.objectName);
|
|
187
|
+
setObjectSchema(schemaData);
|
|
188
|
+
} catch (err) {
|
|
189
|
+
setError(err as Error);
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
fetchSchema();
|
|
194
|
+
}, [schema.objectName, dataSource]);
|
|
195
|
+
|
|
196
|
+
// Fetch initial data for edit/view modes
|
|
197
|
+
React.useEffect(() => {
|
|
198
|
+
const fetchData = async () => {
|
|
199
|
+
if (schema.mode === 'create' || !schema.recordId || !dataSource) {
|
|
200
|
+
setFormData(schema.initialData || schema.initialValues || {});
|
|
201
|
+
setLoading(false);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
try {
|
|
206
|
+
const data = await dataSource.findOne(schema.objectName, schema.recordId);
|
|
207
|
+
setFormData(data || {});
|
|
208
|
+
} catch (err) {
|
|
209
|
+
setError(err as Error);
|
|
210
|
+
} finally {
|
|
211
|
+
setLoading(false);
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
if (objectSchema || !dataSource) {
|
|
216
|
+
fetchData();
|
|
217
|
+
}
|
|
218
|
+
}, [objectSchema, schema.mode, schema.recordId, schema.initialData, schema.initialValues, dataSource, schema.objectName]);
|
|
219
|
+
|
|
220
|
+
// Build form fields from section config
|
|
221
|
+
const buildSectionFields = useCallback((section: FormSectionConfig): FormField[] => {
|
|
222
|
+
const fields: FormField[] = [];
|
|
223
|
+
|
|
224
|
+
for (const fieldDef of section.fields) {
|
|
225
|
+
const fieldName = typeof fieldDef === 'string' ? fieldDef : fieldDef.name;
|
|
226
|
+
|
|
227
|
+
if (typeof fieldDef === 'object') {
|
|
228
|
+
// Use the field definition directly
|
|
229
|
+
fields.push(fieldDef);
|
|
230
|
+
} else if (objectSchema?.fields?.[fieldName]) {
|
|
231
|
+
// Build from object schema
|
|
232
|
+
const field = objectSchema.fields[fieldName];
|
|
233
|
+
fields.push({
|
|
234
|
+
name: fieldName,
|
|
235
|
+
label: field.label || fieldName,
|
|
236
|
+
type: mapFieldTypeToFormType(field.type),
|
|
237
|
+
required: field.required || false,
|
|
238
|
+
disabled: schema.readOnly || schema.mode === 'view' || field.readonly,
|
|
239
|
+
placeholder: field.placeholder,
|
|
240
|
+
description: field.help || field.description,
|
|
241
|
+
validation: buildValidationRules(field),
|
|
242
|
+
field: field,
|
|
243
|
+
options: field.options,
|
|
244
|
+
multiple: field.multiple,
|
|
245
|
+
});
|
|
246
|
+
} else {
|
|
247
|
+
// Fallback for unknown fields
|
|
248
|
+
fields.push({
|
|
249
|
+
name: fieldName,
|
|
250
|
+
label: fieldName,
|
|
251
|
+
type: 'input',
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return fields;
|
|
257
|
+
}, [objectSchema, schema.readOnly, schema.mode]);
|
|
258
|
+
|
|
259
|
+
// Handle form submission
|
|
260
|
+
const handleSubmit = useCallback(async (data: Record<string, any>) => {
|
|
261
|
+
if (!dataSource) {
|
|
262
|
+
if (schema.onSuccess) {
|
|
263
|
+
await schema.onSuccess(data);
|
|
264
|
+
}
|
|
265
|
+
return data;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
try {
|
|
269
|
+
let result;
|
|
270
|
+
|
|
271
|
+
if (schema.mode === 'create') {
|
|
272
|
+
result = await dataSource.create(schema.objectName, data);
|
|
273
|
+
} else if (schema.mode === 'edit' && schema.recordId) {
|
|
274
|
+
result = await dataSource.update(schema.objectName, schema.recordId, data);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (schema.onSuccess) {
|
|
278
|
+
await schema.onSuccess(result);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return result;
|
|
282
|
+
} catch (err) {
|
|
283
|
+
if (schema.onError) {
|
|
284
|
+
schema.onError(err as Error);
|
|
285
|
+
}
|
|
286
|
+
throw err;
|
|
287
|
+
}
|
|
288
|
+
}, [schema, dataSource]);
|
|
289
|
+
|
|
290
|
+
// Handle cancel
|
|
291
|
+
const handleCancel = useCallback(() => {
|
|
292
|
+
if (schema.onCancel) {
|
|
293
|
+
schema.onCancel();
|
|
294
|
+
}
|
|
295
|
+
}, [schema]);
|
|
296
|
+
|
|
297
|
+
// Generate tab value
|
|
298
|
+
const getTabValue = (section: FormSectionConfig, index: number): string => {
|
|
299
|
+
return section.name || section.label || `tab-${index}`;
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
if (error) {
|
|
303
|
+
return (
|
|
304
|
+
<div className="p-4 border border-red-300 bg-red-50 rounded-md">
|
|
305
|
+
<h3 className="text-red-800 font-semibold">Error loading form</h3>
|
|
306
|
+
<p className="text-red-600 text-sm mt-1">{error.message}</p>
|
|
307
|
+
</div>
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (loading) {
|
|
312
|
+
return (
|
|
313
|
+
<div className="p-8 text-center">
|
|
314
|
+
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900"></div>
|
|
315
|
+
<p className="mt-2 text-sm text-gray-600">Loading form...</p>
|
|
316
|
+
</div>
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Collect all fields across all sections for the form
|
|
321
|
+
const allFields: FormField[] = schema.sections.flatMap(section => buildSectionFields(section));
|
|
322
|
+
|
|
323
|
+
// Build the overall form schema
|
|
324
|
+
const formSchema = {
|
|
325
|
+
type: 'form' as const,
|
|
326
|
+
fields: allFields,
|
|
327
|
+
layout: 'vertical' as const,
|
|
328
|
+
defaultValues: formData,
|
|
329
|
+
submitLabel: schema.submitText || (schema.mode === 'create' ? 'Create' : 'Update'),
|
|
330
|
+
cancelLabel: schema.cancelText,
|
|
331
|
+
showSubmit: schema.showSubmit !== false && schema.mode !== 'view',
|
|
332
|
+
showCancel: schema.showCancel !== false,
|
|
333
|
+
onSubmit: handleSubmit,
|
|
334
|
+
onCancel: handleCancel,
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
// Determine orientation based on tabPosition
|
|
338
|
+
const isVertical = schema.tabPosition === 'left' || schema.tabPosition === 'right';
|
|
339
|
+
|
|
340
|
+
return (
|
|
341
|
+
<div className={cn('w-full', className, schema.className)}>
|
|
342
|
+
<Tabs
|
|
343
|
+
value={activeTab}
|
|
344
|
+
onValueChange={setActiveTab}
|
|
345
|
+
orientation={isVertical ? 'vertical' : 'horizontal'}
|
|
346
|
+
className={cn(isVertical && 'flex gap-4')}
|
|
347
|
+
>
|
|
348
|
+
<TabsList className={cn(
|
|
349
|
+
isVertical ? 'flex-col h-auto' : '',
|
|
350
|
+
schema.tabPosition === 'bottom' && 'order-last',
|
|
351
|
+
schema.tabPosition === 'right' && 'order-last'
|
|
352
|
+
)}>
|
|
353
|
+
{schema.sections.map((section, index) => (
|
|
354
|
+
<TabsTrigger
|
|
355
|
+
key={getTabValue(section, index)}
|
|
356
|
+
value={getTabValue(section, index)}
|
|
357
|
+
className={isVertical ? 'w-full justify-start' : ''}
|
|
358
|
+
>
|
|
359
|
+
{section.label || `Tab ${index + 1}`}
|
|
360
|
+
</TabsTrigger>
|
|
361
|
+
))}
|
|
362
|
+
</TabsList>
|
|
363
|
+
|
|
364
|
+
<div className="flex-1">
|
|
365
|
+
{schema.sections.map((section, index) => (
|
|
366
|
+
<TabsContent
|
|
367
|
+
key={getTabValue(section, index)}
|
|
368
|
+
value={getTabValue(section, index)}
|
|
369
|
+
className="mt-0"
|
|
370
|
+
>
|
|
371
|
+
<FormSection
|
|
372
|
+
description={section.description}
|
|
373
|
+
columns={section.columns || 1}
|
|
374
|
+
>
|
|
375
|
+
{/* Render fields for this section */}
|
|
376
|
+
<SchemaRenderer
|
|
377
|
+
schema={{
|
|
378
|
+
...formSchema,
|
|
379
|
+
fields: buildSectionFields(section),
|
|
380
|
+
// Only show buttons on the last tab or always visible
|
|
381
|
+
showSubmit: schema.showSubmit !== false && schema.mode !== 'view',
|
|
382
|
+
showCancel: schema.showCancel !== false,
|
|
383
|
+
}}
|
|
384
|
+
/>
|
|
385
|
+
</FormSection>
|
|
386
|
+
</TabsContent>
|
|
387
|
+
))}
|
|
388
|
+
</div>
|
|
389
|
+
</Tabs>
|
|
390
|
+
</div>
|
|
391
|
+
);
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
export default TabbedForm;
|