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
14 changes: 14 additions & 0 deletions .changeset/27483-invalid-typed-date-refuses-submit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@object-ui/fields': patch
'@object-ui/components': patch
---

fix(fields, components): a date typed in a form the picker cannot read is a field error that refuses submit, not a silent drop (#27483)

Clause-②: yes (widening)
Metadata-protocol: none

Typing `13/45/2026` into a record form's date field and pressing Create saved the record with the date empty (or, on edit, with the old date) and a success toast. The picker never writes text that names no date, so the form value stayed what it was and nothing told the form.

- **Now.** The text box is marked invalid as soon as the user leaves it, the field shows the picker's message (the typed text and the expected format) as its form error, and submit is refused the way a missing required value is: the dialog stays open and nothing is sent. Correcting or clearing the text withdraws the error. `datetime` fields behave the same.
- **`onInvalidInputChange`** is a new optional widget prop on `FieldWidgetComponentProps`, the widget props type `@object-ui/fields` exports. The key is optional, so every existing widget and host compiles and behaves as before; no consumer breaks. It is fired as follows: a widget that parses typed text fires it with a message when the committed text names no value, and with `null` once it is valid, cleared, or unmounted. The `form` renderer (`@object-ui/components`) turns the message into the field's `invalidInput` validation error and passes the callback to the `date` / `datetime` widgets only. A widget given the callback leaves the message text to the form; hosts that pass none (the grid's inline editor, the action-param dialog) keep the picker's own notice, unchanged.
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* objectstack#27483 — the form renderer's half of the `onInvalidInputChange`
* channel: a widget's report of text it could not parse becomes the field's
* validation error and refuses submit; withdrawing it lets the submit through;
* and only the widgets that report receive the callback.
*
* The real date picker lives in `@object-ui/fields`, which components tests
* never load (its end-to-end pin is there); a probe stands in for it here.
*/

import React from 'react';
import { describe, it, expect, beforeAll, beforeEach, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { ComponentRegistry } from '@object-ui/core';
// Module scope, not `beforeAll` — the cold transform must not be billed to
// `hookTimeout`. See object-ui/no-dynamic-import-in-test-hook (objectui#3010).
import '../../../renderers';

const MESSAGE = 'Not saved: "13/45/2026" is not a date.';

type ProbeProps = {
field?: { name?: string };
onInvalidInputChange?: (message: string | null) => void;
};

function ReportingProbe(props: ProbeProps) {
return (
<div data-testid={`probe-${props.field?.name}`} data-has-key={'onInvalidInputChange' in props ? 'yes' : 'no'}>
<button type="button" onClick={() => props.onInvalidInputChange?.(MESSAGE)}>
report
</button>
<button type="button" onClick={() => props.onInvalidInputChange?.(null)}>
withdraw
</button>
</div>
);
}

beforeAll(() => {
ComponentRegistry.register('field:date', ReportingProbe, { namespace: 'test' });
// A registered widget that does not report: the strip must keep the key off it.
ComponentRegistry.register('field:lookup', ReportingProbe, { namespace: 'test' });
}, 30000);

beforeEach(() => {
if (!Element.prototype.scrollIntoView) {
Element.prototype.scrollIntoView = () => {};
}
});

function renderForm(onSubmit: (data: Record<string, unknown>) => void) {
const Form = ComponentRegistry.get('form')!;
return render(
<Form
schema={{
type: 'form',
showSubmit: true,
showCancel: false,
submitLabel: 'Create',
defaultValues: { due: null },
fields: [
{ name: 'due', label: 'Due Date', type: 'field:date' },
{ name: 'account', label: 'Account', type: 'field:lookup' },
],
onSubmit,
}}
/>,
);
}

const dueRow = () => document.querySelector('[data-field="due"]') as HTMLElement;
const within = (row: HTMLElement, name: string) =>
Array.from(row.querySelectorAll('button')).find((b) => b.textContent === name)!;

describe('form renderer — unparseable widget input is a field error (objectstack#27483)', () => {
it('a report shows as the field message at once and refuses submit', async () => {
const onSubmit = vi.fn();
renderForm(onSubmit);

fireEvent.click(within(dueRow(), 'report'));
await waitFor(() => expect(dueRow().textContent).toContain(MESSAGE));

const scroll = vi.spyOn(Element.prototype, 'scrollIntoView');
fireEvent.click(screen.getByRole('button', { name: /create/i }));
await waitFor(() => expect(scroll).toHaveBeenCalled());
expect(onSubmit).not.toHaveBeenCalled();
});

it('a withdrawn report clears the error and the submit goes through', async () => {
const onSubmit = vi.fn();
renderForm(onSubmit);

fireEvent.click(within(dueRow(), 'report'));
await waitFor(() => expect(dueRow().textContent).toContain(MESSAGE));
fireEvent.click(within(dueRow(), 'withdraw'));
await waitFor(() => expect(dueRow().textContent).not.toContain(MESSAGE));

fireEvent.click(screen.getByRole('button', { name: /create/i }));
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
});

it('only the reporting widget types receive the callback', () => {
renderForm(() => {});
expect(screen.getByTestId('probe-due')).toHaveAttribute('data-has-key', 'yes');
expect(screen.getByTestId('probe-account')).toHaveAttribute('data-has-key', 'no');
});
});
29 changes: 29 additions & 0 deletions ui/packages/components/src/renderers/form/form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1229,6 +1229,7 @@ function stripRegisteredFieldProps(type: string, props: RenderFieldProps): Rende
dependentValues,
dependsOnLabels,
emptyHint,
onInvalidInputChange,
// Renderer-only, and deliberately NOT a new key in the widget contract
// (objectui#3393). The call site started forwarding `label` for the
// built-in fullscreen dialog; registered widgets must keep receiving
Expand Down Expand Up @@ -1266,9 +1267,15 @@ function stripRegisteredFieldProps(type: string, props: RenderFieldProps): Rende
// A record-mode amount's symbol and decimals follow the code the user picks
// in its sibling select, so the amount box needs the live record too.
...(RECORD_CURRENCY_WIDGET_TYPES.has(normalizedType) ? { dependentValues } : {}),
// Only the widgets that report unparseable text read it; every other one
// would spread an unknown handler onto its DOM (objectstack#27483).
...(INVALID_INPUT_REPORTING_WIDGET_TYPES.has(normalizedType) ? { onInvalidInputChange } : {}),
};
}

