Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


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

##### ObjectDashboard (`plugin-dashboard`)
- [x] Implement responsive grid: `grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4`
Expand Down
11 changes: 9 additions & 2 deletions packages/plugin-form/src/ObjectForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { SplitForm } from './SplitForm';
import { DrawerForm } from './DrawerForm';
import { ModalForm } from './ModalForm';
import { FormSection } from './FormSection';
import { applyAutoLayout } from './autoLayout';

export interface ObjectFormProps {
/**
Expand Down Expand Up @@ -575,12 +576,18 @@ const SimpleObjectForm: React.FC<ObjectFormProps> = ({
);
}

// Apply auto-layout: infer columns and colSpan when not explicitly configured
const hasSections = schema.sections?.length;
const autoLayoutResult = !hasSections
? applyAutoLayout(formFields, objectSchema, schema.columns, schema.mode)
: { fields: formFields, columns: schema.columns };
Comment on lines +579 to +583
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Auto-layout (including create-mode filtering of formula/summary/auto_number fields and auto colSpan for wide fields) is completely skipped when sections are defined. This creates inconsistent behavior: forms without sections get intelligent auto-generated field filtering in create mode, but forms with sections do not. Consider applying at least the filterCreateModeFields logic before the sections check to ensure consistent behavior across all form variants.

Copilot uses AI. Check for mistakes.

// Default flat form (no sections)
const formSchema: FormSchema = {
type: 'form',
fields: formFields,
fields: autoLayoutResult.fields,
layout: formLayout,
columns: schema.columns,
columns: autoLayoutResult.columns,
submitLabel: schema.submitText || (schema.mode === 'create' ? 'Create' : 'Update'),
cancelLabel: schema.cancelText,
showSubmit: schema.showSubmit !== false && schema.mode !== 'view',
Expand Down
321 changes: 321 additions & 0 deletions packages/plugin-form/src/__tests__/autoLayout.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,321 @@
import { describe, it, expect } from 'vitest';
import {
isWideFieldType,
isAutoGeneratedFieldType,
inferColumns,
applyAutoColSpan,
filterCreateModeFields,
applyAutoLayout,
} from '../autoLayout';
import type { FormField } from '@object-ui/types';

describe('autoLayout', () => {
describe('isWideFieldType', () => {
it('returns true for wide form field types', () => {
expect(isWideFieldType('field:textarea')).toBe(true);
expect(isWideFieldType('field:markdown')).toBe(true);
expect(isWideFieldType('field:html')).toBe(true);
expect(isWideFieldType('field:grid')).toBe(true);
expect(isWideFieldType('field:rich-text')).toBe(true);
});

it('returns true for raw wide field types', () => {
expect(isWideFieldType('textarea')).toBe(true);
expect(isWideFieldType('markdown')).toBe(true);
expect(isWideFieldType('html')).toBe(true);
expect(isWideFieldType('grid')).toBe(true);
expect(isWideFieldType('rich-text')).toBe(true);
});

it('returns false for narrow field types', () => {
expect(isWideFieldType('field:text')).toBe(false);
expect(isWideFieldType('field:number')).toBe(false);
expect(isWideFieldType('field:select')).toBe(false);
expect(isWideFieldType('text')).toBe(false);
expect(isWideFieldType('boolean')).toBe(false);
});
});

describe('isAutoGeneratedFieldType', () => {
it('returns true for auto-generated types', () => {
expect(isAutoGeneratedFieldType('formula')).toBe(true);
expect(isAutoGeneratedFieldType('summary')).toBe(true);
expect(isAutoGeneratedFieldType('auto_number')).toBe(true);
expect(isAutoGeneratedFieldType('autonumber')).toBe(true);
});

it('returns false for user-editable types', () => {
expect(isAutoGeneratedFieldType('text')).toBe(false);
expect(isAutoGeneratedFieldType('number')).toBe(false);
expect(isAutoGeneratedFieldType('select')).toBe(false);
});
});

describe('inferColumns', () => {
it('returns 1 column for 0 fields', () => {
expect(inferColumns(0)).toBe(1);
});

it('returns 1 column for 1-3 fields', () => {
expect(inferColumns(1)).toBe(1);
expect(inferColumns(2)).toBe(1);
expect(inferColumns(3)).toBe(1);
});

it('returns 2 columns for 4+ fields', () => {
expect(inferColumns(4)).toBe(2);
expect(inferColumns(8)).toBe(2);
expect(inferColumns(20)).toBe(2);
});
});

describe('applyAutoColSpan', () => {
it('returns fields unchanged when columns is 1', () => {
const fields: FormField[] = [
{ name: 'a', label: 'A', type: 'field:textarea' },
];
const result = applyAutoColSpan(fields, 1);
expect(result).toEqual(fields);
});

it('sets colSpan for wide fields in multi-column layout', () => {
const fields: FormField[] = [
{ name: 'name', label: 'Name', type: 'field:text' },
{ name: 'desc', label: 'Description', type: 'field:textarea' },
{ name: 'notes', label: 'Notes', type: 'field:markdown' },
];
const result = applyAutoColSpan(fields, 2);

expect(result[0].colSpan).toBeUndefined();
expect(result[1].colSpan).toBe(2);
expect(result[2].colSpan).toBe(2);
});

it('does not override user-defined colSpan', () => {
const fields: FormField[] = [
{ name: 'desc', label: 'Description', type: 'field:textarea', colSpan: 1 },
];
const result = applyAutoColSpan(fields, 2);
expect(result[0].colSpan).toBe(1);
});

it('does not mutate original fields', () => {
const fields: FormField[] = [
{ name: 'desc', label: 'Description', type: 'field:textarea' },
];
const result = applyAutoColSpan(fields, 2);
expect(fields[0].colSpan).toBeUndefined();
expect(result[0].colSpan).toBe(2);
});
});

describe('filterCreateModeFields', () => {
const objectSchema = {
name: 'test',
fields: {
name: { type: 'text', label: 'Name' },
total: { type: 'formula', label: 'Total' },
count: { type: 'summary', label: 'Count' },
record_no: { type: 'auto_number', label: 'Record #' },
email: { type: 'email', label: 'Email' },
},
};

it('filters out formula, summary, and auto_number fields', () => {
const fields: FormField[] = [
{ name: 'name', label: 'Name', type: 'field:text' },
{ name: 'total', label: 'Total', type: 'field:text' },
{ name: 'count', label: 'Count', type: 'field:text' },
{ name: 'record_no', label: 'Record #', type: 'field:text' },
{ name: 'email', label: 'Email', type: 'field:text' },
];

const result = filterCreateModeFields(fields, objectSchema);

expect(result).toHaveLength(2);
expect(result.map(f => f.name)).toEqual(['name', 'email']);
});

it('keeps all fields when objectSchema has no fields metadata', () => {
const fields: FormField[] = [
{ name: 'name', label: 'Name', type: 'field:text' },
{ name: 'total', label: 'Total', type: 'field:text' },
];

const result = filterCreateModeFields(fields, { name: 'test' });
expect(result).toHaveLength(2);
});

it('keeps custom fields not in object schema', () => {
const fields: FormField[] = [
{ name: 'custom_field', label: 'Custom', type: 'field:text' },
];

const result = filterCreateModeFields(fields, objectSchema);
expect(result).toHaveLength(1);
});
});

describe('applyAutoLayout', () => {
it('infers 1 column for 3 fields', () => {
const fields: FormField[] = [
{ name: 'a', label: 'A', type: 'field:text' },
{ name: 'b', label: 'B', type: 'field:number' },
{ name: 'c', label: 'C', type: 'field:select' },
];

const result = applyAutoLayout(fields, null, undefined, 'create');
expect(result.columns).toBe(1);
expect(result.fields).toHaveLength(3);
});

it('infers 2 columns for 5 fields', () => {
const fields: FormField[] = [
{ name: 'a', label: 'A', type: 'field:text' },
{ name: 'b', label: 'B', type: 'field:text' },
{ name: 'c', label: 'C', type: 'field:text' },
{ name: 'd', label: 'D', type: 'field:text' },
{ name: 'e', label: 'E', type: 'field:text' },
];

const result = applyAutoLayout(fields, null, undefined, 'edit');
expect(result.columns).toBe(2);
});

it('applies colSpan to wide fields when columns > 1', () => {
const fields: FormField[] = [
{ name: 'a', label: 'A', type: 'field:text' },
{ name: 'b', label: 'B', type: 'field:text' },
{ name: 'c', label: 'C', type: 'field:text' },
{ name: 'd', label: 'D', type: 'field:textarea' },
];

const result = applyAutoLayout(fields, null, undefined, 'edit');
expect(result.columns).toBe(2);
expect(result.fields[3].colSpan).toBe(2);
// Regular fields should not have colSpan
expect(result.fields[0].colSpan).toBeUndefined();
});

it('filters auto-generated fields in create mode', () => {
const fields: FormField[] = [
{ name: 'name', label: 'Name', type: 'field:text' },
{ name: 'total', label: 'Total', type: 'field:text' },
{ name: 'email', label: 'Email', type: 'field:text' },
{ name: 'count', label: 'Count', type: 'field:text' },
];

const objectSchema = {
name: 'test',
fields: {
name: { type: 'text' },
total: { type: 'formula' },
email: { type: 'email' },
count: { type: 'summary' },
},
};

const result = applyAutoLayout(fields, objectSchema, undefined, 'create');
expect(result.fields.map(f => f.name)).toEqual(['name', 'email']);
expect(result.columns).toBe(1); // Only 2 fields after filtering → 1 column
});

it('does not filter auto-generated fields in edit mode', () => {
const fields: FormField[] = [
{ name: 'name', label: 'Name', type: 'field:text' },
{ name: 'total', label: 'Total', type: 'field:text' },
];

const objectSchema = {
name: 'test',
fields: {
name: { type: 'text' },
total: { type: 'formula' },
},
};

const result = applyAutoLayout(fields, objectSchema, undefined, 'edit');
expect(result.fields).toHaveLength(2);
});

it('respects user-provided columns', () => {
const fields: FormField[] = [
{ name: 'a', label: 'A', type: 'field:text' },
{ name: 'b', label: 'B', type: 'field:text' },
];

const result = applyAutoLayout(fields, null, 3, 'edit');
expect(result.columns).toBe(3);
});

it('applies auto colSpan even with user-provided columns', () => {
const fields: FormField[] = [
{ name: 'a', label: 'A', type: 'field:text' },
{ name: 'b', label: 'B', type: 'field:textarea' },
];

const result = applyAutoLayout(fields, null, 3, 'edit');
expect(result.columns).toBe(3);
expect(result.fields[1].colSpan).toBe(3);
});

it('does not override user-defined colSpan on fields', () => {
const fields: FormField[] = [
{ name: 'a', label: 'A', type: 'field:text' },
{ name: 'b', label: 'B', type: 'field:textarea', colSpan: 1 },
{ name: 'c', label: 'C', type: 'field:text' },
{ name: 'd', label: 'D', type: 'field:text' },
];

const result = applyAutoLayout(fields, null, undefined, 'edit');
expect(result.columns).toBe(2);
expect(result.fields[1].colSpan).toBe(1); // User override preserved
});

it('does not mutate original fields array', () => {
const fields: FormField[] = [
{ name: 'a', label: 'A', type: 'field:text' },
{ name: 'b', label: 'B', type: 'field:textarea' },
{ name: 'c', label: 'C', type: 'field:text' },
{ name: 'd', label: 'D', type: 'field:text' },
];

const result = applyAutoLayout(fields, null, undefined, 'edit');
expect(fields[1].colSpan).toBeUndefined();
expect(result.fields[1].colSpan).toBe(2);
});

it('handles empty fields array', () => {
const result = applyAutoLayout([], null, undefined, 'create');
expect(result.fields).toEqual([]);
expect(result.columns).toBe(1);
});

it('infers columns based on field count after create-mode filtering', () => {
// Start with 5 fields, but 3 are auto-generated → 2 remain → 1 column
const fields: FormField[] = [
{ name: 'name', label: 'Name', type: 'field:text' },
{ name: 'f1', label: 'F1', type: 'field:text' },
{ name: 'f2', label: 'F2', type: 'field:text' },
{ name: 'f3', label: 'F3', type: 'field:text' },
{ name: 'f4', label: 'F4', type: 'field:text' },
];

const objectSchema = {
name: 'test',
fields: {
name: { type: 'text' },
f1: { type: 'formula' },
f2: { type: 'summary' },
f3: { type: 'auto_number' },
f4: { type: 'text' },
},
};

const result = applyAutoLayout(fields, objectSchema, undefined, 'create');
// Only 'name' and 'f4' remain after filtering
expect(result.fields).toHaveLength(2);
expect(result.columns).toBe(1);
});
});
});
Loading