@object-ui/plugin-form 3.1.5 → 3.3.1

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 (42) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +21 -1
  3. package/dist/index.d.ts +1 -1
  4. package/dist/index.js +116 -73
  5. package/dist/index.umd.cjs +2 -2
  6. package/dist/{plugin-form → packages/plugin-form}/src/DrawerForm.d.ts +2 -0
  7. package/dist/{plugin-form → packages/plugin-form}/src/autoLayout.d.ts +11 -4
  8. package/package.json +43 -11
  9. package/.turbo/turbo-build.log +0 -32
  10. package/src/DrawerForm.tsx +0 -410
  11. package/src/EmbeddableForm.tsx +0 -240
  12. package/src/FormAnalytics.tsx +0 -209
  13. package/src/FormSection.tsx +0 -152
  14. package/src/FormVariants.test.tsx +0 -219
  15. package/src/ModalForm.tsx +0 -485
  16. package/src/ObjectForm.msw.test.tsx +0 -156
  17. package/src/ObjectForm.stories.tsx +0 -85
  18. package/src/ObjectForm.test.tsx +0 -61
  19. package/src/ObjectForm.tsx +0 -609
  20. package/src/SplitForm.tsx +0 -300
  21. package/src/TabbedForm.tsx +0 -395
  22. package/src/WizardForm.tsx +0 -502
  23. package/src/__tests__/EmbeddableFormPrefill.test.tsx +0 -186
  24. package/src/__tests__/MobileUX.test.tsx +0 -433
  25. package/src/__tests__/NewVariants.test.tsx +0 -684
  26. package/src/__tests__/autoLayout.test.ts +0 -339
  27. package/src/__tests__/form-validation-submit.test.tsx +0 -286
  28. package/src/autoLayout.ts +0 -166
  29. package/src/index.tsx +0 -134
  30. package/tsconfig.json +0 -9
  31. package/vite.config.ts +0 -57
  32. package/vitest.config.ts +0 -12
  33. package/vitest.setup.ts +0 -1
  34. /package/dist/{plugin-form → packages/plugin-form}/src/EmbeddableForm.d.ts +0 -0
  35. /package/dist/{plugin-form → packages/plugin-form}/src/FormAnalytics.d.ts +0 -0
  36. /package/dist/{plugin-form → packages/plugin-form}/src/FormSection.d.ts +0 -0
  37. /package/dist/{plugin-form → packages/plugin-form}/src/ModalForm.d.ts +0 -0
  38. /package/dist/{plugin-form → packages/plugin-form}/src/ObjectForm.d.ts +0 -0
  39. /package/dist/{plugin-form → packages/plugin-form}/src/SplitForm.d.ts +0 -0
  40. /package/dist/{plugin-form → packages/plugin-form}/src/TabbedForm.d.ts +0 -0
  41. /package/dist/{plugin-form → packages/plugin-form}/src/WizardForm.d.ts +0 -0
  42. /package/dist/{plugin-form → packages/plugin-form}/src/index.d.ts +0 -0
