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
38 changes: 38 additions & 0 deletions .changeset/formpage-maxlength-override-5595.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
---
'@object-ui/console': patch
---

`buildSections` now honours a FormView field's `maxLength` override instead of always
taking the object's ceiling (objectui#5595).

The function merges a form's field overrides with the target object's field definitions,
and its own docstring states the rule: *"Field-level FormField overrides take precedence
over object defaults."* Every key in the loop is built that way — `override.label ??
def.label`, `override.required ?? def.required`, `override.placeholder ?? def.placeholder`
— except one, which read `def.maxLength` unconditionally. So an author who set a tighter
per-form limit (a short public intake form over a column whose object-level ceiling is
generous) got the generous one.

The failure was silent in the worst direction: no diagnostic, no warning, and the form
still submits, so the symptom is a value the author believed the input refused being
accepted. It is load-bearing rather than cosmetic — the merged row reaches the DOM at two
`maxLength={field.maxLength}` sites, the `textarea` arm and the default `input type="text"`
arm.

`override.maxLength ?? def.maxLength` — `??` rather than `||`, matching the sibling keys,
so an explicitly declared `0` stays a value the author wrote rather than falling through
to the column's ceiling. This narrows only what the input allows; the object's storage
ceiling still decides at submit time, so nothing that was accepted before is now rejected
anywhere but at the keyboard.

Why it survived: the console's local `FormFieldSpec` did not declare `maxLength` at all
until objectui#5542, so no one typing a spec in this app could write the override in the
first place, and the inert merge branch was never exercised. #5542 converged that type
onto the shared app-shell declaration, which does declare the key — making the gap
expressible, and therefore findable.

The pin `#5542` left behind — `expect(row.maxLength).toBeUndefined()` in
`FormPage.fieldSpec.test.ts`, which recorded the old answer explicitly rather than
assuming it — is **inverted** to `toBe(40)` rather than deleted. It was the pre-registered
evidence for this fix, and it is what made the gap findable in the first place, so it
keeps its place and names the honoured answer.
26 changes: 17 additions & 9 deletions apps/console/src/components/FormPage.fieldSpec.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,11 @@
* really differs, so PIN A can never pass vacuously.
* • **vitest** — the `buildSections` block. It proves the widening is inert
* at runtime: a field spec carrying the previously-undeclarable keys builds
* the same rows, and the keys this renderer does not honour are recorded as
* not honoured rather than assumed.
* the same rows. Whether each key is HONOURED is recorded rather than
* assumed — and that is not bookkeeping: `maxLength` was pinned here as
* NOT honoured, which is how objectui#5595 found that the merge dropped
* the override while the docstring promised it won. That assertion now
* names the honoured answer instead.
*/

import { describe, expect, it } from 'vitest';
Expand Down Expand Up @@ -186,13 +189,18 @@ describe('objectui#5542 — the console renders the shared field spec', () => {
expect(row.colSpan).toBe(2);
expect(row.required).toBe(true);

// Recorded rather than assumed: `RenderableField` is the narrow honoured
// shape, and it takes `maxLength` from the OBJECT schema, never from the
// field override — so authoring it on the field is declarable-but-inert in
// this renderer. That was true before #5542 too; the only thing that
// changed is that the type now tells the truth about the document instead
// of pretending the key cannot be written at all.
expect(row.maxLength).toBeUndefined();
// objectui#5595's pre-registered evidence — INVERTED here, not deleted.
//
// When #5542 landed this read `toBeUndefined()`, recording rather than
// assuming that `buildSections` took `maxLength` from the OBJECT schema
// alone and discarded the field override. Recording it is what made the
// gap findable: #5595 ruled it a declared≠enforced defect, because the
// function's own docstring promises overrides win and every sibling key
// implements that. So the same assertion now names the honoured answer.
//
// `objectSchema` is `null` in this call, so there is no object ceiling to
// inherit — the 40 can ONLY have come from the field override above.
expect(row.maxLength).toBe(40);
});

