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/12966-searchless-object.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@object-ui/plugin-list': patch
'@object-ui/fields': patch
---

The list's quick-search box and the record pickers' type-ahead are no longer offered on an object whose search resolves to no field, and a refused search shows the server's message (objectui#12966, the console half of objectstack#24284).

objectstack now answers a non-blank `search` over such an object with `400 INVALID_FIELD` (`param: 'search'`) instead of every row. The common shape is a junction object: two lookups and a composed formula title, with no `searchableFields` declared.

- **The list toolbar** decides with the spec's `resolveSearchFieldResolution`, over the object definition with `internal: true` fields left out and the object's own `searchableFields`, the inputs the engine uses. When the set is empty the search box is not drawn, and a term restored at mount is not applied. Until the definition is read the box is drawn, as before; the list is loading for that read anyway, since its data fetch waits on it. A list whose data source serves no definition keeps the box. A view's `searchableFields` does not make such an object searchable: the server refuses that override outside the object's set.
- **`RecordPickerDialog` and `LookupField`'s inline dropdown** draw no search box on a target whose set is empty. They still list the records, and the dialog still pages them. An unknown target schema keeps the box.
- **A refused search** on a list now shows the server's sentence, which names the object and the two remedies, in place of the generic "query was rejected" copy. The record pickers already showed the server's message.

Nothing is added to either package entry: no export, prop or type member.
55 changes: 33 additions & 22 deletions packages/fields/src/widgets/LookupField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { RecordPickerDialog, lookupFiltersToRecord } from './RecordPickerDialog.
import type { RecordPickerFilterColumn } from './RecordPickerDialog.js';
import { PeoplePicker } from './PeoplePicker.js';
import { useRecordQuery } from './useRecordQuery.js';
import { objectResolvesSearch } from './searchOffer.js';
import { deriveLookupColumns } from './deriveLookupColumns.js';
import { buildExpandFields, getRecordDisplayName, mergeFilterNodes, resolveAffordance, toPredicateRecord, withoutDeniedFields } from '@object-ui/core';
import { getRecentLookupIds, pushRecentLookupId } from './recentLookups.js';
Expand Down Expand Up @@ -736,6 +737,12 @@ export function LookupField({ value, onChange, field, readonly, error: fieldErro
if (!isOpen) setActiveIndex(-1);
}, [isOpen]);

// objectui#12966 — no type-ahead on a target whose search resolves to no
// field: the server refuses that search with `400 INVALID_FIELD`. The
// dropdown still lists the target's records. The target schema is read at
// mount (`refObjectSchema`); until it is known the box is kept, as before.
const searchOffered = objectResolvesSearch(refObjectSchema) !== false;

