From a429afeab4a223e54c3cbe2d91ae1bb00bd541e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 19:30:30 +0000 Subject: [PATCH 1/4] fix(components): date / datetime filter rows draw the host-registered date widget (objectui#12743) FilterBuilder resolves `field:date` / `field:datetime` from ComponentRegistry at render time, the way the form renderer in this package resolves `field:`. @object-ui/fields registers the platform picker there behind its lazy loader, so this package stays fields-free and the picker stays in its own chunk. With nothing registered the row keeps the native input. The row's value keeps its shape (YYYY-MM-DD; the zoneless local YYYY-MM-DDTHH:mm for datetime). A pointer press outside the editor blurs it first, so the picker's commit-on-blur reads the typed text before a host popover closes. Escape discards. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB --- ...ter-builder-date-value-seam-12743.test.tsx | 214 +++++++++++++++++ .../components/src/custom/filter-builder.tsx | 218 ++++++++++++++++-- 2 files changed, 411 insertions(+), 21 deletions(-) create mode 100644 packages/components/src/__tests__/filter-builder-date-value-seam-12743.test.tsx diff --git a/packages/components/src/__tests__/filter-builder-date-value-seam-12743.test.tsx b/packages/components/src/__tests__/filter-builder-date-value-seam-12743.test.tsx new file mode 100644 index 0000000000..84c8448aa4 --- /dev/null +++ b/packages/components/src/__tests__/filter-builder-date-value-seam-12743.test.tsx @@ -0,0 +1,214 @@ +/** + * 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. + */ + +/** + * objectui#12743 — the seam: a `date` / `datetime` filter row draws the field + * widget the HOST registered as `field:date` / `field:datetime`. + * + * `packages/components` does not depend on `@object-ui/fields` (that package + * depends on this one), so the widget here is a stand-in with the platform + * picker's commit behaviour: it reads its text on blur and on Enter, never per + * keystroke, and its `datetime` face writes an ISO instant, as a field's + * stored shape is. The real picker, through the console's list toolbar, is + * pinned in `plugin-list` (`ListView.filterDatePicker-12743.test.tsx`). + * + * What is pinned: + * - with the widget registered, a date row (scalar and both `between` bounds) + * draws it, and no `input[type=date]`; + * - the row's value keeps its shape: `YYYY-MM-DD` for date, the zoneless + * local `YYYY-MM-DDTHH:mm` for datetime (what `datetime-local` wrote); + * - a typed day reaches the row on blur, on Enter, and on a pointer press + * outside the row while the box holds focus (the press blurs it first, so a + * popover that closes on that press cannot drop the text); + * - CONTROL: a text and a number row draw what they drew before, and with + * nothing registered a date row keeps the native input. + */ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import React from 'react'; +import { render, fireEvent, cleanup, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { ComponentRegistry } from '@object-ui/core'; + +import { FilterBuilder, type FilterBuilderRowGroup } from '../custom/filter-builder'; + +/** Commit-on-blur stand-in for the platform picker. */ +function makeStandIn(withTime: boolean) { + const StandIn = ({ id, value, onChange }: { id?: string; value?: string; onChange: (next: string) => void }) => { + const [text, setText] = React.useState(value ?? ''); + const commit = () => { + if (text === (value ?? '')) return; + onChange(withTime && text ? new Date(text).toISOString() : text); + }; + return ( + setText(e.target.value)} + onBlur={commit} + onKeyDown={(e) => { + if (e.key === 'Enter') commit(); + }} + /> + ); + }; + return StandIn; +} + +function registerStandIns() { + ComponentRegistry.register('date', makeStandIn(false), { namespace: 'field' }); + ComponentRegistry.register('datetime', makeStandIn(true), { namespace: 'field' }); +} + +afterEach(() => { + cleanup(); + ComponentRegistry.unregister('date', 'field'); + ComponentRegistry.unregister('datetime', 'field'); +}); + +const FIELDS = [ + { value: 'due', label: 'Due', type: 'date' }, + { value: 'remind_at', label: 'Remind at', type: 'datetime' }, + { value: 'name', label: 'Name', type: 'text' }, + { value: 'amount', label: 'Amount', type: 'number' }, +]; + +function group(field: string, operator: string, value: unknown): FilterBuilderRowGroup { + return { id: 'root', logic: 'and', conditions: [{ id: 'c1', field, operator, value }] } as FilterBuilderRowGroup; +} + +function mount(value: FilterBuilderRowGroup) { + const onChange = vi.fn(); + const utils = render( +
+ + +
, + ); + return { ...utils, onChange }; +} + +/** The value the last emitted group carries on its one row. */ +const lastValue = (onChange: ReturnType) => + (onChange.mock.calls.at(-1)?.[0] as FilterBuilderRowGroup).conditions[0].value; + +/** The registered widget arrives through the editor's own `Suspense`. */ +const findPickers = () => screen.findAllByTestId('stand-in-picker'); + +function typeInto(input: HTMLElement, text: string) { + input.focus(); + fireEvent.change(input, { target: { value: text } }); +} + +describe('FilterBuilder date rows draw the host-registered date widget (objectui#12743)', () => { + it('a date row draws the registered widget, not input[type=date]', async () => { + registerStandIns(); + const { container } = mount(group('due', 'equals', '2026-10-01')); + const [picker] = await findPickers(); + expect((picker as HTMLInputElement).value).toBe('2026-10-01'); + expect(container.querySelector('input[type="date"]')).toBeNull(); + // Its accessible name rides on the editor's hidden label. + expect(screen.getByLabelText('Value')).toBe(picker); + }); + + it('typed day + blur writes YYYY-MM-DD', async () => { + registerStandIns(); + const { onChange } = mount(group('due', 'equals', '')); + const [picker] = await findPickers(); + typeInto(picker, '2026-10-20'); + expect(onChange).not.toHaveBeenCalled(); + fireEvent.blur(picker); + expect(lastValue(onChange)).toBe('2026-10-20'); + }); + + it('typed day + Enter writes YYYY-MM-DD', async () => { + registerStandIns(); + const { onChange } = mount(group('due', 'equals', '')); + const [picker] = await findPickers(); + typeInto(picker, '2026-10-20'); + fireEvent.keyDown(picker, { key: 'Enter' }); + expect(lastValue(onChange)).toBe('2026-10-20'); + }); + + it('typed day + a pointer press outside: the press blurs the box first, so the day is written', async () => { + registerStandIns(); + const { onChange } = mount(group('due', 'equals', '')); + const [picker] = await findPickers(); + typeInto(picker, '2026-10-20'); + // `fireEvent.pointerDown` moves no focus; only the editor's capture + // listener can have blurred the box. + fireEvent.pointerDown(screen.getByTestId('outside')); + expect(picker).not.toHaveFocus(); + expect(lastValue(onChange)).toBe('2026-10-20'); + }); + + it('a pointer press INSIDE the editor does not blur it', async () => { + registerStandIns(); + const { onChange } = mount(group('due', 'equals', '')); + const [picker] = await findPickers(); + typeInto(picker, '2026-10-20'); + fireEvent.pointerDown(picker); + expect(picker).toHaveFocus(); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('Escape discards: nothing is written, also after a later outside press', async () => { + registerStandIns(); + const { onChange } = mount(group('due', 'equals', '')); + const [picker] = await findPickers(); + typeInto(picker, '2026-10-20'); + fireEvent.keyDown(picker, { key: 'Escape' }); + // The host closes on Escape and the row unmounts with the text unread. + cleanup(); + fireEvent.pointerDown(document.body); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('a datetime row keeps the zoneless local shape datetime-local wrote', async () => { + registerStandIns(); + const { onChange } = mount(group('remind_at', 'equals', '2026-10-01T09:15')); + const [picker] = await findPickers(); + expect((picker as HTMLInputElement).value).toBe('2026-10-01T09:15'); + // The stand-in writes the ISO instant of this local wall clock. + typeInto(picker, '2026-10-20T14:30'); + fireEvent.blur(picker); + expect(lastValue(onChange)).toBe('2026-10-20T14:30'); + }); + + it('`between` on a date column draws the widget for BOTH bounds, each named', async () => { + registerStandIns(); + const { container, onChange } = mount(group('due', 'between', [])); + const pickers = await findPickers(); + expect(pickers).toHaveLength(2); + expect(container.querySelector('input[type="date"]')).toBeNull(); + expect(screen.getByLabelText('From')).toBe(pickers[0]); + expect(screen.getByLabelText('To')).toBe(pickers[1]); + typeInto(pickers[0], '2026-10-01'); + fireEvent.blur(pickers[0]); + expect(lastValue(onChange)).toEqual(['2026-10-01', '']); + }); + + it('CONTROL: text and number rows draw what they drew before', async () => { + registerStandIns(); + const text = mount(group('name', 'equals', 'acme')); + expect(text.container.querySelector('input[type="text"]')).toHaveValue('acme'); + expect(screen.queryByTestId('stand-in-picker')).toBeNull(); + cleanup(); + const number = mount(group('amount', 'equals', 5)); + expect(number.container.querySelector('input[type="number"]')).toHaveValue(5); + expect(screen.queryByTestId('stand-in-picker')).toBeNull(); + }); + + it('CONTROL: with nothing registered, a date row keeps the native input', () => { + const { container } = mount(group('due', 'equals', '2026-10-01')); + expect(container.querySelector('input[type="date"]')).toHaveValue('2026-10-01'); + expect(screen.queryByTestId('stand-in-picker')).toBeNull(); + }); +}); diff --git a/packages/components/src/custom/filter-builder.tsx b/packages/components/src/custom/filter-builder.tsx index 1fbf67bcb6..3d6f94a539 100644 --- a/packages/components/src/custom/filter-builder.tsx +++ b/packages/components/src/custom/filter-builder.tsx @@ -27,7 +27,13 @@ import type { // argued with: `@object-ui/fields` DEPENDS on this package, so importing it // here is a cycle. `core` re-homes the table and `fields` re-exports it, so // both spellings resolve to the SAME function and the SAME dedupe set. -import { isRealCalendarDate, isRetiredFieldType, reportRetiredFieldType } from "@object-ui/core" +import { + ComponentRegistry, + isRealCalendarDate, + isRetiredFieldType, + reportRetiredFieldType, + toDateTimeInputValue, +} from "@object-ui/core" import { createSafeTranslation } from "@object-ui/i18n" import { cn } from "../lib/utils" @@ -1738,6 +1744,13 @@ function FilterBuilder({ ) } + // The platform date picker on a `date` / `datetime` column (objectui#12743), + // when the host registered one; see {@link registeredTemporalWidget}. + const valueFamily = valueFamilyForFieldType(field?.type) + const temporalWidget = registeredTemporalWidget(valueFamily) + const temporalFamily: TemporalValueFamily | undefined = + valueFamily === "date" || valueFamily === "datetime" ? valueFamily : undefined + // A PAIR operator (`between`) — two bounds, in order. One input could only // ever produce one of them, and the spec refuses an incomplete range as // firmly as it refuses a scalar for `in`. @@ -1813,30 +1826,47 @@ function FilterBuilder({ value: next[0] === "" && next[1] === "" ? [] : next, }) } + const renderBound = (index: 0 | 1) => { + const boundLabel = t(index === 0 ? 'filterBuilder.rangeStart' : 'filterBuilder.rangeEnd') + // A `date` / `datetime` bound is the host's registered date widget + // (objectui#12743). Its placeholder is the display locale's pattern, + // so the bound's name rides on a hidden label instead. The incomplete + // marker reaches it as `error`, which is what drives `aria-invalid` + // on a field widget. + if (temporalWidget && temporalFamily) { + return ( + setBound(index, next)} + label={boundLabel} + error={missingBound === index ? t('validation.required', { field: incompleteLabel }) : undefined} + className={missingBound === index ? "border-destructive" : undefined} + testId={`filter-range-${condition.field}-${index === 0 ? 'start' : 'end'}`} + /> + ) + } + return ( + setBound(index, e.target.value)} + /> + ) + } return (
- setBound(0, e.target.value)} - /> + {renderBound(0)} - - setBound(1, e.target.value)} - /> + {renderBound(1)}
{missingBound !== null && ( // Visible, not `sr-only`: the drop this announces is invisible to a @@ -1926,6 +1956,20 @@ function FilterBuilder({ ) } + if (temporalWidget && temporalFamily) { + return ( + updateCondition(condition.id, { value: next })} + label={t('filterBuilder.value')} + testId={`filter-value-${condition.field}`} + /> + ) + } + // Default input for text, number, date const inputType = getInputType(condition.field) @@ -2100,6 +2144,138 @@ function FilterBuilder({ FilterBuilder.displayName = "FilterBuilder" +// ============================================================================ +// TemporalValueEditor — the platform date picker on a `date` / `datetime` row +// ============================================================================ + +/** The value families whose rows draw the host's registered date widget. */ +type TemporalValueFamily = Extract + +/** + * The field widget the HOST registered for a `date` / `datetime` value + * (objectui#12743), or `undefined` when none is registered. + * + * Read from `ComponentRegistry` under the `field` namespace at render time, + * exactly as the form renderer in this package resolves `field:` + * (`renderFieldComponent` in `renderers/form/form.tsx`). `@object-ui/fields` + * registers the platform picker there (`DatePickerField` / + * `DateTimePickerField`, behind a `React.lazy` loader, so it stays in its own + * `fields-date-picker` chunk), and it depends on THIS package, so importing it + * here would be a cycle. The registry is the seam: this package stays + * fields-free and the host fills it. + * + * With nothing registered (a host that does not load `@object-ui/fields`) + * the row keeps the native input, as the form does. + */ +function registeredTemporalWidget( + family: FilterValueFamily, +): React.ComponentType> | undefined { + if (family !== "date" && family !== "datetime") return undefined + return ComponentRegistry.get(family, "field") as React.ComponentType> | undefined +} + +interface TemporalValueEditorProps { + widget: React.ComponentType> + family: TemporalValueFamily + /** The column the row filters on: the widget's `field` carrier. */ + field: { value: string; label: string } + value: FilterBuilderRow["value"] | undefined + /** Receives the value in the row's own shape (see the component's doc). */ + onChange: (next: string) => void + /** The accessible name of the text box, on a visually hidden `