it('still accepts the bare string arm, which is most of what authors write', () => {
Expand Down
152 changes: 152 additions & 0 deletions apps/console/src/components/FormPage.maxLength.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#5595 — a field-level `maxLength` override reaches the input.
*
* ## Why this is pinned at the DOM and not only at `buildSections`
*
* `FormPage.test.ts` pins the merge itself, which is where the defect was:
* `buildSections` built every other key as `override.X ?? def.X` and built
* this one as `def.maxLength`, so a tighter per-form ceiling was dropped while
* the function's docstring promised overrides win.
*
* That merge test is necessary and not sufficient. The row is only worth
* anything if it reaches the element the user types into, and in THIS repo
* that step has independently failed for THIS key twice already — objectui#5201
* and #5253, both closed, both "a declared `max_length` ceiling never reaches
* the DOM", in the built-in form branches rather than here. Different renderer
* and a different mechanism each time (spelling/lookup, not a missing merge
* branch), which is exactly why a merge-level pin cannot see them. So the
* end-to-end leg is pinned separately, against the rendered attribute.
*
* ## Both DOM sites, deliberately
*
* `FormPage.tsx` spreads `maxLength={field.maxLength}` at two independent
* sites: the `textarea` arm (`textarea` / `paragraph` / `long_text`) and the
* default `input type="text"` arm. They are separate JSX elements in separate
* switch branches, so a fix that reached one proves nothing about the other.
*
* ## The controls
*
* Two cases here are controls scoped to the same attribute, and both can fail:
*
* - "falls back to the object ceiling" — without it, "the form's 40 wins" is
* equally satisfied by a renderer that lost the object side entirely, or by
* one that hard-codes 40.
* - "no ceiling on either side leaves the attribute off" — without it, every
* assertion above is equally satisfied by a renderer that caps every input
* at some number of its own choosing.
*/

import { describe, expect, it, vi, afterEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { FormPage } from './FormPage';

vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));

/**
* The object behind the forms below. `title` and `notes` carry deliberately
* GENEROUS ceilings — the whole point is a form that wants a tighter one than
* the column allows; `freeform` carries none, for the both-absent control.
*/
const OBJECT_SCHEMA = {
name: 'showcase_task',
label: 'Task',
fields: {
title: { type: 'text', label: 'Title', maxLength: 200 },
notes: { type: 'long_text', label: 'Notes', maxLength: 5000 },
freeform: { type: 'text', label: 'Freeform' },
},
};

/** Wrap a section's fields in the `/meta/view/:name` envelope this route reads. */
function viewEnvelope(fields: unknown[]) {
return {
name: 'showcase_task.intake',
object: 'showcase_task',
viewKind: 'form',
label: 'Task intake',
config: { type: 'simple', sections: [{ label: 'Task', fields }] },
};
}

function stubFetch(routes: Array<{ match: string; body: unknown }>) {
return vi.fn(async (url: string) => {
const route = routes.find((r) => String(url).includes(r.match));
if (!route) throw new Error(`unstubbed fetch: ${url}`);
return {
ok: true,
status: 200,
statusText: 'OK',
json: async () => route.body,
text: async () => JSON.stringify(route.body),
} as unknown as Response;
});
}

const recordPath = (objectName: string, recordId: string) =>
`/apps/ai.objectstack.showcase/${objectName}/record/${recordId}`;

/** Render the internal route (`/forms/:name`) over a given field list. */
function renderForm(fields: unknown[]) {
vi.stubGlobal(
'fetch',
stubFetch([
{ match: '/meta/view/', body: viewEnvelope(fields) },
{ match: '/meta/object/', body: OBJECT_SCHEMA },
]),
);
return render(
<MemoryRouter initialEntries={['/forms/showcase_task.intake']}>
<Routes>
<Route path="/forms/:name" element={<FormPage mode="internal" recordPath={recordPath} />} />
</Routes>
</MemoryRouter>,
);
}

/** Wait for the load to settle — every case needs the field on screen first. */
async function awaitField(label: string) {
await waitFor(() => expect(screen.getByLabelText(label)).toBeInTheDocument());
return screen.getByLabelText(label);
}

afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});

