@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.
@@ -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;