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
12 changes: 12 additions & 0 deletions .changeset/12784-user-filter-date-range-picker.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@object-ui/plugin-list': patch
---

A list view's `date-range` user-filter chip draws the platform date picker on both bounds, and names them in the console's language (objectui#12784). It used to draw two native date inputs, which take their placeholder and digit order from the machine's locale, labelled with the English literals "From" and "To".

- **How it gets there, with no new dependency.** Each bound resolves the `field:date` widget from `ComponentRegistry` at render time, the seam the list Filter panel and `filter-ui` use since objectui#12743. `@object-ui/fields` registers the picker there behind its lazy loader. With no `field:date` widget registered, a bound keeps the native input.
- **The labels.** The bounds reuse the language packs' existing `filterBuilder.rangeStart` / `filterBuilder.rangeEnd` keys, which all ten packs already carry.
- **The value written is unchanged.** Each bound writes `YYYY-MM-DD`, and the chip writes `[]` when both bounds are empty, so the list query gets the same `>=` / `<=` conditions as before.
- **A typed day is applied.** The picker reads its text on blur and on Enter. A pointer press outside the bound while it holds focus blurs it first, so the text is read before the chip's popover closes on that press. Escape discards.

Clause-②: no — no prop, export, dependency or language-pack key is added or removed.
134 changes: 113 additions & 21 deletions packages/plugin-list/src/UserFilters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ import { cn, Button, Input, Popover, PopoverContent, PopoverTrigger, LookupValue
import { ChevronDown, X, Plus } from 'lucide-react';
import type { ListViewSchema } from '@object-ui/types';
import { normalizeFilterOperator } from '@objectstack/spec/ui';
import { useSafeFieldLabel, useObjectTranslation } from '@object-ui/i18n';
import { ComponentRegistry } from '@object-ui/core';
import { useSafeFieldLabel, useObjectTranslation, useSafeTranslate } from '@object-ui/i18n';
// THE GATE the maintainer ruled onto `@object-ui/fields`' surface
// (objectui#4914, ruling B); the implementation is homed in
// `@object-ui/core` and re-exported there, so both spellings are one
Expand All @@ -27,6 +28,87 @@ function useMoreLabel(): string {
return !v || v === 'common.more' ? 'More' : v;
}

/**
* One bound of an authored `date-range` chip (objectui#12784): the field
* widget the HOST registered as `field:date`.
*
* Resolved from `ComponentRegistry` at render time, the seam `FilterBuilder`'s
* date rows and `filter-ui`'s date filters use since objectui#12743.
* `@object-ui/fields` registers the platform picker there (a text box in the
* console locale's format with a calendar popover) behind a lazy loader that
* keeps it in its own chunk. With nothing registered the bound keeps the
* native `<input type="date">`.
*
* The value is `YYYY-MM-DD`, as the native input wrote. The picker reads its
* typed text on blur and on Enter; a pointer press outside the bound while it
* holds focus blurs it first (a capture listener), so the text is read before
* the chip's popover closes on that press. Escape does not blur: it discards.
*/
function DateBoundInput({
testId,
field,
label,
value,
onChange,
}: {
testId: string;
field: string;
label: string;
value: string;
onChange: (next: string) => void;
}) {
const Widget = ComponentRegistry.get('date', 'field') as React.ComponentType<Record<string, unknown>> | undefined;
const rootRef = React.useRef<HTMLDivElement | null>(null);
const inputId = React.useId();

React.useEffect(() => {
if (!Widget || typeof document === 'undefined') return;
const onPointerDown = (event: PointerEvent) => {
const root = rootRef.current;
if (!root) return;
const focused = document.activeElement;
if (!(focused instanceof HTMLElement) || !root.contains(focused)) return;
if (event.target instanceof Node && root.contains(event.target)) return;
focused.blur();
};
document.addEventListener('pointerdown', onPointerDown, true);
return () => document.removeEventListener('pointerdown', onPointerDown, true);
}, [Widget]);

if (!Widget) {
return (
<input
id={inputId}
type="date"
aria-label={label}
data-testid={testId}
value={value}
onChange={(e) => onChange(e.target.value)}
className="h-7 min-w-0 flex-1 rounded border border-input bg-background px-2 text-xs text-foreground"
/>
);
}

return (
<div ref={rootRef} className="min-w-0 flex-1" data-testid={testId}>
<label htmlFor={inputId} className="sr-only">
{label}
</label>
<React.Suspense
fallback={<div aria-busy="true" className="h-8 w-full rounded-md border border-input bg-muted/40" />}
>
<Widget
id={inputId}
field={{ name: field, label, type: 'date' }}
value={value}
onChange={(next: unknown) => onChange(typeof next === 'string' ? next : '')}
className="h-8 text-xs"
/>
</React.Suspense>
</div>
);
}

/** Resolved option with optional count */
interface ResolvedOption {
label: string;
Expand Down Expand Up @@ -458,6 +540,7 @@ function typingOf(field: ResolvedField): string {
function DropdownFilters({ fields, objectDef, data, onFilterChange, maxVisible, className, initialSelections, onSelectionsChange }: DropdownFiltersProps) {
const { fieldLabel, translateOptions } = useSafeFieldLabel();
const moreLabel = useMoreLabel();
const tt = useSafeTranslate();
const objectName: string | undefined = objectDef?.name;
// Control kind per field, from the AUTHORED type only. Keyed off the raw
// config, not the resolved field: `resolveFields` back-fills `type` from
Expand Down Expand Up @@ -655,7 +738,7 @@ function DropdownFilters({ fields, objectDef, data, onFilterChange, maxVisible,
LOOKUP_LIKE_TYPES.has(f.type || '') &&
f.options.length === 0 &&
(f.referenceTo || f.type === 'user');
const popoverWidth = isLookupLike ? 'w-72' : 'w-56';
const popoverWidth = isLookupLike || kind === 'range' ? 'w-72' : 'w-56';

return (
<Popover key={f.field}>
Expand Down Expand Up @@ -722,26 +805,35 @@ function DropdownFilters({ fields, objectDef, data, onFilterChange, maxVisible,
) : kind === 'range' ? (
// Authored `type: 'date-range'` — a from/to day-granularity pair.
// Used to render the literal "No options" dead control (#2942).
// Each bound is the platform date picker when the host registered
// one (objectui#12784); the labels are the filter builder's range
// bounds, translated in every pack.
<div className="space-y-2 p-1" data-testid={`filter-range-${f.field}`}>
{([0, 1] as const).map((slot) => (
<label key={slot} className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="w-8 shrink-0">{slot === 0 ? 'From' : 'To'}</span>
<input
type="date"
data-testid={`filter-range-${f.field}-${slot === 0 ? 'from' : 'to'}`}
value={String(selected[slot] ?? '')}
onChange={(e) => {
const next: (string | number | boolean)[] = [
String(selected[0] ?? ''),
String(selected[1] ?? ''),
];
next[slot] = e.target.value;
handleChange(f.field, next[0] === '' && next[1] === '' ? [] : next);
}}
className="h-7 flex-1 rounded border border-input bg-background px-2 text-xs text-foreground"
/>
</label>
))}
{([0, 1] as const).map((slot) => {
const boundLabel =
slot === 0 ? tt('filterBuilder.rangeStart', 'From') : tt('filterBuilder.rangeEnd', 'To');
return (
<div key={slot} className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="min-w-8 shrink-0 whitespace-nowrap" aria-hidden="true">
{boundLabel}
</span>
<DateBoundInput
testId={`filter-range-${f.field}-${slot === 0 ? 'from' : 'to'}`}
field={`${f.field}-${slot === 0 ? 'from' : 'to'}`}
label={boundLabel}
value={String(selected[slot] ?? '')}
onChange={(value) => {
const next: (string | number | boolean)[] = [
String(selected[0] ?? ''),
String(selected[1] ?? ''),
];
next[slot] = value;
handleChange(f.field, next[0] === '' && next[1] === '' ? [] : next);
}}
/>
</div>
);
})}
</div>
) : kind === 'text' ? (
// Authored `type: 'text'` — a contains query, committed on Enter /
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
/**
* 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#12784 — an authored `date-range` user-filter chip draws the
* platform date picker on both bounds, labelled from the language packs.
*
* `@object-ui/fields` is registered as the console registers it, so each
* bound resolves the host's `field:date` widget from `ComponentRegistry` (the
* seam the list Filter panel uses since objectui#12743) and the picker
* arrives through its own lazy chunk.
*
* Pinned:
* - (a) both bounds draw the picker, not `input[type=date]`; in a `zh`
* session the placeholder and digit order are `YYYY/MM/DD` and the bounds
* are named by the pack's `filterBuilder.rangeStart` / `rangeEnd`;
* - (b) a typed bound applies on Enter, on blur, and on a press outside the
* chip (which closes it), with the same `YYYY-MM-DD` conditions the native
* pair emitted; clearing both bounds writes `[]`;
* - (c) CONTROL: the select, multi-select and text chips draw what they drew
* before.
*
* The native fallback (nothing registered) keeps the pair of
* `input[type=date]`, pinned in `user-filter-arity-spec-parity.test.tsx`.
*/
import * as React from 'react';
import { describe, it, expect, vi, beforeAll } from 'vitest';
import { render, screen, waitFor, fireEvent, within } from '@testing-library/react';
import '@testing-library/jest-dom';
import { I18nProvider } from '@object-ui/i18n';
import { registerAllFields } from '@object-ui/fields';
import { UserFilters } from '../UserFilters';

registerAllFields();

beforeAll(() => {
if (!Element.prototype.scrollIntoView) {
Element.prototype.scrollIntoView = vi.fn() as any;
}
// Radix popover uses pointer capture; the DOM test environment lacks these.
if (!(Element.prototype as any).hasPointerCapture) {
(Element.prototype as any).hasPointerCapture = () => false;
}
if (!(Element.prototype as any).setPointerCapture) {
(Element.prototype as any).setPointerCapture = () => {};
}
if (!(Element.prototype as any).releasePointerCapture) {
(Element.prototype as any).releasePointerCapture = () => {};
}
});

const objectDef = {
name: 'tasks',
fields: {
due_date: { type: 'date', label: 'Due' },
status: {
type: 'select',
label: 'Status',
options: [
{ label: 'To Do', value: 'todo' },
{ label: 'Done', value: 'done' },
],
},
name: { type: 'text', label: 'Name' },
},
};

function mount(fields: Array<Record<string, unknown>>, language?: string) {
const onFilterChange = vi.fn();
const filters = (
<div>
<button type="button" data-testid="outside">
outside
</button>
<UserFilters
config={{ element: 'dropdown', fields } as any}
objectDef={objectDef}
data={[]}
onFilterChange={onFilterChange}
/>
</div>
);
render(
language ? (
<I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false }} persistLanguage={false}>
{filters}
</I18nProvider>
) : (
filters
),
);
return { onFilterChange };
}

const RANGE = [{ field: 'due_date', type: 'date-range' }];

/** Open the chip and wait for both lazily loaded picker text boxes. */
async function openRange(): Promise<{ range: HTMLElement; from: HTMLInputElement; to: HTMLInputElement }> {
fireEvent.click(await screen.findByTestId('filter-badge-due_date'));
const range = await screen.findByTestId('filter-range-due_date');
const [from, to] = await waitFor(
() => {
const inputs = range.querySelectorAll('[data-slot="date-picker"] input');
if (inputs.length !== 2) throw new Error('date pickers not rendered');
return Array.from(inputs) as HTMLInputElement[];
},
{ timeout: 5000 },
);
return { range, from, to };
}

function typeInto(input: HTMLInputElement, text: string) {
input.focus();
fireEvent.change(input, { target: { value: text } });
}

describe('UserFilters date-range chip draws the platform date picker (objectui#12784)', () => {
it('(a) both bounds draw the picker, not input[type=date], named From / To', async () => {
mount(RANGE);
const { range, from, to } = await openRange();
expect(range.querySelector('input[type="date"]')).toBeNull();
expect(from.type).toBe('text');
expect(from.placeholder).toBe('MM/DD/YYYY');
expect(screen.getByLabelText('From')).toBe(from);
expect(screen.getByLabelText('To')).toBe(to);
});

it('(a) zh session: the console-locale placeholder, and the bound labels from the zh pack', async () => {
mount(RANGE, 'zh');
const { from, to } = await openRange();
expect(from.placeholder).toBe('YYYY/MM/DD');
expect(to.placeholder).toBe('YYYY/MM/DD');
expect(screen.getByLabelText('起始值')).toBe(from);
expect(screen.getByLabelText('结束值')).toBe(to);
expect(screen.queryByText('From')).toBeNull();
expect(screen.queryByText('To')).toBeNull();
});

it('(b) a typed From applies on Enter, a typed To on blur, with YYYY-MM-DD bounds', async () => {
const { onFilterChange } = mount(RANGE);
const { from, to } = await openRange();
typeInto(from, '10/01/2026');
expect(onFilterChange).not.toHaveBeenCalled();
fireEvent.keyDown(from, { key: 'Enter' });
expect(onFilterChange).toHaveBeenLastCalledWith([['due_date', '>=', '2026-10-01']]);

typeInto(to, '10/31/2026');
fireEvent.blur(to);
expect(onFilterChange).toHaveBeenLastCalledWith([
['due_date', '>=', '2026-10-01'],
['due_date', '<=', '2026-10-31'],
]);
});

it('(b) zh session: a day typed in the console format applies as YYYY-MM-DD', async () => {
const { onFilterChange } = mount(RANGE, 'zh');
const { to } = await openRange();
typeInto(to, '2026/10/31');
fireEvent.keyDown(to, { key: 'Enter' });
expect(onFilterChange).toHaveBeenLastCalledWith([['due_date', '<=', '2026-10-31']]);
});

it('(b) a typed bound + a press outside: the chip closes and the day is applied', async () => {
const { onFilterChange } = mount(RANGE);
const { from } = await openRange();
typeInto(from, '10/20/2026');
// `fireEvent` moves no focus, so only the bound's capture listener (the
// pointerdown blur) can have read the typed text before Radix dismisses
// the popover on the click that follows.
const outside = screen.getByTestId('outside');
fireEvent.pointerDown(outside);
fireEvent.click(outside);
await waitFor(() => expect(screen.queryByTestId('filter-range-due_date')).toBeNull());
expect(onFilterChange).toHaveBeenLastCalledWith([['due_date', '>=', '2026-10-20']]);
});

it('(b) clearing the only set bound writes no condition', async () => {
const { onFilterChange } = mount(RANGE);
const { range, from } = await openRange();
typeInto(from, '10/20/2026');
fireEvent.keyDown(from, { key: 'Enter' });
expect(onFilterChange).toHaveBeenLastCalledWith([['due_date', '>=', '2026-10-20']]);
fireEvent.click(within(range).getByTestId('date-picker-clear'));
expect(onFilterChange).toHaveBeenLastCalledWith([]);
});

it('(c) CONTROL: select, multi-select and text chips draw what they drew before', async () => {
mount([
{ field: 'status', type: 'select' },
{ field: 'name', type: 'text' },
]);
fireEvent.click(screen.getByTestId('filter-badge-status'));
const options = await screen.findByTestId('filter-options-status');
expect(options.querySelectorAll('input[type="radio"]')).toHaveLength(2);
expect(options.querySelector('[data-slot="date-picker"]')).toBeNull();
fireEvent.click(screen.getByTestId('filter-badge-name'));
expect(await screen.findByTestId('filter-text-name')).toHaveAttribute('type', 'search');
});

it('(c) CONTROL: a multi-select chip keeps its checkboxes', async () => {
mount([{ field: 'status', type: 'multi-select' }]);
fireEvent.click(screen.getByTestId('filter-badge-status'));
const options = await screen.findByTestId('filter-options-status');
expect(options.querySelectorAll('input[type="checkbox"]')).toHaveLength(2);
});
});
Loading
Loading