@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.
Files changed (34) hide show
  1. package/.turbo/turbo-build.log +21 -0
  2. package/CHANGELOG.md +15 -0
  3. package/dist/index.d.ts +1 -1
  4. package/dist/index.js +1400 -280
  5. package/dist/index.umd.cjs +2 -2
  6. package/dist/packages/plugin-form/src/DrawerForm.d.ts +61 -0
  7. package/dist/packages/plugin-form/src/FormSection.d.ts +49 -0
  8. package/dist/packages/plugin-form/src/FormVariants.test.d.ts +0 -0
  9. package/dist/packages/plugin-form/src/ModalForm.d.ts +60 -0
  10. package/dist/packages/plugin-form/src/ObjectForm.msw.test.d.ts +0 -0
  11. package/dist/packages/plugin-form/src/ObjectForm.test.d.ts +1 -0
  12. package/dist/packages/plugin-form/src/SplitForm.d.ts +50 -0
  13. package/dist/packages/plugin-form/src/TabbedForm.d.ts +123 -0
  14. package/dist/packages/plugin-form/src/WizardForm.d.ts +112 -0
  15. package/dist/packages/plugin-form/src/__tests__/NewVariants.test.d.ts +8 -0
  16. package/dist/packages/plugin-form/src/index.d.ts +15 -0
  17. package/package.json +10 -8
  18. package/src/DrawerForm.tsx +385 -0
  19. package/src/FormSection.tsx +144 -0
  20. package/src/FormVariants.test.tsx +219 -0
  21. package/src/ModalForm.tsx +379 -0
  22. package/src/ObjectForm.msw.test.tsx +156 -0
  23. package/src/ObjectForm.test.tsx +61 -0
  24. package/src/ObjectForm.tsx +267 -15
  25. package/src/SplitForm.tsx +299 -0
  26. package/src/TabbedForm.tsx +394 -0
  27. package/src/WizardForm.tsx +501 -0
  28. package/src/__tests__/NewVariants.test.tsx +488 -0
  29. package/src/index.tsx +62 -2
  30. package/vite.config.ts +18 -0
  31. package/vitest.config.ts +12 -0
  32. package/vitest.setup.ts +1 -0
  33. package/dist/plugin-form/src/index.d.ts +0 -3
  34. /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;