@object-ui/plugin-form 0.5.0 → 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.
@@ -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
- setLoading(false);
274
- }, [objectSchema, schema.fields, schema.customFields, schema.readOnly, schema.mode, hasInlineFields]);
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) => {
@@ -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: (schema.layout === 'vertical' || schema.layout === 'horizontal')
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;