Skip to content

Commit efa2915

Browse files
fix(fields,components): a date typed in a form that the picker cannot read is a field error that refuses submit (#27623)
domain:ui#2 · os-bill · session_01Utp6CY2n1JmZZKrHgtp9jC <!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgP6XkfpjCfyzzG2KwQPUZfy)_ Fixes #27483 Clause-②: yes (widening) Metadata-protocol: none ## 维护者速读 - 改了什么:记录表单里手输一个读不懂的日期(如 13/45/2026)时,该字段当场标红并显示提示,点「创建」被拒、对话框不关、不发请求;改对或清空后才能保存。 - 为什么改:原来日期控件不写入读不懂的文本,表单值保持为空(编辑时保持旧值),照常保存并弹成功提示,用户以为日期已存好。 - 风险与回滚:只新增一个可选的控件回调和一条表单校验项,只对 date / datetime 控件生效;回滚即还原本 PR,无数据迁移。 - 先看:`ui/packages/components/src/renderers/form/form.tsx`(新校验项 invalidInput 与回调)、`ui/packages/fields/src/widgets/date-picker/DatePickerField.tsx`(上报与撤回)、`ui/packages/fields/src/widgets/types.ts`(新属性的契约说明)。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/fields/src/__tests__/date-picker-invalid-text-blocks-submit-27483.test.tsx`,预期 5 条全绿。 - 需要你做的:无,席位落地。 ## What changes The date / datetime picker never writes text that names no date. In a record form that meant the form value stayed what it was (empty on create, the old date on edit), the submit went through and the success toast fired. Per the triage ruling, the unparseable text is now marked invalid through the form's existing field validation, the way a missing required value is. - **`onInvalidInputChange`** (`@object-ui/fields`, `FieldWidgetComponentProps`): a new optional widget prop. A widget that parses typed text fires it with the message to show when the committed text names no value, and with `null` once the text is valid, cleared, or the widget unmounts. Its doc comment names its producer (the date picker's two faces) and its consumer (the form renderer). It is added to `toHostProps`' `HOST_PLUMBING_KEYS`, so the compile-time partition still holds and `FieldEditWidget` delivers it when a host passes it. - **`DatePickerField` / `DateTimePickerField`**: report on change only (not on every render), withdraw on unmount, and report nothing while readonly. A widget given the callback leaves the message text to the form's FormMessage, as the `error` slot already does; a host that passes none (the grid's inline editor, the action-param and bulk-action dialogs) keeps the picker's own notice, byte for byte. `aria-invalid` is set either way. - **`form` renderer** (`@object-ui/components`): a per-field `invalidInputMessages` map (a ref, beside `requiredMessages`, read by the validator at call time) and a first `rules.validate` entry, `invalidInput`, that refuses while a message is held. The callback stores the message and runs `trigger(name)`, so the error shows when the user leaves the field, not only at submit; a withdrawal clears only an `invalidInput` error. `stripRegisteredFieldProps` forwards the callback to the `date` / `datetime` widgets only, so no other widget spreads an unknown handler onto its DOM. - The `time` widget is a native `input[type=time]` and shares neither the parser nor the commit path, so it is untouched. ## Tests - `ui/packages/fields/src/__tests__/date-picker-invalid-text-blocks-submit-27483.test.tsx` (new, real `form` renderer plus the production registration of the picker): typing `13/45/2026` and leaving the field shows the form message and `aria-invalid`, with no second notice; pressing Create is refused (the form scrolls to the field) and `onSubmit` is never called; correcting the text clears the error and saves `2026-10-20`; clearing it lets an optional empty date save; a valid typed date saves (control); `datetime` refuses the same way. - `ui/packages/components/src/renderers/form/__tests__/form-invalid-input-refuses-submit-27483.test.tsx` (new, probe widget): a report becomes the field message at once and refuses submit; a withdrawal clears it and the submit goes through; only `field:date` receives the callback, `field:lookup` does not. - `DatePickerField-12622.test.tsx` (updated, the existing "not written, and says so" pin is kept as is): with a host callback the picker reports the message (containing the text and the format), draws no own notice, reports `null` after a valid commit, on clear and on unmount; `datetime` reports too. ## Acceptance notes - Hosts outside the record form (`FieldEditWidget` for the grid and record-page inline edit, `ActionParamDialog`, `BulkActionDialog`) pass no callback today, so there the picker still shows its own notice and does not block the host's save. Not reproduced here and not filed; noted as the remaining surface of the same channel. - The message shown is the picker's existing `fields.datePicker.invalid` string ("Not saved: ... is not a date. Type it as FORMAT, or pick one from the calendar."), so no i18n key was added. - The merge of `main` met the record-currency change in `stripRegisteredFieldProps`; both allow-list lines are kept. ## Verification Head `81611c8a`. Test readings at `bc5108de` (after the one merge of `main`); `c4826848` only types the two new test files' probe and registrations instead of casting to `any` (both packages' `type-check` exit 0 and the four pin files 33/33 there), and `81611c8a` only changes the changeset's declaration to `Clause-②: yes (widening)`: `FieldWidgetComponentProps` is exported from `@object-ui/fields`, and the new key is optional, so no consumer breaks. The derived gates are re-run at `81611c8a`. <details> <summary>Tests and type-check (local, under the verify lock)</summary> - `pnpm --dir ui exec vitest run --maxWorkers=2 packages/fields/ packages/components/`: Test Files 734 passed, 2 skipped; Tests 8683 passed, 31 skipped; VERDICT command-exit 0. (Before the merge: 731 files, 8654 tests, exit 0.) - `pnpm --filter @object-ui/fields type-check` exit 0; `pnpm --filter @object-ui/components type-check` exit 0. - eslint on the seven changed `ui/` files: 0 errors, and no warning on the lines this PR adds. - `pnpm check:new-line-citations` (ui): 0 new citations. </details> <details> <summary>Ablation (one per tested behaviour, each through scripts/ablation-replace.mjs, restored by blob)</summary> - The form's `invalidInput` validator made to pass always: 4 tests red (the two refusal pins in the fields end-to-end file, both renderer pins that need the error), 20 green; restored to blob `c3d119a7bf03`. - The picker's report call removed: 5 tests red (the two end-to-end refusal pins and the three 12622 report pins), 16 green; restored to blob `556b44a4b738`. - No build was needed: the ui vitest config aliases `@object-ui/components` to its `src`. </details> <details> <summary>Gates (dispatch-gates derivation, 34 families)</summary> - `dispatch-gates --ran`: 34 derived, 34 run, 0 NOT-MEASURED, 0 UNRUN; every recorded exit 0. - Two were re-run alone after a first reading under load: `pnpm check:type-check-debt` (exit 3, queue timeout, then exit 0 under the lock) and `pnpm --dir ui exec vitest run scripts/` (one test timed out at 15 s beside the package run, then 4901 passed alone). - `node scripts/check-sdui-manifest.mjs` and its `--self-test`: exit 0. - CI runs the rest of the farm. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CXAtdK85r4832Kydbf)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent af52253 commit efa2915

8 files changed

Lines changed: 422 additions & 3 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@object-ui/fields': patch
3+
'@object-ui/components': patch
4+
---
5+
6+
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)
7+
8+
Clause-②: yes (widening)
9+
Metadata-protocol: none
10+
11+
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.
12+
13+
- **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.
14+
- **`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.
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
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+
/**
10+
* objectstack#27483 — the form renderer's half of the `onInvalidInputChange`
11+
* channel: a widget's report of text it could not parse becomes the field's
12+
* validation error and refuses submit; withdrawing it lets the submit through;
13+
* and only the widgets that report receive the callback.
14+
*
15+
* The real date picker lives in `@object-ui/fields`, which components tests
16+
* never load (its end-to-end pin is there); a probe stands in for it here.
17+
*/
18+
19+
import React from 'react';
20+
import { describe, it, expect, beforeAll, beforeEach, vi } from 'vitest';
21+
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
22+
import { ComponentRegistry } from '@object-ui/core';
23+
// Module scope, not `beforeAll` — the cold transform must not be billed to
24+
// `hookTimeout`. See object-ui/no-dynamic-import-in-test-hook (objectui#3010).
25+
import '../../../renderers';
26+
27+
const MESSAGE = 'Not saved: "13/45/2026" is not a date.';
28+
29+
type ProbeProps = {
30+
field?: { name?: string };
31+
onInvalidInputChange?: (message: string | null) => void;
32+
};
33+
34+
function ReportingProbe(props: ProbeProps) {
35+
return (
36+
<div data-testid={`probe-${props.field?.name}`} data-has-key={'onInvalidInputChange' in props ? 'yes' : 'no'}>
37+
<button type="button" onClick={() => props.onInvalidInputChange?.(MESSAGE)}>
38+
report
39+
</button>
40+
<button type="button" onClick={() => props.onInvalidInputChange?.(null)}>
41+
withdraw
42+
</button>
43+
</div>
44+
);
45+
}
46+
47+
beforeAll(() => {
48+
ComponentRegistry.register('field:date', ReportingProbe, { namespace: 'test' });
49+
// A registered widget that does not report: the strip must keep the key off it.
50+
ComponentRegistry.register('field:lookup', ReportingProbe, { namespace: 'test' });
51+
}, 30000);
52+
53+
beforeEach(() => {
54+
if (!Element.prototype.scrollIntoView) {
55+
Element.prototype.scrollIntoView = () => {};
56+
}
57+
});
58+
59+
function renderForm(onSubmit: (data: Record<string, unknown>) => void) {
60+
const Form = ComponentRegistry.get('form')!;
61+
return render(
62+
<Form
63+
schema={{
64+
type: 'form',
65+
showSubmit: true,
66+
showCancel: false,
67+
submitLabel: 'Create',
68+
defaultValues: { due: null },
69+
fields: [
70+
{ name: 'due', label: 'Due Date', type: 'field:date' },
71+
{ name: 'account', label: 'Account', type: 'field:lookup' },
72+
],
73+
onSubmit,
74+
}}
75+
/>,
76+
);
77+
}
78+
79+
const dueRow = () => document.querySelector('[data-field="due"]') as HTMLElement;
80+
const within = (row: HTMLElement, name: string) =>
81+
Array.from(row.querySelectorAll('button')).find((b) => b.textContent === name)!;
82+
83+
describe('form renderer — unparseable widget input is a field error (objectstack#27483)', () => {
84+
it('a report shows as the field message at once and refuses submit', async () => {
85+
const onSubmit = vi.fn();
86+
renderForm(onSubmit);
87+
88+
fireEvent.click(within(dueRow(), 'report'));
89+
await waitFor(() => expect(dueRow().textContent).toContain(MESSAGE));
90+
91+
const scroll = vi.spyOn(Element.prototype, 'scrollIntoView');
92+
fireEvent.click(screen.getByRole('button', { name: /create/i }));
93+
await waitFor(() => expect(scroll).toHaveBeenCalled());
94+
expect(onSubmit).not.toHaveBeenCalled();
95+
});
96+
97+
it('a withdrawn report clears the error and the submit goes through', async () => {
98+
const onSubmit = vi.fn();
99+
renderForm(onSubmit);
100+
101+
fireEvent.click(within(dueRow(), 'report'));
102+
await waitFor(() => expect(dueRow().textContent).toContain(MESSAGE));
103+
fireEvent.click(within(dueRow(), 'withdraw'));
104+
await waitFor(() => expect(dueRow().textContent).not.toContain(MESSAGE));
105+
106+
fireEvent.click(screen.getByRole('button', { name: /create/i }));
107+
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
108+
});
109+
110+
it('only the reporting widget types receive the callback', () => {
111+
renderForm(() => {});
112+
expect(screen.getByTestId('probe-due')).toHaveAttribute('data-has-key', 'yes');
113+
expect(screen.getByTestId('probe-account')).toHaveAttribute('data-has-key', 'no');
114+
});
115+
});

‎ui/packages/components/src/renderers/form/form.tsx‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1232,6 +1232,7 @@ function stripRegisteredFieldProps(type: string, props: RenderFieldProps): Rende
12321232
dependentValues,
12331233
dependsOnLabels,
12341234
emptyHint,
1235+
onInvalidInputChange,
12351236
// Renderer-only, and deliberately NOT a new key in the widget contract
12361237
// (objectui#3393). The call site started forwarding `label` for the
12371238
// built-in fullscreen dialog; registered widgets must keep receiving
@@ -1269,9 +1270,15 @@ function stripRegisteredFieldProps(type: string, props: RenderFieldProps): Rende
12691270
// A record-mode amount's symbol and decimals follow the code the user picks
12701271
// in its sibling select, so the amount box needs the live record too.
12711272
...(RECORD_CURRENCY_WIDGET_TYPES.has(normalizedType) ? { dependentValues } : {}),
1273+
// Only the widgets that report unparseable text read it; every other one
1274+
// would spread an unknown handler onto its DOM (objectstack#27483).
1275+
...(INVALID_INPUT_REPORTING_WIDGET_TYPES.has(normalizedType) ? { onInvalidInputChange } : {}),
12721276
};
12731277
}
12741278

1279+
/** The registered widgets that fire `onInvalidInputChange`: the date picker's two faces. */
1280+
const INVALID_INPUT_REPORTING_WIDGET_TYPES: ReadonlySet<string> = new Set(['date', 'datetime']);
1281+
12751282
/**
12761283
* FullscreenTextarea — `<Textarea>` with a top-right "expand" button that
12771284
* opens a fullscreen edit dialog. Mobile UX (round 3) — driven by the form
@@ -3254,6 +3261,11 @@ ComponentRegistry.register('form',
32543261
// on every render — the same React footgun documented at the call site.
32553262
const requiredMessagesRef = React.useRef<Map<string, string> | undefined>(undefined);
32563263
const requiredMessages = (requiredMessagesRef.current ??= new Map<string, string>());
3264+
// Per field name, the message of text its widget could not parse
3265+
// (`onInvalidInputChange`, objectstack#27483); absent ⇒ none. A ref for the
3266+
// reasons `requiredMessages` gives: read by a validator at call time.
3267+
const invalidInputMessagesRef = React.useRef<Map<string, string> | undefined>(undefined);
3268+
const invalidInputMessages = (invalidInputMessagesRef.current ??= new Map<string, string>());
32573269

32583270
// --- Field rendering ---------------------------------------------------
32593271
// Renders ONE field row (or a virtual section divider). Hoisted out of the
@@ -3450,6 +3462,11 @@ ComponentRegistry.register('form',
34503462
}
34513463
const authoredValidate = rules.validate;
34523464
rules.validate = {
3465+
// Text the widget could not parse was never written, so the value
3466+
// cannot speak for it: this verdict comes first, under its own key,
3467+
// and refuses submit the way a missing required value does
3468+
// (objectstack#27483).
3469+
invalidInput: () => invalidInputMessages.get(name) ?? true,
34533470
// A field-authored `validate` keeps running, and keeps its own
34543471
// `type: 'validate'` error key.
34553472
...(typeof authoredValidate === 'function'
@@ -3839,6 +3856,18 @@ ComponentRegistry.register('form',
38393856
// Undefined when the field is valid, so the key is simply
38403857
// absent from the DOM rather than rendering `error=""`.
38413858
error: fieldState.error?.message,
3859+
// The widget's verdict on text it could not parse, turned
3860+
// into this field's `invalidInput` error at once (above);
3861+
// withdrawn, it clears only that error (objectstack#27483).
3862+
onInvalidInputChange: (message: string | null) => {
3863+
if (message === null) {
3864+
if (!invalidInputMessages.delete(name)) return;
3865+
if (form.getFieldState(name).error?.type === 'invalidInput') form.clearErrors(name);
3866+
return;
3867+
}
3868+
invalidInputMessages.set(name, message);
3869+
void form.trigger(name);
3870+
},
38423871
// Required is a STATE, so it travels the state channel
38433872
// (objectui#3290). Until now the resolved `required` (static
38443873
// `required` + `requiredWhen` CEL) drove exactly one thing —
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
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+
/**
10+
* objectstack#27483 — a date typed in a form the picker cannot read is a
11+
* validation error, not a silent drop.
12+
*
13+
* The picker never writes text that names no date (`13/45/2026`), so the form
14+
* value stayed what it was: the record saved with an empty Due Date and a
15+
* success toast. The picker now reports such text through
16+
* `onInvalidInputChange`, and the form renderer turns it into the field's
17+
* error, which refuses submit the way a missing required value does.
18+
*
19+
* Mounted through the real `form` renderer with the production registration of
20+
* the picker, because the defect lives in the hand-off between the two.
21+
*/
22+
23+
import React from 'react';
24+
import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest';
25+
import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react';
26+
import '@testing-library/jest-dom';
27+
import { ComponentRegistry } from '@object-ui/core';
28+
// Module scope: pulls in the form renderer's registration side effect.
29+
import '@object-ui/components';
30+
31+
import { withFieldCarrier } from '../withFieldCarrier';
32+
import { DatePickerField, DateTimePickerField } from '../widgets/date-picker/DatePickerField';
33+
34+
beforeAll(() => {
35+
ComponentRegistry.register('date', withFieldCarrier(DatePickerField), {
36+
namespace: 'field',
37+
skipFallback: true,
38+
});
39+
ComponentRegistry.register('datetime', withFieldCarrier(DateTimePickerField), {
40+
namespace: 'field',
41+
skipFallback: true,
42+
});
43+
}, 60000);
44+
45+
beforeEach(() => {
46+
if (!Element.prototype.scrollIntoView) {
47+
Element.prototype.scrollIntoView = () => {};
48+
}
49+
});
50+
51+
afterEach(() => {
52+
cleanup();
53+
vi.restoreAllMocks();
54+
});
55+
56+
function renderForm(type: 'date' | 'datetime', onSubmit: (data: Record<string, unknown>) => void) {
57+
const Form = ComponentRegistry.get('form')!;
58+
return render(
59+
<Form
60+
schema={{
61+
type: 'form',
62+
mode: 'create',
63+
showSubmit: true,
64+
showCancel: false,
65+
submitLabel: 'Create',
66+
defaultValues: { title: 'Walkthrough task', due: null },
67+
fields: [
68+
{ name: 'title', label: 'Title', type: 'text' },
69+
{ name: 'due', label: 'Due Date', type: `field:${type}` },
70+
],
71+
onSubmit,
72+
}}
73+
/>,
74+
);
75+
}
76+
77+
async function dueBox(): Promise<HTMLInputElement> {
78+
return waitFor(() => {
79+
const input = document.querySelector('[data-field="due"] [data-slot="date-picker"] input');
80+
if (!input) throw new Error('date picker never rendered');
81+
return input as HTMLInputElement;
82+
});
83+
}
84+
85+
const typeAndLeave = (input: HTMLInputElement, text: string) => {
86+
fireEvent.change(input, { target: { value: text } });
87+
fireEvent.blur(input);
88+
};
89+
90+
const dueRow = () => document.querySelector('[data-field="due"]') as HTMLElement;
91+
92+
/** Press Create and wait for the refusal to land: the form scrolls to its first invalid field. */
93+
async function submitAndSeeRefusal() {
94+
const scroll = vi.spyOn(Element.prototype, 'scrollIntoView');
95+
fireEvent.click(screen.getByRole('button', { name: /create/i }));
96+
await waitFor(() => expect(scroll).toHaveBeenCalled());
97+
}
98+
99+
describe('an unreadable typed date refuses submit (objectstack#27483)', () => {
100+
it('13/45/2026 marks the field invalid and sends nothing', async () => {
101+
const onSubmit = vi.fn();
102+
renderForm('date', onSubmit);
103+
const input = await dueBox();
104+
105+
typeAndLeave(input, '13/45/2026');
106+
// Shown on leaving the field, before any submit: the form's own message,
107+
// naming the format, and the picker's notice is not drawn beside it.
108+
await waitFor(() => expect(dueRow().textContent).toContain('MM/DD/YYYY'));
109+
expect(dueRow().querySelector('[data-testid="date-picker-notice"]')).toBeNull();
110+
expect(input).toHaveAttribute('aria-invalid', 'true');
111+
112+
await submitAndSeeRefusal();
113+
expect(onSubmit).not.toHaveBeenCalled();
114+
expect(input.value).toBe('13/45/2026');
115+
});
116+
117+
it('correcting the text clears the error and the typed day is saved', async () => {
118+
const onSubmit = vi.fn();
119+
renderForm('date', onSubmit);
120+
const input = await dueBox();
121+
122+
typeAndLeave(input, '13/45/2026');
123+
await waitFor(() => expect(input).toHaveAttribute('aria-invalid', 'true'));
124+
125+
typeAndLeave(input, '10/20/2026');
126+
await waitFor(() => expect(dueRow().textContent).not.toContain('MM/DD/YYYY'));
127+
expect(input).toHaveAttribute('aria-invalid', 'false');
128+
129+
fireEvent.click(screen.getByRole('button', { name: /create/i }));
130+
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
131+
expect(onSubmit.mock.calls[0][0]).toMatchObject({ title: 'Walkthrough task', due: '2026-10-20' });
132+
});
133+
134+
it('clearing the bad text withdraws the error and an empty optional date saves', async () => {
135+
const onSubmit = vi.fn();
136+
renderForm('date', onSubmit);
137+
const input = await dueBox();
138+
139+
typeAndLeave(input, '13/45/2026');
140+
await waitFor(() => expect(input).toHaveAttribute('aria-invalid', 'true'));
141+
142+
fireEvent.click(screen.getByTestId('date-picker-clear'));
143+
await waitFor(() => expect(input).toHaveAttribute('aria-invalid', 'false'));
144+
145+
fireEvent.click(screen.getByRole('button', { name: /create/i }));
146+
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
147+
});
148+
149+
it('a valid typed date saves untouched (control)', async () => {
150+
const onSubmit = vi.fn();
151+
renderForm('date', onSubmit);
152+
typeAndLeave(await dueBox(), '10/20/2026');
153+
154+
fireEvent.click(screen.getByRole('button', { name: /create/i }));
155+
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
156+
expect(onSubmit.mock.calls[0][0]).toMatchObject({ due: '2026-10-20' });
157+
});
158+
159+
it('datetime: an unreadable date and time refuses submit the same way', async () => {
160+
const onSubmit = vi.fn();
161+
renderForm('datetime', onSubmit);
162+
const input = await dueBox();
163+
164+
typeAndLeave(input, '13/45/2026 25:99');
165+
await waitFor(() => expect(input).toHaveAttribute('aria-invalid', 'true'));
166+
167+
await submitAndSeeRefusal();
168+
expect(onSubmit).not.toHaveBeenCalled();
169+
});
170+
});

0 commit comments

Comments
 (0)