From c4b7a4590372f84dfdfbdfbe20f3a3ca03cd853f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 13:25:34 +0000 Subject: [PATCH 1/5] fix(fields,components): an unreadable typed date is a field error that refuses submit The date / datetime picker never writes text that names no date, so the form kept its old value and saved it as if nothing had been typed. The picker now reports such text through a new onInvalidInputChange widget prop, and the form renderer turns the report into the field's validation error, which refuses submit the way a missing required value does. Claude-Session: https://claude.ai/code/session_01Utp6CXAtdK85r4832Kydbf Co-authored-by: Claude --- ...nvalid-input-refuses-submit-27483.test.tsx | 110 ++++++++++++ .../components/src/renderers/form/form.tsx | 29 +++ ...-invalid-text-blocks-submit-27483.test.tsx | 170 ++++++++++++++++++ .../DatePickerField-12622.test.tsx | 49 +++++ .../widgets/date-picker/DatePickerField.tsx | 28 ++- ui/packages/fields/src/widgets/toHostProps.ts | 3 + ui/packages/fields/src/widgets/types.ts | 15 ++ 7 files changed, 401 insertions(+), 3 deletions(-) create mode 100644 ui/packages/components/src/renderers/form/__tests__/form-invalid-input-refuses-submit-27483.test.tsx create mode 100644 ui/packages/fields/src/__tests__/date-picker-invalid-text-blocks-submit-27483.test.tsx diff --git a/ui/packages/components/src/renderers/form/__tests__/form-invalid-input-refuses-submit-27483.test.tsx b/ui/packages/components/src/renderers/form/__tests__/form-invalid-input-refuses-submit-27483.test.tsx new file mode 100644 index 00000000000..132749aca47 --- /dev/null +++ b/ui/packages/components/src/renderers/form/__tests__/form-invalid-input-refuses-submit-27483.test.tsx @@ -0,0 +1,110 @@ +/** + * 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.'; + +function ReportingProbe(props: any) { + return ( +
+ + +
+ ); +} + +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 as any).scrollIntoView) { + (Element.prototype as any).scrollIntoView = () => {}; + } +}); + +function renderForm(onSubmit: (data: Record) => void) { + const Form = ComponentRegistry.get('form')!; + return render( +
, + ); +} + +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 as any, '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'); + }); +}); diff --git a/ui/packages/components/src/renderers/form/form.tsx b/ui/packages/components/src/renderers/form/form.tsx index 88b12b82310..c3d119a7bf0 100644 --- a/ui/packages/components/src/renderers/form/form.tsx +++ b/ui/packages/components/src/renderers/form/form.tsx @@ -1219,6 +1219,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 @@ -1253,9 +1254,15 @@ function stripRegisteredFieldProps(type: string, props: RenderFieldProps): Rende // leftover props onto a DOM node, where an unknown `emptyHint` attribute is // a React warning — hence an allow-list, not an unconditional pass-through. ...(CASCADE_OPTION_WIDGET_TYPES.has(normalizedType) ? { dependentValues, emptyHint } : {}), + // 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 = new Set(['date', 'datetime']); + /** * FullscreenTextarea — `