describe('objectui#5595 — the form field maxLength override reaches the input', () => {
it('caps the text input at the FORM ceiling, not the object one', async () => {
renderForm([{ field: 'title', maxLength: 40 }]);
const input = await awaitField('Title');
// The column allows 200; this form asked for 40. Pre-fix the author got
// 200 here, silently — the symptom the card describes is a value the
// author believed was refused at the input being accepted.
expect(input).toHaveAttribute('maxlength', '40');
});

it('caps the TEXTAREA arm too — a second, independent JSX site', async () => {
renderForm([{ field: 'notes', maxLength: 120 }]);
const textarea = await awaitField('Notes');
expect(textarea.tagName).toBe('TEXTAREA');
expect(textarea).toHaveAttribute('maxlength', '120');
});

it('CONTROL — falls back to the object ceiling when the form asks for none', async () => {
renderForm(['title']);
const input = await awaitField('Title');
// Scoped to the same attribute and able to fail: dropping `?? def.maxLength`
// turns this red while leaving both cases above green.
expect(input).toHaveAttribute('maxlength', '200');
});

it('CONTROL — no ceiling on either side leaves the attribute off entirely', async () => {
renderForm(['freeform']);
const input = await awaitField('Freeform');
// Without this, every assertion above is equally satisfied by a renderer
// that caps every input at a number of its own choosing.
expect(input).not.toHaveAttribute('maxlength');
});
});
60 changes: 60 additions & 0 deletions apps/console/src/components/FormPage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,66 @@ describe('buildSections', () => {
expect(section.fields[0].placeholder).toBe('Ada');
});

// objectui#5595 — `maxLength` was the ONE key in the merge that read the
// object definition unconditionally, while the docstring above
// `buildSections` promises field-level overrides win. The three cases below
// pin both sides of the `??` plus the both-absent floor, because "the
// override wins" and "the object default still reaches the row" are
// separately breakable and a single case is satisfied by a merge that
// dropped either side entirely.
it('lets a tighter per-form maxLength beat the object ceiling', () => {
const [section] = buildSections(
{
type: 'simple',
sections: [{ fields: [{ field: 'email', maxLength: 40 }] }],
},
objectSchema,
);
// The object column allows 200; this form asks for 40. Before #5595 the
// author silently got 200 at the input, with no diagnostic.
expect(section.fields[0].maxLength).toBe(40);
});

it('falls back to the object ceiling when the form declares no maxLength', () => {
const [section] = buildSections(
{
type: 'simple',
sections: [{ fields: [{ field: 'email', label: 'Work email' }] }],
},
objectSchema,
);
// The control on the case above, scoped to the same merge branch and able
// to fail: `maxLength: override.maxLength` alone (no `?? def`) turns this
// red while leaving the case above green.
expect(section.fields[0].label).toBe('Work email');
expect(section.fields[0].maxLength).toBe(200);
});

it('keeps an explicit 0 rather than falling through to the object ceiling', () => {
const [section] = buildSections(
{
type: 'simple',
sections: [{ fields: [{ field: 'email', maxLength: 0 }] }],
},
objectSchema,
);
// `??`, not `||` — the distinction the sibling keys already rely on. A
// declared 0 is a value the author wrote; `||` would silently restore the
// exact defect #5595 fixed, for the one input that admits no characters.
expect(section.fields[0].maxLength).toBe(0);
});

it('leaves maxLength undefined when neither side declares one', () => {
const [section] = buildSections(
{ type: 'simple', sections: [{ fields: ['first_name'] }] },
objectSchema,
);
// `first_name` carries no ceiling on either side, so the row must carry
// none — the DOM sites spread `maxLength={field.maxLength}` straight onto
// the input, and a fabricated number there would cap a field nobody capped.
expect(section.fields[0].maxLength).toBeUndefined();
});

it('normalizes object schema options through onto the renderable field', () => {
const [section] = buildSections(
{
Expand Down
9 changes: 8 additions & 1 deletion apps/console/src/components/FormPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -422,7 +422,14 @@ export function buildSections(
helpText: override.helpText ?? def.helpText,
defaultValue: def.defaultValue,
options: normalizeOptions(def.options),
maxLength: def.maxLength,
// The FORM's ceiling wins over the object's, exactly like every
// sibling key above. This one key read `def` alone, so a tighter
// per-form limit — a short public intake form over a generously
// sized column — was discarded with no diagnostic, and the docstring
// promising overrides win was false for it (objectui#5595). The
// override can only NARROW what the author sees at the input; the
// object's storage ceiling still decides at submit time.
maxLength: override.maxLength ?? def.maxLength,
colSpan: override.colSpan ?? 1,
});
}
Expand Down
Loading