@object-ui/plugin-form 3.3.0 → 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.
package/src/ModalForm.tsx DELETED
@@ -1,485 +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
- * ModalForm Component
11
- *
12
- * A form variant that renders inside a Dialog (modal) overlay.
13
- * Aligns with @objectstack/spec FormView type: 'modal'
14
- */
15
-
16
- import React, { useState, useCallback, useEffect, useMemo, useId } from 'react';
17
- import type { FormField, DataSource } from '@object-ui/types';
18
- import {
19
- Dialog,
20
- MobileDialogContent,
21
- DialogHeader,
22
- DialogTitle,
23
- DialogDescription,
24
- Skeleton,
25
- Button,
26
- cn,
27
- } from '@object-ui/components';
28
- import { Loader2 } from 'lucide-react';
29
- import { FormSection } from './FormSection';
30
- import { SchemaRenderer, useSafeFieldLabel } from '@object-ui/react';
31
- import { mapFieldTypeToFormType, buildValidationRules } from '@object-ui/fields';
32
- import { applyAutoLayout, inferModalSize } from './autoLayout';
33
-
34
- export interface ModalFormSectionConfig {
35
- name?: string;
36
- label?: string;
37
- description?: string;
38
- columns?: 1 | 2 | 3 | 4;
39
- fields: (string | FormField)[];
40
- }
41
-
42
- export interface ModalFormSchema {
43
- type: 'object-form';
44
- formType: 'modal';
45
- objectName: string;
46
- mode: 'create' | 'edit' | 'view';
47
- recordId?: string | number;
48
- title?: string;
49
- description?: string;
50
- sections?: ModalFormSectionConfig[];
51
- fields?: string[];
52
- customFields?: FormField[];
53
-
54
- /**
55
- * Whether the modal is open.
56
- * @default true
57
- */
58
- open?: boolean;
59
-
60
- /**
61
- * Callback when open state changes.
62
- */
63
- onOpenChange?: (open: boolean) => void;
64
-
65
- /**
66
- * Modal dialog size.
67
- * @default 'default'
68
- */
69
- modalSize?: 'sm' | 'default' | 'lg' | 'xl' | 'full';
70
-
71
- /**
72
- * Whether to show a close button in the header.
73
- * @default true
74
- */
75
- modalCloseButton?: boolean;
76
-
77
- // Common form props
78
- showSubmit?: boolean;
79
- submitText?: string;
80
- showCancel?: boolean;
81
- cancelText?: string;
82
- initialValues?: Record<string, any>;
83
- initialData?: Record<string, any>;
84
- readOnly?: boolean;
85
- layout?: 'vertical' | 'horizontal';
86
- columns?: number;
87
- onSuccess?: (data: any) => void | Promise<void>;
88
- onError?: (error: Error) => void;
89
- onCancel?: () => void;
90
- className?: string;
91
- }
92
-
93
- export interface ModalFormProps {
94
- schema: ModalFormSchema;
95
- dataSource?: DataSource;
96
- className?: string;
97
- }
98
-
99
- /**
100
- * Size class map for the dialog content.
101
- *
102
- * Uses `sm:` prefix so that `tailwind-merge` correctly resolves the conflict
103
- * with MobileDialogContent's base `sm:max-w-lg` class. On mobile (< sm) the
104
- * dialog is already full-screen, so max-width only matters at sm+ breakpoints.
105
- */
106
- const modalSizeClasses: Record<string, string> = {
107
- sm: 'sm:max-w-sm',
108
- default: 'sm:max-w-lg',
109
- lg: 'sm:max-w-2xl',
110
- xl: 'sm:max-w-5xl',
111
- full: 'sm:max-w-[95vw] sm:w-full',
112
- };
113
-
114
- /**
115
- * Container-query-based grid classes for form field layout.
116
- * Uses @container / @md: / @2xl: / @4xl: variants so that the grid
117
- * responds to the modal's actual width instead of the viewport,
118
- * ensuring single-column on narrow mobile modals regardless of viewport size.
119
- */
120
- const CONTAINER_GRID_COLS: Record<number, string | undefined> = {
121
- 1: undefined, // let the form renderer use its default (space-y-4)
122
- 2: 'grid gap-4 grid-cols-1 @md:grid-cols-2',
123
- 3: 'grid gap-4 grid-cols-1 @md:grid-cols-2 @2xl:grid-cols-3',
124
- 4: 'grid gap-4 grid-cols-1 @md:grid-cols-2 @2xl:grid-cols-3 @4xl:grid-cols-4',
125
- };
126
-
127
- export const ModalForm: React.FC<ModalFormProps> = ({
128
- schema,
129
- dataSource,
130
- className,
131
- }) => {
132
- const { fieldLabel } = useSafeFieldLabel();
133
- const [objectSchema, setObjectSchema] = useState<any>(null);
134
- const [formFields, setFormFields] = useState<FormField[]>([]);
135
- const [formData, setFormData] = useState<Record<string, any>>({});
136
- const [loading, setLoading] = useState(true);
137
- const [error, setError] = useState<Error | null>(null);
138
- const [isSubmitting, setIsSubmitting] = useState(false);
139
-
140
- const isOpen = schema.open !== false;
141
-
142
- // Stable form id for linking the external submit button to the form element
143
- const formId = useId();
144
-
145
- // Compute auto-layout for flat fields (no sections) to determine inferred columns
146
- const autoLayoutResult = useMemo(() => {
147
- if (schema.sections?.length || schema.customFields?.length) return null;
148
- return applyAutoLayout(formFields, objectSchema, schema.columns, schema.mode);
149
- }, [formFields, objectSchema, schema.columns, schema.mode, schema.sections, schema.customFields]);
150
-
151
- // Auto-upgrade modal size when auto-layout infers multi-column and user hasn't set modalSize
152
- const effectiveModalSize = useMemo(() => {
153
- if (schema.modalSize) return schema.modalSize;
154
- if (autoLayoutResult?.columns && autoLayoutResult.columns > 1) {
155
- return inferModalSize(autoLayoutResult.columns);
156
- }
157
- // Auto-upgrade for sections: use the max columns across all sections
158
- if (schema.sections?.length) {
159
- const maxCols = Math.max(...schema.sections.map(s => Number(s.columns) || 1));
160
- if (maxCols > 1) return inferModalSize(maxCols);
161
- }
162
- return 'default';
163
- }, [schema.modalSize, autoLayoutResult, schema.sections]);
164
-
165
- const sizeClass = modalSizeClasses[effectiveModalSize] || modalSizeClasses.default;
166
-
167
- // Fetch object schema
168
- useEffect(() => {
169
- const fetchSchema = async () => {
170
- if (!dataSource) {
171
- setLoading(false);
172
- return;
173
- }
174
- try {
175
- const data = await dataSource.getObjectSchema(schema.objectName);
176
- setObjectSchema(data);
177
- } catch (err) {
178
- setError(err as Error);
179
- setLoading(false);
180
- }
181
- };
182
- fetchSchema();
183
- }, [schema.objectName, dataSource]);
184
-
185
- // Fetch initial data
186
- useEffect(() => {
187
- const fetchData = async () => {
188
- if (schema.mode === 'create' || !schema.recordId) {
189
- setFormData(schema.initialData || schema.initialValues || {});
190
- setLoading(false);
191
- return;
192
- }
193
-
194
- if (!dataSource) {
195
- setFormData(schema.initialData || schema.initialValues || {});
196
- setLoading(false);
197
- return;
198
- }
199
-
200
- try {
201
- const data = await dataSource.findOne(schema.objectName, schema.recordId);
202
- setFormData(data || {});
203
- } catch (err) {
204
- setError(err as Error);
205
- } finally {
206
- setLoading(false);
207
- }
208
- };
209
-
210
- if (objectSchema || !dataSource) {
211
- fetchData();
212
- }
213
- }, [objectSchema, schema.mode, schema.recordId, schema.initialData, schema.initialValues, dataSource, schema.objectName]);
214
-
215
- // Build form fields from section config
216
- const buildSectionFields = useCallback((section: ModalFormSectionConfig): FormField[] => {
217
- const fields: FormField[] = [];
218
-
219
- for (const fieldDef of section.fields) {
220
- const fieldName = typeof fieldDef === 'string' ? fieldDef : fieldDef.name;
221
-
222
- if (typeof fieldDef === 'object') {
223
- fields.push(fieldDef);
224
- } else if (objectSchema?.fields?.[fieldName]) {
225
- const field = objectSchema.fields[fieldName];
226
- fields.push({
227
- name: fieldName,
228
- label: fieldLabel(schema.objectName, fieldName, field.label || fieldName),
229
- type: mapFieldTypeToFormType(field.type),
230
- required: field.required || false,
231
- disabled: schema.readOnly || schema.mode === 'view' || field.readonly,
232
- placeholder: field.placeholder,
233
- description: field.help || field.description,
234
- validation: buildValidationRules(field),
235
- field: field,
236
- options: field.options,
237
- multiple: field.multiple,
238
- });
239
- } else {
240
- fields.push({
241
- name: fieldName,
242
- label: fieldName,
243
- type: 'input',
244
- });
245
- }
246
- }
247
-
248
- return fields;
249
- }, [objectSchema, schema.readOnly, schema.mode]);
250
-
251
- // Build fields from flat field list (when no sections)
252
- useEffect(() => {
253
- if (!objectSchema && dataSource) return;
254
-
255
- if (schema.customFields?.length) {
256
- setFormFields(schema.customFields);
257
- setLoading(false);
258
- return;
259
- }
260
-
261
- if (schema.sections?.length) {
262
- setLoading(false);
263
- return;
264
- }
265
-
266
- if (!objectSchema) return;
267
-
268
- const fieldsToShow = schema.fields || Object.keys(objectSchema.fields || {});
269
- const generated: FormField[] = [];
270
-
271
- for (const fieldName of fieldsToShow) {
272
- const name = typeof fieldName === 'string' ? fieldName : (fieldName as any).name;
273
- if (!name) continue;
274
- const field = objectSchema.fields?.[name];
275
- if (!field) continue;
276
-
277
- generated.push({
278
- name,
279
- label: fieldLabel(schema.objectName, name, field.label || name),
280
- type: mapFieldTypeToFormType(field.type),
281
- required: field.required || false,
282
- disabled: schema.readOnly || schema.mode === 'view' || field.readonly,
283
- placeholder: field.placeholder,
284
- description: field.help || field.description,
285
- validation: buildValidationRules(field),
286
- field: field,
287
- options: field.options,
288
- multiple: field.multiple,
289
- });
290
- }
291
-
292
- setFormFields(generated);
293
- setLoading(false);
294
- }, [objectSchema, schema.fields, schema.customFields, schema.sections, schema.readOnly, schema.mode, dataSource]);
295
-
296
- // Handle form submission
297
- const handleSubmit = useCallback(async (data: Record<string, any>) => {
298
- setIsSubmitting(true);
299
- try {
300
- if (!dataSource) {
301
- if (schema.onSuccess) {
302
- await schema.onSuccess(data);
303
- }
304
- // Close modal on success
305
- schema.onOpenChange?.(false);
306
- return data;
307
- }
308
-
309
- let result;
310
- if (schema.mode === 'create') {
311
- result = await dataSource.create(schema.objectName, data);
312
- } else if (schema.mode === 'edit' && schema.recordId) {
313
- result = await dataSource.update(schema.objectName, schema.recordId, data);
314
- }
315
- if (schema.onSuccess) {
316
- await schema.onSuccess(result);
317
- }
318
- // Close modal on success
319
- schema.onOpenChange?.(false);
320
- return result;
321
- } catch (err) {
322
- if (schema.onError) {
323
- schema.onError(err as Error);
324
- }
325
- throw err;
326
- } finally {
327
- setIsSubmitting(false);
328
- }
329
- }, [schema, dataSource]);
330
-
331
- // Handle cancel
332
- const handleCancel = useCallback(() => {
333
- if (schema.onCancel) {
334
- schema.onCancel();
335
- }
336
- // Close modal on cancel
337
- schema.onOpenChange?.(false);
338
- }, [schema]);
339
-
340
- const formLayout = (schema.layout === 'vertical' || schema.layout === 'horizontal')
341
- ? schema.layout
342
- : 'vertical';
343
-
344
- // Build base form schema
345
- // Actions are hidden inside the form renderer — we render them in a sticky footer instead
346
- const showSubmit = schema.showSubmit !== false && schema.mode !== 'view';
347
- const showCancel = schema.showCancel !== false;
348
- const submitLabel = schema.submitText || (schema.mode === 'create' ? 'Create' : 'Update');
349
- const cancelLabel = schema.cancelText || 'Cancel';
350
-
351
- const baseFormSchema = {
352
- type: 'form' as const,
353
- layout: formLayout,
354
- defaultValues: formData,
355
- submitLabel,
356
- cancelLabel,
357
- showSubmit,
358
- showCancel,
359
- onSubmit: handleSubmit,
360
- onCancel: handleCancel,
361
- showActions: false, // Hide actions — rendered in sticky footer
362
- id: formId, // Link external submit button via form attribute
363
- };
364
-
365
- const renderContent = () => {
366
- if (error) {
367
- return (
368
- <div className="p-4 border border-red-300 bg-red-50 rounded-md">
369
- <h3 className="text-red-800 font-semibold">Error loading form</h3>
370
- <p className="text-red-600 text-sm mt-1">{error.message}</p>
371
- </div>
372
- );
373
- }
374
-
375
- if (loading) {
376
- return (
377
- <div className="space-y-4" data-testid="modal-form-skeleton">
378
- {[1, 2, 3].map((i) => (
379
- <div key={i} className="space-y-2">
380
- <Skeleton className="h-4 w-24" />
381
- <Skeleton className="h-10 w-full" />
382
- </div>
383
- ))}
384
- </div>
385
- );
386
- }
387
-
388
- // Sections layout
389
- if (schema.sections?.length) {
390
- return (
391
- <div className="space-y-6">
392
- {schema.sections.map((section, index) => {
393
- const sectionCols = section.columns || 1;
394
- return (
395
- <FormSection
396
- key={section.name || section.label || index}
397
- label={section.label}
398
- description={section.description}
399
- columns={sectionCols}
400
- gridClassName={CONTAINER_GRID_COLS[sectionCols]}
401
- >
402
- <SchemaRenderer
403
- schema={{
404
- ...baseFormSchema,
405
- fields: buildSectionFields(section),
406
- // Actions are in the sticky footer, not inside sections
407
- }}
408
- />
409
- </FormSection>
410
- );
411
- })}
412
- </div>
413
- );
414
- }
415
-
416
- // Reuse pre-computed auto-layout result for flat fields
417
- const layoutResult = autoLayoutResult ?? applyAutoLayout(formFields, objectSchema, schema.columns, schema.mode);
418
-
419
- // Flat fields layout — use container-query grid classes so the form
420
- // responds to the modal width, not the viewport width.
421
- const containerFieldClass = CONTAINER_GRID_COLS[layoutResult.columns || 1];
422
-
423
- return (
424
- <SchemaRenderer
425
- schema={{
426
- ...baseFormSchema,
427
- fields: layoutResult.fields,
428
- columns: layoutResult.columns,
429
- ...(containerFieldClass ? { fieldContainerClass: containerFieldClass } : {}),
430
- }}
431
- />
432
- );
433
- };
434
-
435
- const hasFooter = !loading && !error && (showSubmit || showCancel);
436
-
437
- return (
438
- <Dialog open={isOpen} onOpenChange={schema.onOpenChange}>
439
- <MobileDialogContent className={cn(sizeClass, 'flex flex-col h-[100dvh] sm:h-auto sm:max-h-[90vh] overflow-hidden p-0', className, schema.className)}>
440
- {(schema.title || schema.description) && (
441
- <DialogHeader className="shrink-0 px-4 pt-4 sm:px-6 sm:pt-6 pb-2 border-b">
442
- {schema.title && <DialogTitle>{schema.title}</DialogTitle>}
443
- {schema.description && <DialogDescription>{schema.description}</DialogDescription>}
444
- </DialogHeader>
445
- )}
446
-
447
- <div className="@container flex-1 overflow-y-auto px-4 sm:px-6 py-4">
448
- {renderContent()}
449
- </div>
450
-
451
- {/* Sticky footer — always visible action buttons */}
452
- {hasFooter && (
453
- <div className="shrink-0 border-t px-4 sm:px-6 py-3 bg-background" data-testid="modal-form-footer">
454
- <div className="flex flex-col sm:flex-row gap-2 sm:justify-end">
455
- {showCancel && (
456
- <Button
457
- type="button"
458
- variant="outline"
459
- onClick={handleCancel}
460
- disabled={isSubmitting}
461
- className="w-full sm:w-auto"
462
- >
463
- {cancelLabel}
464
- </Button>
465
- )}
466
- {showSubmit && (
467
- <Button
468
- type="submit"
469
- form={formId}
470
- disabled={isSubmitting}
471
- className="w-full sm:w-auto"
472
- >
473
- {isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
474
- {submitLabel}
475
- </Button>
476
- )}
477
- </div>
478
- </div>
479
- )}
480
- </MobileDialogContent>
481
- </Dialog>
482
- );
483
- };
484
-
485
- export default ModalForm;
@@ -1,156 +0,0 @@
1
- import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from 'vitest';
2
- import { render, screen, waitFor } from '@testing-library/react';
3
- import '@testing-library/jest-dom';
4
- import { ObjectForm } from './ObjectForm';
5
- import { ObjectStackAdapter } from '@object-ui/data-objectstack';
6
- import { setupServer } from 'msw/node';
7
- import { http, HttpResponse } from 'msw';
8
- import { registerAllFields } from '@object-ui/fields';
9
- import React from 'react';
10
- import { ContactObject } from '../../../examples/crm/src/objects/contact.object';
11
-
12
- // Register widget renderers
13
- registerAllFields();
14
-
15
- const BASE_URL = process.env.OBJECTSTACK_API_URL || 'http://localhost';
16
-
17
- // --- Mock Data ---
18
-
19
- const mockSchema = ContactObject;
20
-
21
- const mockRecord = {
22
- id: '1',
23
- name: 'Alice Johnson',
24
- email: 'alice@example.com',
25
- status: 'Active'
26
- };
27
-
28
- // --- MSW Setup ---
29
-
30
- const handlers = [
31
- // .well-known discovery endpoint (used by client.connect())
32
- http.get(`${BASE_URL}/.well-known/objectstack`, () => {
33
- return HttpResponse.json({
34
- name: 'ObjectStack API',
35
- version: '1.0',
36
- endpoints: {
37
- data: '/api/v1/data',
38
- metadata: '/api/v1/meta'
39
- },
40
- capabilities: {
41
- graphql: false,
42
- search: false,
43
- websockets: false,
44
- files: true,
45
- analytics: false,
46
- hub: false
47
- }
48
- });
49
- }),
50
-
51
- // OPTIONS handler for CORS preflight
52
- http.options(`${BASE_URL}/*`, () => {
53
- return new HttpResponse(null, {
54
- status: 200,
55
- headers: {
56
- 'Access-Control-Allow-Origin': '*',
57
- 'Access-Control-Allow-Methods': 'GET,HEAD,POST,PUT,DELETE,CONNECT,OPTIONS,TRACE,PATCH',
58
- 'Access-Control-Allow-Headers': 'Content-Type, Authorization',
59
- },
60
- });
61
- }),
62
-
63
- // Health check / Connection check (ObjectStackClient often pings root or /api/v1)
64
- http.get(`${BASE_URL}/api/v1`, () => {
65
- return HttpResponse.json({ status: 'ok', version: '1.0.0' });
66
- }),
67
-
68
- // Mock Schema Fetch: GET /api/v1/metadata/object/:name and /api/v1/meta/object/:name (client uses /meta)
69
- http.get(`${BASE_URL}/api/v1/metadata/object/:name`, ({ params }) => {
70
- const { name } = params;
71
- if (name === 'contact') {
72
- return HttpResponse.json(mockSchema);
73
- }
74
- return new HttpResponse(null, { status: 404 });
75
- }),
76
- http.get(`${BASE_URL}/api/v1/meta/object/:name`, ({ params }) => {
77
- const { name } = params;
78
- if (name === 'contact') {
79
- return HttpResponse.json(mockSchema);
80
- }
81
- return new HttpResponse(null, { status: 404 });
82
- }),
83
-
84
- // Mock Record Fetch: GET /api/v1/data/:object/:id
85
- http.get(`${BASE_URL}/api/v1/data/:object/:id`, ({ params }) => {
86
- const { object, id } = params;
87
- if (object === 'contact' && id === '1') {
88
- return HttpResponse.json({ record: mockRecord });
89
- }
90
- return new HttpResponse(null, { status: 404 });
91
- })
92
- ];
93
-
94
- const server = setupServer(...handlers);
95
-
96
- // --- Test Suite ---
97
-
98
- describe('ObjectForm with ObjectStack/MSW', () => {
99
- // Only start MSW if we are NOT using a real server
100
- if (!process.env.OBJECTSTACK_API_URL) {
101
- beforeAll(() => server.listen());
102
- afterEach(() => server.resetHandlers());
103
- afterAll(() => server.close());
104
- }
105
-
106
- // Create real adapter instance pointing to MSW or Real Server
107
- const dataSource = new ObjectStackAdapter({
108
- baseUrl: BASE_URL,
109
- // Add custom fetch for environment that might need it, or rely on global fetch
110
- // fetch: global.fetch
111
- });
112
-
113
- it('loads schema and renders form fields', async () => {
114
- render(
115
- <ObjectForm
116
- schema={{
117
- type: 'object-form',
118
- objectName: 'contact', // Triggers schema fetch
119
- mode: 'create'
120
- }}
121
- dataSource={dataSource} // Logic moves from mock fn to real adapter + MSW
122
- />
123
- );
124
-
125
- // Verify fields appear (async as schema loads via HTTP)
126
- await waitFor(() => {
127
- // Changed from 'Full Name' to 'Name' based on CRM example schema
128
- expect(screen.getByText('Name')).toBeInTheDocument();
129
- }, { timeout: 2000 }); // Give slight buffer for network mock
130
- expect(screen.getByText('Email')).toBeInTheDocument();
131
- expect(screen.getByText('Status')).toBeInTheDocument();
132
- });
133
-
134
- it('loads record data in edit mode', async () => {
135
- render(
136
- <ObjectForm
137
- schema={{
138
- type: 'object-form',
139
- objectName: 'contact',
140
- mode: 'edit',
141
- recordId: '1'
142
- }}
143
- dataSource={dataSource}
144
- />
145
- );
146
-
147
- // Initial load of schema logic + data fetch
148
- await waitFor(() => {
149
- // Changed from 'Full Name' to 'Name'
150
- expect(screen.getByRole('textbox', { name: /Name/i })).toHaveValue('Alice Johnson');
151
- }, { timeout: 2000 }); // Give slight buffer for network mock
152
-
153
- // Changed from 'Email Address' to 'Email'
154
- expect(screen.getByRole('textbox', { name: /Email/i })).toHaveValue('alice@example.com');
155
- });
156
- });