/** The registered widgets that fire `onInvalidInputChange`: the date picker's two faces. */
const INVALID_INPUT_REPORTING_WIDGET_TYPES: ReadonlySet<string> = new Set(['date', 'datetime']);

/**
* FullscreenTextarea — `<Textarea>` with a top-right "expand" button that
* opens a fullscreen edit dialog. Mobile UX (round 3) — driven by the form
Expand Down Expand Up @@ -3235,6 +3242,11 @@ ComponentRegistry.register('form',
// on every render — the same React footgun documented at the call site.
const requiredMessagesRef = React.useRef<Map<string, string> | undefined>(undefined);
const requiredMessages = (requiredMessagesRef.current ??= new Map<string, string>());
// Per field name, the message of text its widget could not parse
// (`onInvalidInputChange`, objectstack#27483); absent ⇒ none. A ref for the
// reasons `requiredMessages` gives: read by a validator at call time.
const invalidInputMessagesRef = React.useRef<Map<string, string> | undefined>(undefined);
const invalidInputMessages = (invalidInputMessagesRef.current ??= new Map<string, string>());

// --- Field rendering ---------------------------------------------------
// Renders ONE field row (or a virtual section divider). Hoisted out of the
Expand Down Expand Up @@ -3431,6 +3443,11 @@ ComponentRegistry.register('form',
}
const authoredValidate = rules.validate;
rules.validate = {
// Text the widget could not parse was never written, so the value
// cannot speak for it: this verdict comes first, under its own key,
// and refuses submit the way a missing required value does
// (objectstack#27483).
invalidInput: () => invalidInputMessages.get(name) ?? true,
// A field-authored `validate` keeps running, and keeps its own
// `type: 'validate'` error key.
...(typeof authoredValidate === 'function'
Expand Down Expand Up @@ -3820,6 +3837,18 @@ ComponentRegistry.register('form',
// Undefined when the field is valid, so the key is simply
// absent from the DOM rather than rendering `error=""`.
error: fieldState.error?.message,
// The widget's verdict on text it could not parse, turned
// into this field's `invalidInput` error at once (above);
// withdrawn, it clears only that error (objectstack#27483).
onInvalidInputChange: (message: string | null) => {
if (message === null) {
if (!invalidInputMessages.delete(name)) return;
if (form.getFieldState(name).error?.type === 'invalidInput') form.clearErrors(name);
return;
}
invalidInputMessages.set(name, message);
void form.trigger(name);
},
// Required is a STATE, so it travels the state channel
// (objectui#3290). Until now the resolved `required` (static
// `required` + `requiredWhen` CEL) drove exactly one thing —
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* objectstack#27483 — a date typed in a form the picker cannot read is a
* validation error, not a silent drop.
*
* The picker never writes text that names no date (`13/45/2026`), so the form
* value stayed what it was: the record saved with an empty Due Date and a
* success toast. The picker now reports such text through
* `onInvalidInputChange`, and the form renderer turns it into the field's
* error, which refuses submit the way a missing required value does.
*
* Mounted through the real `form` renderer with the production registration of
* the picker, because the defect lives in the hand-off between the two.
*/

import React from 'react';
import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest';
import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react';
import '@testing-library/jest-dom';
import { ComponentRegistry } from '@object-ui/core';
// Module scope: pulls in the form renderer's registration side effect.
import '@object-ui/components';

