Skip to content

Commit 560a7a0

Browse files
authored
Merge pull request #613 from objectstack-ai/copilot/optimize-default-layout
2 parents 09714cc + b09e8c2 commit 560a7a0

6 files changed

Lines changed: 892 additions & 25 deletions

File tree

ROADMAP.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,10 @@ Each plugin view must work seamlessly from 320px (small phone) to 2560px (ultraw
395395
- [x] Increase touch targets for all form controls (min 44×44px)
396396
- [x] Optimize select/dropdown fields for mobile (bottom sheet pattern on phones)
397397
- [x] Ensure date pickers and multi-select fields are mobile-friendly
398+
- [x] Auto-Layout: infer optimal columns from field count (≤3 → 1 col, ≥4 → 2 cols)
399+
- [x] Auto-Layout: smart colSpan for wide fields (textarea/markdown/html/grid → full row)
400+
- [x] Auto-Layout: filter auto-generated fields (formula/summary/auto_number) in create mode
401+
- [x] Auto-Layout: user configuration always takes priority over inferred defaults
398402

399403
##### ObjectDashboard (`plugin-dashboard`)
400404
- [x] Implement responsive grid: `grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4`

packages/plugin-form/src/ObjectForm.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { SplitForm } from './SplitForm';
2323
import { DrawerForm } from './DrawerForm';
2424
import { ModalForm } from './ModalForm';
2525
import { FormSection } from './FormSection';
26+
import { applyAutoLayout } from './autoLayout';
2627

2728
export interface ObjectFormProps {
2829
/**
@@ -575,12 +576,18 @@ const SimpleObjectForm: React.FC<ObjectFormProps> = ({
575576
);
576577
}
577578

579+
// Apply auto-layout: infer columns and colSpan when not explicitly configured
580+
const hasSections = schema.sections?.length;
581+
const autoLayoutResult = !hasSections
582+
? applyAutoLayout(formFields, objectSchema, schema.columns, schema.mode)
583+
: { fields: formFields, columns: schema.columns };
584+
578585
// Default flat form (no sections)
579586
const formSchema: FormSchema = {
580587
type: 'form',
581-
fields: formFields,
588+
fields: autoLayoutResult.fields,
582589
layout: formLayout,
583-
columns: schema.columns,
590+
columns: autoLayoutResult.columns,
584591
submitLabel: schema.submitText || (schema.mode === 'create' ? 'Create' : 'Update'),
585592
cancelLabel: schema.cancelText,
586593
showSubmit: schema.showSubmit !== false && schema.mode !== 'view',
Lines changed: 321 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,321 @@
1+
import { describe, it, expect } from 'vitest';
2+
import {
3+
isWideFieldType,
4+
isAutoGeneratedFieldType,
5+
inferColumns,
6+
applyAutoColSpan,
7+
filterCreateModeFields,
8+
applyAutoLayout,
9+
} from '../autoLayout';
10+
import type { FormField } from '@object-ui/types';
11+
12+
describe('autoLayout', () => {
13+
describe('isWideFieldType', () => {
14+
it('returns true for wide form field types', () => {
15+
expect(isWideFieldType('field:textarea')).toBe(true);
16+
expect(isWideFieldType('field:markdown')).toBe(true);
17+
expect(isWideFieldType('field:html')).toBe(true);
18+
expect(isWideFieldType('field:grid')).toBe(true);
19+
expect(isWideFieldType('field:rich-text')).toBe(true);
20+
});
21+
22+
it('returns true for raw wide field types', () => {
23+
expect(isWideFieldType('textarea')).toBe(true);
24+
expect(isWideFieldType('markdown')).toBe(true);
25+
expect(isWideFieldType('html')).toBe(true);
26+
expect(isWideFieldType('grid')).toBe(true);
27+
expect(isWideFieldType('rich-text')).toBe(true);
28+
});
29+
30+
it('returns false for narrow field types', () => {
31+
expect(isWideFieldType('field:text')).toBe(false);
32+
expect(isWideFieldType('field:number')).toBe(false);
33+
expect(isWideFieldType('field:select')).toBe(false);
34+
expect(isWideFieldType('text')).toBe(false);
35+
expect(isWideFieldType('boolean')).toBe(false);
36+
});
37+
});
38+
39+
describe('isAutoGeneratedFieldType', () => {
40+
it('returns true for auto-generated types', () => {
41+
expect(isAutoGeneratedFieldType('formula')).toBe(true);
42+
expect(isAutoGeneratedFieldType('summary')).toBe(true);
43+
expect(isAutoGeneratedFieldType('auto_number')).toBe(true);
44+
expect(isAutoGeneratedFieldType('autonumber')).toBe(true);
45+
});
46+
47+
it('returns false for user-editable types', () => {
48+
expect(isAutoGeneratedFieldType('text')).toBe(false);
49+
expect(isAutoGeneratedFieldType('number')).toBe(false);
50+
expect(isAutoGeneratedFieldType('select')).toBe(false);
51+
});
52+
});
53+
54+
describe('inferColumns', () => {
55+
it('returns 1 column for 0 fields', () => {
56+
expect(inferColumns(0)).toBe(1);
57+
});
58+
59+
it('returns 1 column for 1-3 fields', () => {
60+
expect(inferColumns(1)).toBe(1);
61+
expect(inferColumns(2)).toBe(1);
62+
expect(inferColumns(3)).toBe(1);
63+
});
64+
65+
it('returns 2 columns for 4+ fields', () => {
66+
expect(inferColumns(4)).toBe(2);
67+
expect(inferColumns(8)).toBe(2);
68+
expect(inferColumns(20)).toBe(2);
69+
});
70+
});
71+
72+
describe('applyAutoColSpan', () => {
73+
it('returns fields unchanged when columns is 1', () => {
74+
const fields: FormField[] = [
75+
{ name: 'a', label: 'A', type: 'field:textarea' },
76+
];
77+
const result = applyAutoColSpan(fields, 1);
78+
expect(result).toEqual(fields);
79+
});
80+
81+
it('sets colSpan for wide fields in multi-column layout', () => {
82+
const fields: FormField[] = [
83+
{ name: 'name', label: 'Name', type: 'field:text' },
84+
{ name: 'desc', label: 'Description', type: 'field:textarea' },
85+
{ name: 'notes', label: 'Notes', type: 'field:markdown' },
86+
];
87+
const result = applyAutoColSpan(fields, 2);
88+
89+
expect(result[0].colSpan).toBeUndefined();
90+
expect(result[1].colSpan).toBe(2);
91+
expect(result[2].colSpan).toBe(2);
92+
});
93+
94+
it('does not override user-defined colSpan', () => {
95+
const fields: FormField[] = [
96+
{ name: 'desc', label: 'Description', type: 'field:textarea', colSpan: 1 },
97+
];
98+
const result = applyAutoColSpan(fields, 2);
99+
expect(result[0].colSpan).toBe(1);
100+
});
101+
102+
it('does not mutate original fields', () => {
103+
const fields: FormField[] = [
104+
{ name: 'desc', label: 'Description', type: 'field:textarea' },
105+
];
106+
const result = applyAutoColSpan(fields, 2);
107+
expect(fields[0].colSpan).toBeUndefined();
108+
expect(result[0].colSpan).toBe(2);
109+
});
110+
});
111+
112+
describe('filterCreateModeFields', () => {
113+
const objectSchema = {
114+
name: 'test',
115+
fields: {
116+
name: { type: 'text', label: 'Name' },
117+
total: { type: 'formula', label: 'Total' },
118+
count: { type: 'summary', label: 'Count' },
119+
record_no: { type: 'auto_number', label: 'Record #' },
120+
email: { type: 'email', label: 'Email' },
121+
},
122+
};
123+
124+
it('filters out formula, summary, and auto_number fields', () => {
125+
const fields: FormField[] = [
126+
{ name: 'name', label: 'Name', type: 'field:text' },
127+
{ name: 'total', label: 'Total', type: 'field:text' },
128+
{ name: 'count', label: 'Count', type: 'field:text' },
129+
{ name: 'record_no', label: 'Record #', type: 'field:text' },
130+
{ name: 'email', label: 'Email', type: 'field:text' },
131+
];
132+
133+
const result = filterCreateModeFields(fields, objectSchema);
134+
135+
expect(result).toHaveLength(2);
136+
expect(result.map(f => f.name)).toEqual(['name', 'email']);
137+
});
138+
139+
it('keeps all fields when objectSchema has no fields metadata', () => {
140+
const fields: FormField[] = [
141+
{ name: 'name', label: 'Name', type: 'field:text' },
142+
{ name: 'total', label: 'Total', type: 'field:text' },
143+
];
144+
145+
const result = filterCreateModeFields(fields, { name: 'test' });
146+
expect(result).toHaveLength(2);
147+
});
148+
149+
it('keeps custom fields not in object schema', () => {
150+
const fields: FormField[] = [
151+
{ name: 'custom_field', label: 'Custom', type: 'field:text' },
152+
];
153+
154+
const result = filterCreateModeFields(fields, objectSchema);
155+
expect(result).toHaveLength(1);
156+
});
157+
});
158+
159+
describe('applyAutoLayout', () => {
160+
it('infers 1 column for 3 fields', () => {
161+
const fields: FormField[] = [
162+
{ name: 'a', label: 'A', type: 'field:text' },
163+
{ name: 'b', label: 'B', type: 'field:number' },
164+
{ name: 'c', label: 'C', type: 'field:select' },
165+
];
166+
167+
const result = applyAutoLayout(fields, null, undefined, 'create');
168+
expect(result.columns).toBe(1);
169+
expect(result.fields).toHaveLength(3);
170+
});
171+
172+
it('infers 2 columns for 5 fields', () => {
173+
const fields: FormField[] = [
174+
{ name: 'a', label: 'A', type: 'field:text' },
175+
{ name: 'b', label: 'B', type: 'field:text' },
176+
{ name: 'c', label: 'C', type: 'field:text' },
177+
{ name: 'd', label: 'D', type: 'field:text' },
178+
{ name: 'e', label: 'E', type: 'field:text' },
179+
];
180+
181+
const result = applyAutoLayout(fields, null, undefined, 'edit');
182+
expect(result.columns).toBe(2);
183+
});
184+
185+
it('applies colSpan to wide fields when columns > 1', () => {
186+
const fields: FormField[] = [
187+
{ name: 'a', label: 'A', type: 'field:text' },
188+
{ name: 'b', label: 'B', type: 'field:text' },
189+
{ name: 'c', label: 'C', type: 'field:text' },
190+
{ name: 'd', label: 'D', type: 'field:textarea' },
191+
];
192+
193+
const result = applyAutoLayout(fields, null, undefined, 'edit');
194+
expect(result.columns).toBe(2);
195+
expect(result.fields[3].colSpan).toBe(2);
196+
// Regular fields should not have colSpan
197+
expect(result.fields[0].colSpan).toBeUndefined();
198+
});
199+
200+
it('filters auto-generated fields in create mode', () => {
201+
const fields: FormField[] = [
202+
{ name: 'name', label: 'Name', type: 'field:text' },
203+
{ name: 'total', label: 'Total', type: 'field:text' },
204+
{ name: 'email', label: 'Email', type: 'field:text' },
205+
{ name: 'count', label: 'Count', type: 'field:text' },
206+
];
207+
208+
const objectSchema = {
209+
name: 'test',
210+
fields: {
211+
name: { type: 'text' },
212+
total: { type: 'formula' },
213+
email: { type: 'email' },
214+
count: { type: 'summary' },
215+
},
216+
};
217+
218+
const result = applyAutoLayout(fields, objectSchema, undefined, 'create');
219+
expect(result.fields.map(f => f.name)).toEqual(['name', 'email']);
220+
expect(result.columns).toBe(1); // Only 2 fields after filtering → 1 column
221+
});
222+
223+
it('does not filter auto-generated fields in edit mode', () => {
224+
const fields: FormField[] = [
225+
{ name: 'name', label: 'Name', type: 'field:text' },
226+
{ name: 'total', label: 'Total', type: 'field:text' },
227+
];
228+
229+
const objectSchema = {
230+
name: 'test',
231+
fields: {
232+
name: { type: 'text' },
233+
total: { type: 'formula' },
234+
},
235+
};
236+
237+
const result = applyAutoLayout(fields, objectSchema, undefined, 'edit');
238+
expect(result.fields).toHaveLength(2);
239+
});
240+
241+
it('respects user-provided columns', () => {
242+
const fields: FormField[] = [
243+
{ name: 'a', label: 'A', type: 'field:text' },
244+
{ name: 'b', label: 'B', type: 'field:text' },
245+
];
246+
247+
const result = applyAutoLayout(fields, null, 3, 'edit');
248+
expect(result.columns).toBe(3);
249+
});
250+
251+
it('applies auto colSpan even with user-provided columns', () => {
252+
const fields: FormField[] = [
253+
{ name: 'a', label: 'A', type: 'field:text' },
254+
{ name: 'b', label: 'B', type: 'field:textarea' },
255+
];
256+
257+
const result = applyAutoLayout(fields, null, 3, 'edit');
258+
expect(result.columns).toBe(3);
259+
expect(result.fields[1].colSpan).toBe(3);
260+
});
261+
262+
it('does not override user-defined colSpan on fields', () => {
263+
const fields: FormField[] = [
264+
{ name: 'a', label: 'A', type: 'field:text' },
265+
{ name: 'b', label: 'B', type: 'field:textarea', colSpan: 1 },
266+
{ name: 'c', label: 'C', type: 'field:text' },
267+
{ name: 'd', label: 'D', type: 'field:text' },
268+
];
269+
270+
const result = applyAutoLayout(fields, null, undefined, 'edit');
271+
expect(result.columns).toBe(2);
272+
expect(result.fields[1].colSpan).toBe(1); // User override preserved
273+
});
274+
275+
it('does not mutate original fields array', () => {
276+
const fields: FormField[] = [
277+
{ name: 'a', label: 'A', type: 'field:text' },
278+
{ name: 'b', label: 'B', type: 'field:textarea' },
279+
{ name: 'c', label: 'C', type: 'field:text' },
280+
{ name: 'd', label: 'D', type: 'field:text' },
281+
];
282+
283+
const result = applyAutoLayout(fields, null, undefined, 'edit');
284+
expect(fields[1].colSpan).toBeUndefined();
285+
expect(result.fields[1].colSpan).toBe(2);
286+
});
287+
288+
it('handles empty fields array', () => {
289+
const result = applyAutoLayout([], null, undefined, 'create');
290+
expect(result.fields).toEqual([]);
291+
expect(result.columns).toBe(1);
292+
});
293+
294+
it('infers columns based on field count after create-mode filtering', () => {
295+
// Start with 5 fields, but 3 are auto-generated → 2 remain → 1 column
296+
const fields: FormField[] = [
297+
{ name: 'name', label: 'Name', type: 'field:text' },
298+
{ name: 'f1', label: 'F1', type: 'field:text' },
299+
{ name: 'f2', label: 'F2', type: 'field:text' },
300+
{ name: 'f3', label: 'F3', type: 'field:text' },
301+
{ name: 'f4', label: 'F4', type: 'field:text' },
302+
];
303+
304+
const objectSchema = {
305+
name: 'test',
306+
fields: {
307+
name: { type: 'text' },
308+
f1: { type: 'formula' },
309+
f2: { type: 'summary' },
310+
f3: { type: 'auto_number' },
311+
f4: { type: 'text' },
312+
},
313+
};
314+
315+
const result = applyAutoLayout(fields, objectSchema, undefined, 'create');
316+
// Only 'name' and 'f4' remain after filtering
317+
expect(result.fields).toHaveLength(2);
318+
expect(result.columns).toBe(1);
319+
});
320+
});
321+
});

0 commit comments

Comments
 (0)