@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.
- package/.turbo/turbo-build.log +8 -6
- package/CHANGELOG.md +15 -0
- package/dist/index.js +1388 -283
- package/dist/index.umd.cjs +2 -2
- package/dist/packages/plugin-form/src/DrawerForm.d.ts +61 -0
- package/dist/packages/plugin-form/src/FormSection.d.ts +49 -0
- package/dist/packages/plugin-form/src/FormVariants.test.d.ts +0 -0
- package/dist/packages/plugin-form/src/ModalForm.d.ts +60 -0
- package/dist/packages/plugin-form/src/SplitForm.d.ts +50 -0
- package/dist/packages/plugin-form/src/TabbedForm.d.ts +123 -0
- package/dist/packages/plugin-form/src/WizardForm.d.ts +112 -0
- package/dist/packages/plugin-form/src/__tests__/NewVariants.test.d.ts +8 -0
- package/dist/packages/plugin-form/src/index.d.ts +12 -0
- package/package.json +8 -8
- package/src/DrawerForm.tsx +385 -0
- package/src/FormSection.tsx +144 -0
- package/src/FormVariants.test.tsx +219 -0
- package/src/ModalForm.tsx +379 -0
- package/src/ObjectForm.msw.test.tsx +29 -2
- package/src/ObjectForm.tsx +204 -6
- package/src/SplitForm.tsx +299 -0
- package/src/TabbedForm.tsx +394 -0
- package/src/WizardForm.tsx +501 -0
- package/src/__tests__/NewVariants.test.tsx +488 -0
- package/src/index.tsx +60 -3
- package/vitest.config.ts +12 -0
- package/vitest.setup.ts +1 -0
|
@@ -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
|
})
|