@@ -1,609 +0,0 @@
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
- * ObjectForm Component
11
- *
12
- * A smart form component that generates forms from ObjectQL object schemas.
13
- * It automatically creates form fields based on object metadata.
14
- */
15
-
16
- import React, { useEffect, useState, useCallback } from 'react';
17
- import type { ObjectFormSchema, FormField, FormSchema, DataSource } from '@object-ui/types';
18
- import { SchemaRenderer, useSafeFieldLabel } from '@object-ui/react';
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';
26
- import { applyAutoLayout } from './autoLayout';
27
-
28
- export interface ObjectFormProps {
29
- /**
30
- * The schema configuration for the form
31
- */
32
- schema: ObjectFormSchema;
33
-
34
- /**
35
- * Data source (ObjectQL or ObjectStack adapter)
36
- * Optional when using inline field definitions (customFields or fields array with field objects)
37
- */
38
- dataSource?: DataSource;
39
-
40
- /**
41
- * Additional CSS class
42
- */
43
- className?: string;
44
- }
45
-
46
- /**
47
- * ObjectForm Component
48
- *
49
- * Renders a form for an ObjectQL object with automatic schema integration.
50
- *
51
- * @example
52
- * ```tsx
53
- * <ObjectForm
54
- * schema={{
55
- * type: 'object-form',
56
- * objectName: 'users',
57
- * mode: 'create',
58
- * fields: ['name', 'email', 'status']
59
- * }}
60
- * dataSource={dataSource}
61
- * />
62
- * ```
63
- */
64
- export const ObjectForm: React.FC<ObjectFormProps> = ({
65
- schema,
66
- dataSource,
67
- }) => {
68
-
69
- // Route to specialized form variant based on formType
70
- if (schema.formType === 'tabbed' && schema.sections?.length) {
71
- return (
72
- <TabbedForm
73
- schema={{
74
- ...schema,
75
- formType: 'tabbed',
76
- sections: schema.sections.map(s => ({
77
- name: s.name,
78
- label: s.label,
79
- description: s.description,
80
- columns: s.columns,
81
- fields: s.fields,
82
- })),
83
- defaultTab: schema.defaultTab,
84
- tabPosition: schema.tabPosition,
85
- }}
86
- dataSource={dataSource}
87
- className={schema.className}
88
- />
89
- );
90
- }
91
-
92
- if (schema.formType === 'wizard' && schema.sections?.length) {
93
- return (
94
- <WizardForm
95
- schema={{
96
- ...schema,
97
- formType: 'wizard',
98
- sections: schema.sections.map(s => ({
99
- name: s.name,
100
- label: s.label,
101
- description: s.description,
102
- columns: s.columns,
103
- fields: s.fields,
104
- })),
105
- allowSkip: schema.allowSkip,
106
- showStepIndicator: schema.showStepIndicator,
107
- nextText: schema.nextText,
108
- prevText: schema.prevText,
109
- onStepChange: schema.onStepChange,
110
- }}
111
- dataSource={dataSource}
112
- className={schema.className}
113
- />
114
- );
115
- }
116
-
117
- if (schema.formType === 'split' && schema.sections?.length) {
118
- return (
119
- <SplitForm
120
- schema={{
121
- ...schema,
122
- formType: 'split',
123
- sections: schema.sections.map(s => ({
124
- name: s.name,
125
- label: s.label,
126
- description: s.description,
127
- columns: s.columns,
128
- fields: s.fields,
129
- })),
130
- splitDirection: schema.splitDirection,
131
- splitSize: schema.splitSize,
132
- splitResizable: schema.splitResizable,
133
- }}
134
- dataSource={dataSource}
135
- className={schema.className}
136
- />
137
- );
138
- }
139
-
140
- if (schema.formType === 'drawer') {
141
- const { layout: _layout, ...drawerRest } = schema;
142
- const drawerLayout = (schema.layout === 'vertical' || schema.layout === 'horizontal') ? schema.layout : undefined;
143
- return (
144
- <DrawerForm
145
- schema={{
146
- ...drawerRest,
147
- layout: drawerLayout,
148
- formType: 'drawer',
149
- sections: schema.sections?.map(s => ({
150
- name: s.name,
151
- label: s.label,
152
- description: s.description,
153
- columns: s.columns,
154
- fields: s.fields,
155
- })),
156
- open: schema.open,
157
- onOpenChange: schema.onOpenChange,
158
- drawerSide: schema.drawerSide,
159
- drawerWidth: schema.drawerWidth,
160
- }}
161
- dataSource={dataSource}
162
- className={schema.className}
163
- />
164
- );
165
- }
166
-
167
- if (schema.formType === 'modal') {
168
- const { layout: _layout2, ...modalRest } = schema;
169
- const modalLayout = (schema.layout === 'vertical' || schema.layout === 'horizontal') ? schema.layout : undefined;
170
- return (
171
- <ModalForm
172
- schema={{
173
- ...modalRest,
174
- layout: modalLayout,
175
- formType: 'modal',
176
- sections: schema.sections?.map(s => ({
177
- name: s.name,
178
- label: s.label,
179
- description: s.description,
180
- columns: s.columns,
181
- fields: s.fields,
182
- })),
183
- open: schema.open,
184
- onOpenChange: schema.onOpenChange,
185
- modalSize: schema.modalSize,
186
- modalCloseButton: schema.modalCloseButton,
187
- }}
188
- dataSource={dataSource}
189
- className={schema.className}
190
- />
191
- );
192
- }
193
-
194
- // Default: simple form
195
- return <SimpleObjectForm schema={schema} dataSource={dataSource} />;
196
- };
197
-
198
- /**
199
- * SimpleObjectForm — default form variant with auto-generated fields from ObjectQL schema.
200
- */
201
- const SimpleObjectForm: React.FC<ObjectFormProps> = ({
202
- schema,
203
- dataSource,
204
- }) => {
205
- const { fieldLabel } = useSafeFieldLabel();
206
-
207
- const [objectSchema, setObjectSchema] = useState<any>(null);
208
- const [formFields, setFormFields] = useState<FormField[]>([]);
209
- const [initialData, setInitialData] = useState<any>(null);
210
- const [loading, setLoading] = useState(true);
211
- const [error, setError] = useState<Error | null>(null);
212
-
213
- // Check if using inline fields (fields defined as objects, not just names)
214
- const hasInlineFields = schema.customFields && schema.customFields.length > 0;
215
-
216
- // Initialize with inline data if provided
217
- useEffect(() => {
218
- if (hasInlineFields) {
219
- setInitialData(schema.initialData || schema.initialValues || {});
220
- setLoading(false);
221
- }
222
- }, [hasInlineFields, schema.initialData, schema.initialValues]);
223
-
224
- // Fetch object schema from ObjectQL/ObjectStack (skip if using inline fields)
225
- useEffect(() => {
226
- const fetchObjectSchema = async () => {
227
- try {
228
- if (!dataSource) {
229
- throw new Error('DataSource is required when using ObjectQL schema fetching (inline fields not provided)');
230
- }
231
- const schemaData = await dataSource.getObjectSchema(schema.objectName);
232
- if (!schemaData) {
233
- throw new Error(`No schema found for object "${schema.objectName}"`);
234
- }
235
- setObjectSchema(schemaData);
236
- } catch (err) {
237
- setError(err as Error);
238
- setLoading(false);
239
- }
240
- };
241
-
242
- // Skip fetching if we have inline fields
243
- if (hasInlineFields) {
244
- // Use a minimal schema for inline fields
245
- setObjectSchema({
246
- name: schema.objectName,
247
- fields: {} as Record<string, any>,
248
- });
249
- } else if (schema.objectName && dataSource) {
250
- fetchObjectSchema();
251
- } else if (!hasInlineFields) {
252
- // No objectName or dataSource and no inline fields — cannot proceed
253
- setLoading(false);
254
- }
255
- }, [schema.objectName, dataSource, hasInlineFields]);
256
-
257
- // Fetch initial data for edit/view modes (skip if using inline data)
258
- useEffect(() => {
259
- const fetchInitialData = async () => {
260
- if (!schema.recordId || schema.mode === 'create') {
261
- setInitialData(schema.initialData || schema.initialValues || {});
262
- setLoading(false);
263
- return;
264
- }
265
-
266
- // Skip fetching if using inline data
267
- if (hasInlineFields) {
268
- return;
269
- }
270
-
271
- if (!dataSource) {
272
- setError(new Error('DataSource is required for fetching record data (inline data not provided)'));
273
- setLoading(false);
274
- return;
275
- }
276
-
277
- setLoading(true);
278
- try {
279
- const data = await dataSource.findOne(schema.objectName, schema.recordId);
280
- setInitialData(data);
281
- } catch (err) {
282
- console.error('Failed to fetch record:', err);
283
- setError(err as Error);
284
- } finally {
285
- setLoading(false);
286
- }
287
- };
288
-
289
- if (objectSchema && !hasInlineFields) {
290
- fetchInitialData();
291
- }
292
- }, [schema.objectName, schema.recordId, schema.mode, schema.initialValues, schema.initialData, dataSource, objectSchema, hasInlineFields]);
293
-
294
- // Generate form fields from object schema or inline fields
295
- useEffect(() => {
296
- // For inline fields, use them directly
297
- if (hasInlineFields && schema.customFields) {
298
- setFormFields(schema.customFields);
299
- setLoading(false);
300
- return;
301
- }
302
-
303
- if (!objectSchema) return;
304
-
305
- const generatedFields: FormField[] = [];
306
-
307
- // Determine which fields to include
308
- const fieldsToShow = schema.fields || Object.keys(objectSchema.fields || {});
309
-
310
- // Support object format for fields in schema (legacy/compat)
311
- const fieldNames = Array.isArray(fieldsToShow)
312
- ? fieldsToShow
313
- : Object.keys(fieldsToShow);
314
-
315
- fieldNames.forEach((fieldName) => {
316
- // If fieldsToShow is an array of strings, fieldName is the string
317
- // If fieldsToShow is array of objects (unlikely but possible in some formats), we need to extract name
318
- const name = typeof fieldName === 'string' ? fieldName : (fieldName as any).name;
319
- if (!name) return;
320
-
321
- const field = objectSchema.fields?.[name];
322
- if (!field && !hasInlineFields) return; // Skip if not found in object definition unless inline
323
-
324
- // Check field-level permissions for create/edit modes
325
- const hasWritePermission = !field?.permissions || field?.permissions.write !== false;
326
- if (schema.mode !== 'view' && !hasWritePermission) return; // Skip fields without write permission
327
-
328
- // Check if there's a custom field configuration
329
- const customField = schema.customFields?.find(f => f.name === name);
330
-
331
- if (customField) {
332
- generatedFields.push(customField);
333
- } else if (field) {
334
- // Auto-generate field from schema
335
- const formField: FormField = {
336
- name: name,
337
- label: fieldLabel(schema.objectName, name, field.label || fieldName),
338
- type: mapFieldTypeToFormType(field.type),
339
- required: field.required || false,
340
- disabled: schema.readOnly || schema.mode === 'view' || field.readonly,
341
- placeholder: field.placeholder,
342
- description: field.help || field.description,
343
- validation: buildValidationRules(field),
344
- // Important: Pass the original field metadata so widgets can access properties like precision, currency, etc.
345
- field: field,
346
- };
347
-
348
- // Add field-specific properties
349
- if (field.type === 'select' || field.type === 'lookup' || field.type === 'master_detail') {
350
- formField.options = field.options || [];
351
- formField.multiple = field.multiple;
352
- }
353
-
354
- if (field.type === 'number' || field.type === 'currency' || field.type === 'percent') {
355
- formField.inputType = 'number';
356
- formField.min = field.min;
357
- formField.max = field.max;
358
- formField.step = field.precision ? Math.pow(10, -field.precision) : undefined;
359
- }
360
-
361
- if (field.type === 'date') {
362
- formField.inputType = 'date';
363
- }
364
-
365
- if (field.type === 'datetime') {
366
- formField.inputType = 'datetime-local';
367
- }
368
-
369
- if (field.type === 'text' || field.type === 'textarea' || field.type === 'markdown' || field.type === 'html') {
370
- formField.maxLength = field.max_length;
371
- formField.minLength = field.min_length;
372
- }
373
-
374
- if (field.type === 'file' || field.type === 'image') {
375
- formField.inputType = 'file';
376
- formField.multiple = field.multiple;
377
- formField.accept = field.accept ? field.accept.join(',') : undefined;
378
- // Add validation hints for file size and dimensions
379
- if (field.max_size) {
380
- const sizeHint = `Max size: ${formatFileSize(field.max_size)}`;
381
- formField.description = formField.description
382
- ? `${formField.description} (${sizeHint})`
383
- : sizeHint;
384
- }
385
- }
386
-
387
- if (field.type === 'email') {
388
- formField.inputType = 'email';
389
- }
390
-
391
- if (field.type === 'phone') {
392
- formField.inputType = 'tel';
393
- }
394
-
395
- if (field.type === 'url') {
396
- formField.inputType = 'url';
397
- }
398
-
399
- if (field.type === 'password') {
400
- formField.inputType = 'password';
401
- }
402
-
403
- if (field.type === 'time') {
404
- formField.inputType = 'time';
405
- }
406
-
407
- // Read-only fields for computed types
408
- if (field.type === 'formula' || field.type === 'summary' || field.type === 'auto_number') {
409
- formField.disabled = true;
410
- }
411
-
412
- // Add conditional visibility based on field dependencies
413
- if (field.visible_on) {
414
- formField.visible = (formData: any) => {
415
- return evaluateCondition(field.visible_on, formData);
416
- };
417
- }
418
-
419
- generatedFields.push(formField);
420
- }
421
- });
422
-
423
- setFormFields(generatedFields);
424
-
425
- // Only set loading to false if we are not going to fetch data
426
- // This prevents a flash of empty form before data is loaded in edit mode
427
- const willFetchData = !hasInlineFields && (schema.recordId && schema.mode !== 'create' && dataSource);
428
- if (!willFetchData) {
429
- setLoading(false);
430
- }
431
- }, [objectSchema, schema.fields, schema.customFields, schema.readOnly, schema.mode, hasInlineFields, schema.recordId, dataSource]);
432
-
433
- // Handle form submission
434
- const handleSubmit = useCallback(async (formData: any, e?: any) => {
435
- // If we receive an event as the first argument, it means the Form renderer passed the event instead of data
436
- // This happens when react-hook-form's handleSubmit is bypassed or configured incorrectly
437
- if (formData && (formData.nativeEvent || formData._reactName === 'onSubmit')) {
438
- console.warn('ObjectForm: Received Event instead of data in handleSubmit! This suggests a Form renderer issue.');
439
- // Proceed defensively - we can't do much if we don't have data, but let's try to not crash
440
- // If we are here, formData is actually the event
441
- if (e === undefined) {
442
- e = formData;
443
- formData = {}; // Reset to empty object or we try to submit the Event object
444
- }
445
- }
446
-
447
- // For inline fields without a dataSource, just call the success callback
448
- if (hasInlineFields && !dataSource) {
449
- if (schema.onSuccess) {
450
- await schema.onSuccess(formData);
451
- }
452
- return formData;
453
- }
454
-
455
- if (!dataSource) {
456
- throw new Error('DataSource is required for form submission (inline mode not configured)');
457
- }
458
-
459
- try {
460
- let result;
461
-
462
- if (schema.mode === 'create') {
463
- result = await dataSource.create(schema.objectName, formData);
464
- } else if (schema.mode === 'edit' && schema.recordId) {
465
- result = await dataSource.update(schema.objectName, schema.recordId, formData);
466
- } else {
467
- throw new Error('Invalid form mode or missing record ID');
468
- }
469
-
470
- // Call success callback if provided
471
- if (schema.onSuccess) {
472
- await schema.onSuccess(result);
473
- }
474
-
475
- return result;
476
- } catch (err) {
477
- console.error('Failed to submit form:', err);
478
-
479
- // Call error callback if provided
480
- if (schema.onError) {
481
- schema.onError(err as Error);
482
- }
483
-
484
- throw err;
485
- }
486
- }, [schema, dataSource, hasInlineFields]);
487
-
488
- // Handle form cancellation
489
- const handleCancel = useCallback(() => {
490
- if (schema.onCancel) {
491
- schema.onCancel();
492
- }
493
- }, [schema]);
494
-
495
- // Calculate default values from schema fields
496
- const schemaDefaultValues = React.useMemo(() => {
497
- if (!objectSchema?.fields) return {};
498
- const defaults: Record<string, any> = {};
499
- Object.keys(objectSchema.fields).forEach(key => {
500
- const field = objectSchema.fields[key];
501
- if (field.defaultValue !== undefined) {
502
- defaults[key] = field.defaultValue;
503
- }
504
- });
505
- return defaults;
506
- }, [objectSchema]);
507
-
508
- const finalDefaultValues = {
509
- ...schemaDefaultValues,
510
- ...initialData
511
- };
512
-
513
- // Render error state
514
- if (error) {
515
- return (
516
- <div className="p-3 sm:p-4 border border-red-300 bg-red-50 rounded-md">
517
- <h3 className="text-red-800 font-semibold">Error loading form</h3>
518
- <p className="text-red-600 text-sm mt-1">{error.message}</p>
519
- </div>
520
- );
521
- }
522
-
523
- // Render loading state
524
- if (loading) {
525
- return (
526
- <div className="p-4 sm:p-8 text-center">
527
- <div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900"></div>
528
- <p className="mt-2 text-sm text-gray-600">Loading form...</p>
529
- </div>
530
- );
531
- }
532
-
533
- // Convert to FormSchema
534
- // Note: FormSchema currently only supports 'vertical' and 'horizontal' layouts
535
- // Map 'grid' and 'inline' to 'vertical' as fallback
536
- const formLayout = (schema.layout === 'vertical' || schema.layout === 'horizontal')
537
- ? schema.layout
538
- : 'vertical';
539
-
540
- // If sections are provided for the simple form, render with FormSection grouping
541
- if (schema.sections?.length && (!schema.formType || schema.formType === 'simple')) {
542
- return (
543
- <div className="w-full space-y-6">
544
- {schema.sections.map((section, index) => {
545
- // Filter formFields to only include fields in this section
546
- const sectionFieldNames = section.fields.map(f => typeof f === 'string' ? f : f.name);
547
- const sectionFields = formFields.filter(f => sectionFieldNames.includes(f.name));
548
-
549
- return (
550
- <FormSection
551
- key={section.name || section.label || index}
552
- label={section.label}
553
- description={section.description}
554
- collapsible={section.collapsible}
555
- collapsed={section.collapsed}
556
- columns={section.columns}
557
- >
558
- <SchemaRenderer
559
- schema={{
560
- type: 'form',
561
- fields: sectionFields,
562
- layout: formLayout,
563
- defaultValues: finalDefaultValues,
564
- // Only show action buttons after the last section
565
- showSubmit: index === schema.sections!.length - 1 && schema.showSubmit !== false && schema.mode !== 'view',
566
- showCancel: index === schema.sections!.length - 1 && schema.showCancel !== false,
567
- submitLabel: schema.submitText || (schema.mode === 'create' ? 'Create' : 'Update'),
568
- cancelLabel: schema.cancelText,
569
- onSubmit: handleSubmit,
570
- onCancel: handleCancel,
571
- } as FormSchema}
572
- />
573
- </FormSection>
574
- );
575
- })}
576
- </div>
577
- );
578
- }
579
-
580
- // Apply auto-layout: infer columns and colSpan when not explicitly configured
581
- const hasSections = schema.sections?.length;
582
- const autoLayoutResult = !hasSections
583
- ? applyAutoLayout(formFields, objectSchema, schema.columns, schema.mode)
584
- : { fields: formFields, columns: schema.columns };
585
-
586
- // Default flat form (no sections)
587
- const formSchema: FormSchema = {
588
- type: 'form',
589
- fields: autoLayoutResult.fields,
590
- layout: formLayout,
591
- columns: autoLayoutResult.columns,
592
- submitLabel: schema.submitText || (schema.mode === 'create' ? 'Create' : 'Update'),
593
- cancelLabel: schema.cancelText,
594
- showSubmit: schema.showSubmit !== false && schema.mode !== 'view',
595
- showCancel: schema.showCancel !== false,
596
- resetOnSubmit: schema.showReset,
597
- defaultValues: finalDefaultValues,
598
- onSubmit: handleSubmit,
599
- onCancel: handleCancel,
600
- className: schema.className,
601
- };
602
-
603
- return (
604
- <div className="w-full">
605
- <SchemaRenderer schema={formSchema} />
606
- </div>
607
- );
608
- };
609
-