// Search is the kernel's debounced setter.
const handleSearchChange = useCallback(
(query: string) => popoverQuery.setSearch(query),
Expand Down Expand Up @@ -1546,30 +1553,34 @@ export function LookupField({ value, onChange, field, readonly, error: fieldErro
{triggerButton}
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start">
{/* Search input */}
<div className="p-2">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
placeholder={t('table.search')}
value={searchQuery}
onChange={(e) => handleSearchChange(e.target.value)}
onKeyDown={handleSearchKeyDown}
className="w-full pl-9 h-8 text-sm"
role="combobox"
aria-autocomplete="list"
aria-controls={listboxId}
aria-expanded={isOpen}
aria-activedescendant={activeIndex >= 0 ? `${listboxId}-opt-${activeIndex}` : undefined}
/>
{loading && (
<Loader2
className="absolute right-3 top-1/2 -translate-y-1/2 size-4 animate-spin text-muted-foreground"
data-testid="lookup-loading"
{/* Search input — not offered on a target whose search resolves to
no field (objectui#12966, see `searchOffered`); the options below
are buttons, so the list stays reachable without it. */}
{searchOffered && (
<div className="p-2">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
placeholder={t('table.search')}
value={searchQuery}
onChange={(e) => handleSearchChange(e.target.value)}
onKeyDown={handleSearchKeyDown}
className="w-full pl-9 h-8 text-sm"
role="combobox"
aria-autocomplete="list"
aria-controls={listboxId}
aria-expanded={isOpen}
aria-activedescendant={activeIndex >= 0 ? `${listboxId}-opt-${activeIndex}` : undefined}
/>
)}
{loading && (
<Loader2
className="absolute right-3 top-1/2 -translate-y-1/2 size-4 animate-spin text-muted-foreground"
data-testid="lookup-loading"
/>
)}
</div>
</div>
</div>
)}

{/* Error state */}
{error && (
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
/**
* 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#12966 — the record pickers offer no type-ahead on a target object
* whose `search` resolves to no field, and still list its records.
*
* objectstack answers a non-blank `search` over such an object with `400
* INVALID_FIELD` (`param: 'search'`). The common shape is a junction object:
* two lookups and a composed formula title.
*
* Pinned against the real `RecordPickerDialog` and the real `LookupField`
* inline dropdown:
*
* - (d) on a searchless target the dialog draws no search box, lists and
* pages the records, and sends no `$search`; the inline dropdown draws no
* type-ahead box and still offers the records;
* - CONTROL: a target with a text field keeps both boxes;
* - an unknown target schema keeps the box (behaviour before the card);
* - (e) where the box shows and the server refuses the term anyway, the
* dialog renders the refusal's own message, not an empty table.
*
* `objectResolvesSearch` itself is pinned in `searchOffer.test.ts`.
*/

import * as React from 'react';
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { render, screen, cleanup, waitFor, act, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import { RecordPickerDialog } from './RecordPickerDialog';
import { LookupField } from './LookupField';

afterEach(cleanup);

beforeEach(() => {
// jsdom has no matchMedia; the lookup's mobile check reads it. Desktop width.
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 1280 });
window.matchMedia = ((query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: () => {},
removeEventListener: () => {},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false,
})) as any;
try {
localStorage.clear();
} catch {
/* jsdom */
}
});

/** A junction object: two lookups and a composed formula title. */
const JUNCTION_FIELDS: Record<string, any> = {
book: { name: 'book', type: 'lookup', reference: 'lib_book', label: 'Book' },
reader: { name: 'reader', type: 'lookup', reference: 'lib_reader', label: 'Reader' },
title: { name: 'title', type: 'formula', label: 'Title' },
};
const SEARCHLESS = { name: 'lib_loan', nameField: 'title', fields: JUNCTION_FIELDS };
const WITH_TEXT = {
name: 'lib_loan',
fields: { ...JUNCTION_FIELDS, note: { name: 'note', type: 'text', label: 'Note' } },
};

const LOANS = Array.from({ length: 12 }, (_, i) => ({
id: `loan${i + 1}`,
title: `Loan ${i + 1}`,
book: `b${i + 1}`,
reader: 'r1',
}));

const REFUSAL_MESSAGE =
"Object 'lib_loan' has no searchable field, so 'search' cannot be honoured on it.";

function makeDataSource(schema: unknown, opts: { refuseSearch?: boolean } = {}) {
return {
find: vi.fn(async (_object: string, params?: Record<string, any>) => {
if (opts.refuseSearch && params?.$search) {
// The data adapter's raw-GET failure shape: message, code, status.
throw Object.assign(new Error(REFUSAL_MESSAGE), {
code: 'INVALID_FIELD',
status: 400,
httpStatus: 400,
});
}
const skip = params?.$skip ?? 0;
const top = params?.$top ?? LOANS.length;
return { data: LOANS.slice(skip, skip + top), total: LOANS.length };
}),
findOne: vi.fn(async () => null),
...(schema ? { getObjectSchema: vi.fn(async () => schema) } : {}),
} as any;
}

const searches = (ds: { find: ReturnType<typeof vi.fn> }) =>
ds.find.mock.calls.map((c: any[]) => c[1]?.$search);

function renderDialog(dataSource: any, objectSchema: unknown) {
return render(
<RecordPickerDialog
open
onOpenChange={() => {}}
dataSource={dataSource}
objectName="lib_loan"
displayField="title"
objectSchema={objectSchema as any}
pageSize={5}
onSelect={vi.fn()}
/>,
);
}

describe('objectui#12966 — RecordPickerDialog on a searchless target', () => {
it('(d) draws no search box, and still lists and pages the records', async () => {
const ds = makeDataSource(SEARCHLESS);
renderDialog(ds, SEARCHLESS);

await waitFor(() => expect(screen.getByText('Loan 1')).toBeInTheDocument());
expect(screen.queryByTestId('record-picker-search')).not.toBeInTheDocument();

// Paging still reads: page 2 asks for the next window.
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /next/i }));
});
await waitFor(() => expect(screen.getByText('Loan 6')).toBeInTheDocument());
expect(ds.find.mock.calls.at(-1)?.[1]?.$skip).toBe(5);
expect(searches(ds).every((s: unknown) => s === undefined)).toBe(true);
});

it('CONTROL: a target with a text field keeps the search box', async () => {
renderDialog(makeDataSource(WITH_TEXT), WITH_TEXT);
await waitFor(() => expect(screen.getByText('Loan 1')).toBeInTheDocument());
expect(screen.getByTestId('record-picker-search')).toBeInTheDocument();
});