import { withFieldCarrier } from '../withFieldCarrier';
import { DatePickerField, DateTimePickerField } from '../widgets/date-picker/DatePickerField';

beforeAll(() => {
ComponentRegistry.register('date', withFieldCarrier(DatePickerField), {
namespace: 'field',
skipFallback: true,
});
ComponentRegistry.register('datetime', withFieldCarrier(DateTimePickerField), {
namespace: 'field',
skipFallback: true,
});
}, 60000);

beforeEach(() => {
if (!Element.prototype.scrollIntoView) {
Element.prototype.scrollIntoView = () => {};
}
});

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

function renderForm(type: 'date' | 'datetime', onSubmit: (data: Record<string, unknown>) => void) {
const Form = ComponentRegistry.get('form')!;
return render(
<Form
schema={{
type: 'form',
mode: 'create',
showSubmit: true,
showCancel: false,
submitLabel: 'Create',
defaultValues: { title: 'Walkthrough task', due: null },
fields: [
{ name: 'title', label: 'Title', type: 'text' },
{ name: 'due', label: 'Due Date', type: `field:${type}` },
],
onSubmit,
}}
/>,
);
}

async function dueBox(): Promise<HTMLInputElement> {
return waitFor(() => {
const input = document.querySelector('[data-field="due"] [data-slot="date-picker"] input');
if (!input) throw new Error('date picker never rendered');
return input as HTMLInputElement;
});
}

const typeAndLeave = (input: HTMLInputElement, text: string) => {
fireEvent.change(input, { target: { value: text } });
fireEvent.blur(input);
};

const dueRow = () => document.querySelector('[data-field="due"]') as HTMLElement;

/** Press Create and wait for the refusal to land: the form scrolls to its first invalid field. */
async function submitAndSeeRefusal() {
const scroll = vi.spyOn(Element.prototype, 'scrollIntoView');
fireEvent.click(screen.getByRole('button', { name: /create/i }));
await waitFor(() => expect(scroll).toHaveBeenCalled());
}

describe('an unreadable typed date refuses submit (objectstack#27483)', () => {
it('13/45/2026 marks the field invalid and sends nothing', async () => {
const onSubmit = vi.fn();
renderForm('date', onSubmit);
const input = await dueBox();

typeAndLeave(input, '13/45/2026');
// Shown on leaving the field, before any submit: the form's own message,
// naming the format, and the picker's notice is not drawn beside it.
await waitFor(() => expect(dueRow().textContent).toContain('MM/DD/YYYY'));
expect(dueRow().querySelector('[data-testid="date-picker-notice"]')).toBeNull();
expect(input).toHaveAttribute('aria-invalid', 'true');

await submitAndSeeRefusal();
expect(onSubmit).not.toHaveBeenCalled();
expect(input.value).toBe('13/45/2026');
});

it('correcting the text clears the error and the typed day is saved', async () => {
const onSubmit = vi.fn();
renderForm('date', onSubmit);
const input = await dueBox();

typeAndLeave(input, '13/45/2026');
await waitFor(() => expect(input).toHaveAttribute('aria-invalid', 'true'));

typeAndLeave(input, '10/20/2026');
await waitFor(() => expect(dueRow().textContent).not.toContain('MM/DD/YYYY'));
expect(input).toHaveAttribute('aria-invalid', 'false');

fireEvent.click(screen.getByRole('button', { name: /create/i }));
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
expect(onSubmit.mock.calls[0][0]).toMatchObject({ title: 'Walkthrough task', due: '2026-10-20' });
});

it('clearing the bad text withdraws the error and an empty optional date saves', async () => {
const onSubmit = vi.fn();
renderForm('date', onSubmit);
const input = await dueBox();

typeAndLeave(input, '13/45/2026');
await waitFor(() => expect(input).toHaveAttribute('aria-invalid', 'true'));

fireEvent.click(screen.getByTestId('date-picker-clear'));
await waitFor(() => expect(input).toHaveAttribute('aria-invalid', 'false'));

fireEvent.click(screen.getByRole('button', { name: /create/i }));
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
});

it('a valid typed date saves untouched (control)', async () => {
const onSubmit = vi.fn();
renderForm('date', onSubmit);
typeAndLeave(await dueBox(), '10/20/2026');

fireEvent.click(screen.getByRole('button', { name: /create/i }));
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
expect(onSubmit.mock.calls[0][0]).toMatchObject({ due: '2026-10-20' });
});

it('datetime: an unreadable date and time refuses submit the same way', async () => {
const onSubmit = vi.fn();
renderForm('datetime', onSubmit);
const input = await dueBox();

typeAndLeave(input, '13/45/2026 25:99');
await waitFor(() => expect(input).toHaveAttribute('aria-invalid', 'true'));

await submitAndSeeRefusal();
expect(onSubmit).not.toHaveBeenCalled();
});
});
Loading
Loading