Skip to content

Commit a117c04

Browse files
committed
feat(Form Variants): implement TabbedForm and WizardForm components with section handling
1 parent 4d49f6a commit a117c04

File tree

7 files changed

+945
-19
lines changed

7 files changed

+945
-19
lines changed
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
});

packages/plugin-form/src/ObjectForm.tsx

Lines changed: 99 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ import React, { useEffect, useState, useCallback } from 'react';
1717
import type { ObjectFormSchema, FormField, FormSchema, DataSource } from '@object-ui/types';
1818
import { SchemaRenderer } from '@object-ui/react';
1919
import { mapFieldTypeToFormType, buildValidationRules, evaluateCondition, formatFileSize } from '@object-ui/fields';
20+
import { TabbedForm } from './TabbedForm';
21+
import { WizardForm } from './WizardForm';
22+
import { FormSection } from './FormSection';
2023

2124
export interface ObjectFormProps {
2225
/**
@@ -59,6 +62,56 @@ export const ObjectForm: React.FC<ObjectFormProps> = ({
5962
dataSource,
6063
}) => {
6164

65+
// Route to specialized form variant based on formType
66+
if (schema.formType === 'tabbed' && schema.sections?.length) {
67+
return (
68+
<TabbedForm
69+
schema={{
70+
...schema,
71+
formType: 'tabbed',
72+
sections: schema.sections.map(s => ({
73+
name: s.name,
74+
label: s.label,
75+
description: s.description,
76+
columns: s.columns,
77+
fields: s.fields,
78+
})),
79+
defaultTab: schema.defaultTab,
80+
tabPosition: schema.tabPosition,
81+
}}
82+
dataSource={dataSource}
83+
className={schema.className}
84+
/>
85+
);
86+
}
87+
88+
if (schema.formType === 'wizard' && schema.sections?.length) {
89+
return (
90+
<WizardForm
91+
schema={{
92+
...schema,
93+
formType: 'wizard',
94+
sections: schema.sections.map(s => ({
95+
name: s.name,
96+
label: s.label,
97+
description: s.description,
98+
columns: s.columns,
99+
fields: s.fields,
100+
})),
101+
allowSkip: schema.allowSkip,
102+
showStepIndicator: schema.showStepIndicator,
103+
nextText: schema.nextText,
104+
prevText: schema.prevText,
105+
onStepChange: schema.onStepChange,
106+
}}
107+
dataSource={dataSource}
108+
className={schema.className}
109+
/>
110+
);
111+
}
112+
113+
// Default: simple form (original implementation below)
114+
62115
const [objectSchema, setObjectSchema] = useState<any>(null);
63116
const [formFields, setFormFields] = useState<FormField[]>([]);
64117
const [initialData, setInitialData] = useState<any>(null);
@@ -375,12 +428,55 @@ export const ObjectForm: React.FC<ObjectFormProps> = ({
375428
// Convert to FormSchema
376429
// Note: FormSchema currently only supports 'vertical' and 'horizontal' layouts
377430
// Map 'grid' and 'inline' to 'vertical' as fallback
431+
const formLayout = (schema.layout === 'vertical' || schema.layout === 'horizontal')
432+
? schema.layout
433+
: 'vertical';
434+
435+
// If sections are provided for the simple form, render with FormSection grouping
436+
if (schema.sections?.length && (!schema.formType || schema.formType === 'simple')) {
437+
return (
438+
<div className="w-full space-y-6">
439+
{schema.sections.map((section, index) => {
440+
// Filter formFields to only include fields in this section
441+
const sectionFieldNames = section.fields.map(f => typeof f === 'string' ? f : f.name);
442+
const sectionFields = formFields.filter(f => sectionFieldNames.includes(f.name));
443+
444+
return (
445+
<FormSection
446+
key={section.name || section.label || index}
447+
label={section.label}
448+
description={section.description}
449+
collapsible={section.collapsible}
450+
collapsed={section.collapsed}
451+
columns={section.columns}
452+
>
453+
<SchemaRenderer
454+
schema={{
455+
type: 'form',
456+
fields: sectionFields,
457+
layout: formLayout,
458+
defaultValues: finalDefaultValues,
459+
// Only show action buttons after the last section
460+
showSubmit: index === schema.sections!.length - 1 && schema.showSubmit !== false && schema.mode !== 'view',
461+
showCancel: index === schema.sections!.length - 1 && schema.showCancel !== false,
462+
submitLabel: schema.submitText || (schema.mode === 'create' ? 'Create' : 'Update'),
463+
cancelLabel: schema.cancelText,
464+
onSubmit: handleSubmit,
465+
onCancel: handleCancel,
466+
} as FormSchema}
467+
/>
468+
</FormSection>
469+
);
470+
})}
471+
</div>
472+
);
473+
}
474+
475+
// Default flat form (no sections)
378476
const formSchema: FormSchema = {
379477
type: 'form',
380478
fields: formFields,
381-
layout: (schema.layout === 'vertical' || schema.layout === 'horizontal')
382-
? schema.layout
383-
: 'vertical',
479+
layout: formLayout,
384480
columns: schema.columns,
385481
submitLabel: schema.submitText || (schema.mode === 'create' ? 'Create' : 'Update'),
386482
cancelLabel: schema.cancelText,

0 commit comments

Comments
 (0)