it('an unknown target schema keeps the search box', async () => {
renderDialog(makeDataSource(null), undefined);
await waitFor(() => expect(screen.getByText('Loan 1')).toBeInTheDocument());
expect(screen.getByTestId('record-picker-search')).toBeInTheDocument();
});

it('(e) a refused term renders the refusal\'s own message, not an empty table', async () => {
const ds = makeDataSource(null, { refuseSearch: true });
renderDialog(ds, undefined);
await waitFor(() => expect(screen.getByText('Loan 1')).toBeInTheDocument());

fireEvent.change(screen.getByTestId('record-picker-search'), { target: { value: 'Dune' } });

const alert = await screen.findByRole('alert');
expect(alert).toHaveTextContent(REFUSAL_MESSAGE);
expect(screen.queryByText('Loan 1')).not.toBeInTheDocument();
});
});

const loanLookup = {
name: 'loan',
label: 'Loan',
reference: 'lib_loan',
reference_field: 'title',
} as any;

async function openLookup(dataSource: any) {
render(
<LookupField field={loanLookup} value={undefined} onChange={vi.fn()} readonly={false} dataSource={dataSource} />,
);
// The target schema is read at mount; let it land before opening.
await waitFor(() => expect(dataSource.getObjectSchema).toHaveBeenCalled());
await act(async () => {
await Promise.resolve();
});
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /select/i }));
});
await waitFor(() => expect(screen.queryAllByRole('option').length).toBeGreaterThan(0));
}

describe('objectui#12966 — LookupField inline dropdown on a searchless target', () => {
it('(d) draws no type-ahead box and still offers the records', async () => {
const ds = makeDataSource(SEARCHLESS);
await openLookup(ds);
expect(screen.queryByRole('combobox')).not.toBeInTheDocument();
expect(screen.getAllByRole('option')[0]).toHaveTextContent('Loan 1');
expect(searches(ds).every((s: unknown) => s === undefined)).toBe(true);
});

it('CONTROL: a target with a text field keeps the type-ahead box', async () => {
await openLookup(makeDataSource(WITH_TEXT));
expect(screen.getByRole('combobox')).toBeInTheDocument();
});
});
43 changes: 25 additions & 18 deletions packages/fields/src/widgets/RecordPickerDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import { usePermissions } from '@object-ui/permissions';
import { RelatedRecordActionsProvider, useRelatedRecordActions } from '@object-ui/react';
import { useFieldTranslation } from './useFieldTranslation.js';
import { useRecordQuery } from './useRecordQuery.js';
import { objectResolvesSearch } from './searchOffer.js';
// The one place a lookup column's display value is decided — shared with the
// inline dropdown in LookupField so a single `lookupColumns` declaration
// cannot render two different ways (objectui#5492).
Expand Down Expand Up @@ -804,6 +805,10 @@ export function RecordPickerDialog({
const setCurrentPage = query.setPage;
const handleSort = query.toggleSort;
const handleSearchChange = query.setSearch;
// objectui#12966 — no type-ahead on an object whose search resolves to no
// field: the server refuses that search with `400 INVALID_FIELD`. The table
// still lists and pages the records. An unknown schema keeps the box.
const searchOffered = objectResolvesSearch(objectSchema) !== false;

// Reset non-query UI/selection state when the dialog closes. Query state
// (search/page/sort/records) is cleared by useRecordQuery when `enabled`
Expand Down Expand Up @@ -1219,25 +1224,27 @@ export function RecordPickerDialog({
(objectui#11903). A bordered wrapper around a borderless input
stacked two outlines; `focus-visible:ring-0` still drew the
primitive's ring offset as a second, 2px ring. */}
<div className="relative">
<Search
aria-hidden="true"
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground"
/>
<Input
placeholder={t('table.search')}
value={searchQuery}
onChange={(e) => handleSearchChange(e.target.value)}
className="pl-9 pr-9"
data-testid="record-picker-search"
/>
{loading && (
<Loader2
className="absolute right-3 top-1/2 -translate-y-1/2 size-4 animate-spin text-muted-foreground"
data-testid="record-picker-loading-indicator"
{searchOffered && (
<div className="relative">
<Search
aria-hidden="true"
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground"
/>
)}
</div>
<Input
placeholder={t('table.search')}
value={searchQuery}
onChange={(e) => handleSearchChange(e.target.value)}
className="pl-9 pr-9"
data-testid="record-picker-search"
/>
{loading && (
<Loader2
className="absolute right-3 top-1/2 -translate-y-1/2 size-4 animate-spin text-muted-foreground"
data-testid="record-picker-loading-indicator"
/>
)}
</div>
)}

{/* Filter bar (inline) — supports external FilterUI via renderFilterBar slot */}
{effectiveFilterColumns && effectiveFilterColumns.length > 0 && (
Expand Down
Loading
Loading