@object-ui/plugin-form 0.5.0 → 3.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,219 @@
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
+ import { describe, it, expect, vi } from 'vitest';
10
+ import { render, screen } from '@testing-library/react';
11
+ import userEvent from '@testing-library/user-event';
12
+ import React from 'react';
13
+ import '@object-ui/components';
14
+ import '@object-ui/fields';
15
+ import { FormSection } from './FormSection';
16
+ import { ObjectForm } from './ObjectForm';
17
+
18
+ describe('FormSection', () => {
19
+ it('renders children without label', () => {
20
+ render(
21
+ <FormSection>
22
+ <div data-testid="child">Field content</div>
23
+ </FormSection>
24
+ );
25
+
26
+ expect(screen.getByTestId('child')).toBeInTheDocument();
27
+ });
28
+
29
+ it('renders with label and description', () => {
30
+ render(
31
+ <FormSection label="Contact Info" description="Enter your contact details">
32
+ <div>Field</div>
33
+ </FormSection>
34
+ );
35
+
36
+ expect(screen.getByText('Contact Info')).toBeInTheDocument();
37
+ expect(screen.getByText('Enter your contact details')).toBeInTheDocument();
38
+ });
39
+
40
+ it('supports collapsible behavior', async () => {
41
+ const user = userEvent.setup();
42
+
43
+ render(
44
+ <FormSection label="Details" collapsible>
45
+ <div data-testid="content">Collapsible content</div>
46
+ </FormSection>
47
+ );
48
+
49
+ // Content should be visible initially
50
+ expect(screen.getByTestId('content')).toBeInTheDocument();
51
+
52
+ // Click to collapse
53
+ await user.click(screen.getByText('Details'));
54
+ expect(screen.queryByTestId('content')).not.toBeInTheDocument();
55
+
56
+ // Click to expand
57
+ await user.click(screen.getByText('Details'));
58
+ expect(screen.getByTestId('content')).toBeInTheDocument();
59
+ });
60
+
61
+ it('starts collapsed when collapsed=true', () => {
62
+ render(
63
+ <FormSection label="Details" collapsible collapsed>
64
+ <div data-testid="content">Hidden content</div>
65
+ </FormSection>
66
+ );
67
+
68
+ expect(screen.queryByTestId('content')).not.toBeInTheDocument();
69
+ });
70
+
71
+ it('applies multi-column grid classes', () => {
72
+ const { container } = render(
73
+ <FormSection columns={2}>
74
+ <div>Field 1</div>
75
+ <div>Field 2</div>
76
+ </FormSection>
77
+ );
78
+
79
+ const grid = container.querySelector('.grid');
80
+ expect(grid).toBeInTheDocument();
81
+ expect(grid?.className).toContain('md:grid-cols-2');
82
+ });
83
+ });
84
+
85
+ describe('ObjectForm with formType', () => {
86
+ const mockDataSource = {
87
+ getObjectSchema: vi.fn().mockResolvedValue({
88
+ name: 'contacts',
89
+ fields: {
90
+ firstName: { label: 'First Name', type: 'text', required: true },
91
+ lastName: { label: 'Last Name', type: 'text', required: false },
92
+ email: { label: 'Email', type: 'email', required: true },
93
+ phone: { label: 'Phone', type: 'phone', required: false },
94
+ street: { label: 'Street', type: 'text', required: false },
95
+ city: { label: 'City', type: 'text', required: false },
96
+ }
97
+ }),
98
+ findOne: vi.fn().mockResolvedValue({}),
99
+ find: vi.fn().mockResolvedValue([]),
100
+ create: vi.fn().mockResolvedValue({ id: '1' }),
101
+ update: vi.fn().mockResolvedValue({ id: '1' }),
102
+ delete: vi.fn().mockResolvedValue(true),
103
+ };
104
+
105
+ it('renders simple form by default (no formType)', async () => {
106
+ render(
107
+ <ObjectForm
108
+ schema={{
109
+ type: 'object-form',
110
+ objectName: 'contacts',
111
+ mode: 'create',
112
+ fields: ['firstName', 'lastName'],
113
+ }}
114
+ dataSource={mockDataSource as any}
115
+ />
116
+ );
117
+
118
+ // Wait for schema fetch
119
+ await screen.findByText(/first name/i, {}, { timeout: 5000 });
120
+ expect(screen.getByText(/last name/i)).toBeInTheDocument();
121
+ });
122
+
123
+ it('renders simple form with sections', async () => {
124
+ render(
125
+ <ObjectForm
126
+ schema={{
127
+ type: 'object-form',
128
+ objectName: 'contacts',
129
+ mode: 'create',
130
+ formType: 'simple',
131
+ sections: [
132
+ {
133
+ label: 'Personal Info',
134
+ fields: ['firstName', 'lastName'],
135
+ columns: 2,
136
+ },
137
+ {
138
+ label: 'Contact Details',
139
+ fields: ['email', 'phone'],
140
+ columns: 2,
141
+ },
142
+ ],
143
+ }}
144
+ dataSource={mockDataSource as any}
145
+ />
146
+ );
147
+
148
+ // Wait for schema fetch and section rendering
149
+ await screen.findByText('Personal Info', {}, { timeout: 5000 });
150
+ expect(screen.getByText('Contact Details')).toBeInTheDocument();
151
+ });
152
+
153
+ it('renders tabbed form with sections as tabs', async () => {
154
+ render(
155
+ <ObjectForm
156
+ schema={{
157
+ type: 'object-form',
158
+ objectName: 'contacts',
159
+ mode: 'create',
160
+ formType: 'tabbed',
161
+ sections: [
162
+ {
163
+ name: 'personal',
164
+ label: 'Personal',
165
+ fields: ['firstName', 'lastName'],
166
+ },
167
+ {
168
+ name: 'contact',
169
+ label: 'Contact',
170
+ fields: ['email', 'phone'],
171
+ },
172
+ ],
173
+ }}
174
+ dataSource={mockDataSource as any}
175
+ />
176
+ );
177
+
178
+ // Wait for tabs to render
179
+ await screen.findByRole('tab', { name: 'Personal' }, { timeout: 5000 });
180
+ expect(screen.getByRole('tab', { name: 'Contact' })).toBeInTheDocument();
181
+ });
182
+
183
+ it('renders wizard form with step indicator', async () => {
184
+ render(
185
+ <ObjectForm
186
+ schema={{
187
+ type: 'object-form',
188
+ objectName: 'contacts',
189
+ mode: 'create',
190
+ formType: 'wizard',
191
+ sections: [
192
+ {
193
+ label: 'Step 1: Basics',
194
+ fields: ['firstName', 'lastName'],
195
+ },
196
+ {
197
+ label: 'Step 2: Contact',
198
+ fields: ['email', 'phone'],
199
+ },
200
+ {
201
+ label: 'Step 3: Address',
202
+ fields: ['street', 'city'],
203
+ },
204
+ ],
205
+ }}
206
+ dataSource={mockDataSource as any}
207
+ />
208
+ );
209
+
210
+ // Wait for loading to finish - wizard shows "Step X of Y" counter
211
+ await screen.findByText(/Step 1 of 3/, {}, { timeout: 5000 });
212
+
213
+ // Should show Next button (not Submit, since we're on step 1)
214
+ expect(screen.getByRole('button', { name: /next/i })).toBeInTheDocument();
215
+
216
+ // Step labels should be present (appears in both indicator and section header)
217
+ expect(screen.getAllByText('Step 1: Basics').length).toBeGreaterThanOrEqual(1);
218
+ });
219
+ });
@@ -0,0 +1,379 @@
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 } from 'react';
17
+ import type { FormField, DataSource } from '@object-ui/types';
18
+ import {
19
+ Dialog,
20
+ DialogContent,
21
+ DialogHeader,
22
+ DialogTitle,
23
+ DialogDescription,
24
+ cn,
25
+ } from '@object-ui/components';
26
+ import { FormSection } from './FormSection';
27
+ import { SchemaRenderer } from '@object-ui/react';
28
+ import { mapFieldTypeToFormType, buildValidationRules } from '@object-ui/fields';
29
+
30
+ export interface ModalFormSectionConfig {
31
+ name?: string;
32
+ label?: string;
33
+ description?: string;
34
+ columns?: 1 | 2 | 3 | 4;
35
+ fields: (string | FormField)[];
36
+ }
37
+
38
+ export interface ModalFormSchema {
39
+ type: 'object-form';
40
+ formType: 'modal';
41
+ objectName: string;
42
+ mode: 'create' | 'edit' | 'view';
43
+ recordId?: string | number;
44
+ title?: string;
45
+ description?: string;
46
+ sections?: ModalFormSectionConfig[];
47
+ fields?: string[];
48
+ customFields?: FormField[];
49
+
50
+ /**
51
+ * Whether the modal is open.
52
+ * @default true
53
+ */
54
+ open?: boolean;
55
+
56
+ /**
57
+ * Callback when open state changes.
58
+ */
59
+ onOpenChange?: (open: boolean) => void;
60
+
61
+ /**
62
+ * Modal dialog size.
63
+ * @default 'default'
64
+ */
65
+ modalSize?: 'sm' | 'default' | 'lg' | 'xl' | 'full';
66
+
67
+ /**
68
+ * Whether to show a close button in the header.
69
+ * @default true
70
+ */
71
+ modalCloseButton?: boolean;
72
+
73
+ // Common form props
74
+ showSubmit?: boolean;
75
+ submitText?: string;
76
+ showCancel?: boolean;
77
+ cancelText?: string;
78
+ initialValues?: Record<string, any>;
79
+ initialData?: Record<string, any>;
80
+ readOnly?: boolean;
81
+ layout?: 'vertical' | 'horizontal';
82
+ columns?: number;
83
+ onSuccess?: (data: any) => void | Promise<void>;
84
+ onError?: (error: Error) => void;
85
+ onCancel?: () => void;
86
+ className?: string;
87
+ }
88
+
89
+ export interface ModalFormProps {
90
+ schema: ModalFormSchema;
91
+ dataSource?: DataSource;
92
+ className?: string;
93
+ }
94
+
95
+ /** Size class map for the dialog content */
96
+ const modalSizeClasses: Record<string, string> = {
97
+ sm: 'max-w-sm',
98
+ default: 'max-w-lg',
99
+ lg: 'max-w-2xl',
100
+ xl: 'max-w-4xl',
101
+ full: 'max-w-[95vw] w-full',
102
+ };
103
+
104
+ export const ModalForm: React.FC<ModalFormProps> = ({
105
+ schema,
106
+ dataSource,
107
+ className,
108
+ }) => {
109
+ const [objectSchema, setObjectSchema] = useState<any>(null);
110
+ const [formFields, setFormFields] = useState<FormField[]>([]);
111
+ const [formData, setFormData] = useState<Record<string, any>>({});
112
+ const [loading, setLoading] = useState(true);
113
+ const [error, setError] = useState<Error | null>(null);
114
+
115
+ const isOpen = schema.open !== false;
116
+ const sizeClass = modalSizeClasses[schema.modalSize || 'default'] || modalSizeClasses.default;
117
+
118
+ // Fetch object schema
119
+ useEffect(() => {
120
+ const fetchSchema = async () => {
121
+ if (!dataSource) {
122
+ setLoading(false);
123
+ return;
124
+ }
125
+ try {
126
+ const data = await dataSource.getObjectSchema(schema.objectName);
127
+ setObjectSchema(data);
128
+ } catch (err) {
129
+ setError(err as Error);
130
+ setLoading(false);
131
+ }
132
+ };
133
+ fetchSchema();
134
+ }, [schema.objectName, dataSource]);
135
+
136
+ // Fetch initial data
137
+ useEffect(() => {
138
+ const fetchData = async () => {
139
+ if (schema.mode === 'create' || !schema.recordId) {
140
+ setFormData(schema.initialData || schema.initialValues || {});
141
+ setLoading(false);
142
+ return;
143
+ }
144
+
145
+ if (!dataSource) {
146
+ setFormData(schema.initialData || schema.initialValues || {});
147
+ setLoading(false);
148
+ return;
149
+ }
150
+
151
+ try {
152
+ const data = await dataSource.findOne(schema.objectName, schema.recordId);
153
+ setFormData(data || {});
154
+ } catch (err) {
155
+ setError(err as Error);
156
+ } finally {
157
+ setLoading(false);
158
+ }
159
+ };
160
+
161
+ if (objectSchema || !dataSource) {
162
+ fetchData();
163
+ }
164
+ }, [objectSchema, schema.mode, schema.recordId, schema.initialData, schema.initialValues, dataSource, schema.objectName]);
165
+
166
+ // Build form fields from section config
167
+ const buildSectionFields = useCallback((section: ModalFormSectionConfig): FormField[] => {
168
+ const fields: FormField[] = [];
169
+
170
+ for (const fieldDef of section.fields) {
171
+ const fieldName = typeof fieldDef === 'string' ? fieldDef : fieldDef.name;
172
+
173
+ if (typeof fieldDef === 'object') {
174
+ fields.push(fieldDef);
175
+ } else if (objectSchema?.fields?.[fieldName]) {
176
+ const field = objectSchema.fields[fieldName];
177
+ fields.push({
178
+ name: fieldName,
179
+ label: field.label || fieldName,
180
+ type: mapFieldTypeToFormType(field.type),
181
+ required: field.required || false,
182
+ disabled: schema.readOnly || schema.mode === 'view' || field.readonly,
183
+ placeholder: field.placeholder,
184
+ description: field.help || field.description,
185
+ validation: buildValidationRules(field),
186
+ field: field,
187
+ options: field.options,
188
+ multiple: field.multiple,
189
+ });
190
+ } else {
191
+ fields.push({
192
+ name: fieldName,
193
+ label: fieldName,
194
+ type: 'input',
195
+ });
196
+ }
197
+ }
198
+
199
+ return fields;
200
+ }, [objectSchema, schema.readOnly, schema.mode]);
201
+
202
+ // Build fields from flat field list (when no sections)
203
+ useEffect(() => {
204
+ if (!objectSchema && dataSource) return;
205
+
206
+ if (schema.customFields?.length) {
207
+ setFormFields(schema.customFields);
208
+ setLoading(false);
209
+ return;
210
+ }
211
+
212
+ if (schema.sections?.length) {
213
+ setLoading(false);
214
+ return;
215
+ }
216
+
217
+ if (!objectSchema) return;
218
+
219
+ const fieldsToShow = schema.fields || Object.keys(objectSchema.fields || {});
220
+ const generated: FormField[] = [];
221
+
222
+ for (const fieldName of fieldsToShow) {
223
+ const name = typeof fieldName === 'string' ? fieldName : (fieldName as any).name;
224
+ if (!name) continue;
225
+ const field = objectSchema.fields?.[name];
226
+ if (!field) continue;
227
+
228
+ generated.push({
229
+ name,
230
+ label: field.label || name,
231
+ type: mapFieldTypeToFormType(field.type),
232
+ required: field.required || false,
233
+ disabled: schema.readOnly || schema.mode === 'view' || field.readonly,
234
+ placeholder: field.placeholder,
235
+ description: field.help || field.description,
236
+ validation: buildValidationRules(field),
237
+ field: field,
238
+ options: field.options,
239
+ multiple: field.multiple,
240
+ });
241
+ }
242
+
243
+ setFormFields(generated);
244
+ setLoading(false);
245
+ }, [objectSchema, schema.fields, schema.customFields, schema.sections, schema.readOnly, schema.mode, dataSource]);
246
+
247
+ // Handle form submission
248
+ const handleSubmit = useCallback(async (data: Record<string, any>) => {
249
+ if (!dataSource) {
250
+ if (schema.onSuccess) {
251
+ await schema.onSuccess(data);
252
+ }
253
+ // Close modal on success
254
+ schema.onOpenChange?.(false);
255
+ return data;
256
+ }
257
+
258
+ try {
259
+ let result;
260
+ if (schema.mode === 'create') {
261
+ result = await dataSource.create(schema.objectName, data);
262
+ } else if (schema.mode === 'edit' && schema.recordId) {
263
+ result = await dataSource.update(schema.objectName, schema.recordId, data);
264
+ }
265
+ if (schema.onSuccess) {
266
+ await schema.onSuccess(result);
267
+ }
268
+ // Close modal on success
269
+ schema.onOpenChange?.(false);
270
+ return result;
271
+ } catch (err) {
272
+ if (schema.onError) {
273
+ schema.onError(err as Error);
274
+ }
275
+ throw err;
276
+ }
277
+ }, [schema, dataSource]);
278
+
279
+ // Handle cancel
280
+ const handleCancel = useCallback(() => {
281
+ if (schema.onCancel) {
282
+ schema.onCancel();
283
+ }
284
+ // Close modal on cancel
285
+ schema.onOpenChange?.(false);
286
+ }, [schema]);
287
+
288
+ const formLayout = (schema.layout === 'vertical' || schema.layout === 'horizontal')
289
+ ? schema.layout
290
+ : 'vertical';
291
+
292
+ // Build base form schema
293
+ const baseFormSchema = {
294
+ type: 'form' as const,
295
+ layout: formLayout,
296
+ defaultValues: formData,
297
+ submitLabel: schema.submitText || (schema.mode === 'create' ? 'Create' : 'Update'),
298
+ cancelLabel: schema.cancelText,
299
+ showSubmit: schema.showSubmit !== false && schema.mode !== 'view',
300
+ showCancel: schema.showCancel !== false,
301
+ onSubmit: handleSubmit,
302
+ onCancel: handleCancel,
303
+ };
304
+
305
+ const renderContent = () => {
306
+ if (error) {
307
+ return (
308
+ <div className="p-4 border border-red-300 bg-red-50 rounded-md">
309
+ <h3 className="text-red-800 font-semibold">Error loading form</h3>
310
+ <p className="text-red-600 text-sm mt-1">{error.message}</p>
311
+ </div>
312
+ );
313
+ }
314
+
315
+ if (loading) {
316
+ return (
317
+ <div className="p-8 text-center">
318
+ <div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900"></div>
319
+ <p className="mt-2 text-sm text-gray-600">Loading form...</p>
320
+ </div>
321
+ );
322
+ }
323
+
324
+ // Sections layout
325
+ if (schema.sections?.length) {
326
+ return (
327
+ <div className="space-y-6">
328
+ {schema.sections.map((section, index) => (
329
+ <FormSection
330
+ key={section.name || section.label || index}
331
+ label={section.label}
332
+ description={section.description}
333
+ columns={section.columns || 1}
334
+ >
335
+ <SchemaRenderer
336
+ schema={{
337
+ ...baseFormSchema,
338
+ fields: buildSectionFields(section),
339
+ showSubmit: index === schema.sections!.length - 1 && baseFormSchema.showSubmit,
340
+ showCancel: index === schema.sections!.length - 1 && baseFormSchema.showCancel,
341
+ }}
342
+ />
343
+ </FormSection>
344
+ ))}
345
+ </div>
346
+ );
347
+ }
348
+
349
+ // Flat fields layout
350
+ return (
351
+ <SchemaRenderer
352
+ schema={{
353
+ ...baseFormSchema,
354
+ fields: formFields,
355
+ columns: schema.columns,
356
+ }}
357
+ />
358
+ );
359
+ };
360
+
361
+ return (
362
+ <Dialog open={isOpen} onOpenChange={schema.onOpenChange}>
363
+ <DialogContent className={cn(sizeClass, 'max-h-[90vh] overflow-y-auto', className, schema.className)}>
364
+ {(schema.title || schema.description) && (
365
+ <DialogHeader>
366
+ {schema.title && <DialogTitle>{schema.title}</DialogTitle>}
367
+ {schema.description && <DialogDescription>{schema.description}</DialogDescription>}
368
+ </DialogHeader>
369
+ )}
370
+
371
+ <div className="py-2">
372
+ {renderContent()}
373
+ </div>
374
+ </DialogContent>
375
+ </Dialog>
376
+ );
377
+ };
378
+
379
+ export default ModalForm;
@@ -28,6 +28,26 @@ const mockRecord = {
28
28
  // --- MSW Setup ---
29
29
 
30
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
+
31
51
  // OPTIONS handler for CORS preflight
32
52
  http.options(`${BASE_URL}/*`, () => {
33
53
  return new HttpResponse(null, {
@@ -45,7 +65,14 @@ const handlers = [
45
65
  return HttpResponse.json({ status: 'ok', version: '1.0.0' });
46
66
  }),
47
67
 
48
- // Mock Schema Fetch: GET /api/v1/meta/object/:name
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
+ }),
49
76
  http.get(`${BASE_URL}/api/v1/meta/object/:name`, ({ params }) => {
50
77
  const { name } = params;
51
78
  if (name === 'contact') {
@@ -58,7 +85,7 @@ const handlers = [
58
85
  http.get(`${BASE_URL}/api/v1/data/:object/:id`, ({ params }) => {
59
86
  const { object, id } = params;
60
87
  if (object === 'contact' && id === '1') {
61
- return HttpResponse.json(mockRecord);
88
+ return HttpResponse.json({ record: mockRecord });
62
89
  }
63
90
  return new HttpResponse(null, { status: 404 });
64
91
  })