diff --git a/.changeset/12966-searchless-object.md b/.changeset/12966-searchless-object.md new file mode 100644 index 0000000000..150ccd0df2 --- /dev/null +++ b/.changeset/12966-searchless-object.md @@ -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. diff --git a/packages/fields/src/widgets/LookupField.tsx b/packages/fields/src/widgets/LookupField.tsx index ba12217291..2fb23b39a0 100644 --- a/packages/fields/src/widgets/LookupField.tsx +++ b/packages/fields/src/widgets/LookupField.tsx @@ -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'; @@ -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), @@ -1546,30 +1553,34 @@ export function LookupField({ value, onChange, field, readonly, error: fieldErro {triggerButton} - {/* Search input */} -
-
- - 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 && ( - +
+ + 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 && ( + + )} +
-
+ )} {/* Error state */} {error && ( diff --git a/packages/fields/src/widgets/RecordPickerDialog.searchlessTarget-12966.test.tsx b/packages/fields/src/widgets/RecordPickerDialog.searchlessTarget-12966.test.tsx new file mode 100644 index 0000000000..3855696e30 --- /dev/null +++ b/packages/fields/src/widgets/RecordPickerDialog.searchlessTarget-12966.test.tsx @@ -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 = { + 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) => { + 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 }) => + ds.find.mock.calls.map((c: any[]) => c[1]?.$search); + +function renderDialog(dataSource: any, objectSchema: unknown) { + return render( + {}} + 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( + , + ); + // 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(); + }); +}); diff --git a/packages/fields/src/widgets/RecordPickerDialog.tsx b/packages/fields/src/widgets/RecordPickerDialog.tsx index 86a887b247..3aff4fbd1b 100644 --- a/packages/fields/src/widgets/RecordPickerDialog.tsx +++ b/packages/fields/src/widgets/RecordPickerDialog.tsx @@ -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). @@ -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` @@ -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. */} -
-
+ handleSearchChange(e.target.value)} + className="pl-9 pr-9" + data-testid="record-picker-search" + /> + {loading && ( + + )} + + )} {/* Filter bar (inline) — supports external FilterUI via renderFilterBar slot */} {effectiveFilterColumns && effectiveFilterColumns.length > 0 && ( diff --git a/packages/fields/src/widgets/searchOffer.test.ts b/packages/fields/src/widgets/searchOffer.test.ts new file mode 100644 index 0000000000..5fa7716025 --- /dev/null +++ b/packages/fields/src/widgets/searchOffer.test.ts @@ -0,0 +1,55 @@ +/** + * 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 — `objectResolvesSearch` answers from the spec's + * `resolveSearchFieldResolution`, over the inputs the engine gives it. + */ + +import { describe, it, expect } from 'vitest'; +import { objectResolvesSearch } from './searchOffer'; + +const JUNCTION = { + book: { type: 'lookup' }, + reader: { type: 'lookup' }, + title: { type: 'formula' }, +}; + +describe('objectResolvesSearch (objectui#12966)', () => { + it('a junction object (two lookups, a formula title) resolves to no field', () => { + expect(objectResolvesSearch({ nameField: 'title', fields: JUNCTION })).toBe(false); + }); + + it('CONTROL: a text field makes it searchable', () => { + expect(objectResolvesSearch({ fields: { ...JUNCTION, note: { type: 'text' } } })).toBe(true); + }); + + it('an enum field makes it searchable', () => { + expect(objectResolvesSearch({ fields: { ...JUNCTION, status: { type: 'select' } } })).toBe(true); + }); + + it('a declared `searchableFields` naming a stored field counts; one naming the formula does not', () => { + expect(objectResolvesSearch({ searchableFields: ['book'], fields: JUNCTION })).toBe(true); + expect(objectResolvesSearch({ searchableFields: ['title'], fields: JUNCTION })).toBe(false); + }); + + it('an `internal: true` text field is left out, as the engine leaves it out', () => { + expect(objectResolvesSearch({ fields: { ...JUNCTION, digest: { type: 'text', internal: true } } })).toBe(false); + }); + + it('reads the array form of the field map', () => { + expect(objectResolvesSearch({ fields: [{ name: 'book', type: 'lookup' }] })).toBe(false); + expect(objectResolvesSearch({ fields: [{ name: 'note', type: 'text' }] })).toBe(true); + }); + + it('an unknown schema is `undefined`, not a verdict', () => { + expect(objectResolvesSearch(null)).toBeUndefined(); + expect(objectResolvesSearch(undefined)).toBeUndefined(); + expect(objectResolvesSearch({ name: 'x' })).toBeUndefined(); + }); +}); diff --git a/packages/fields/src/widgets/searchOffer.ts b/packages/fields/src/widgets/searchOffer.ts new file mode 100644 index 0000000000..fcf3148f99 --- /dev/null +++ b/packages/fields/src/widgets/searchOffer.ts @@ -0,0 +1,59 @@ +/** + * 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. + */ + +/** + * Does a `search` on this object resolve to at least one field? + * + * The platform refuses a non-blank `search` over an object whose resolved + * search set is empty with `400 INVALID_FIELD` (`param: 'search'`) — a + * junction object made of two lookups and a composed formula title is the + * common shape. A picker that offers type-ahead on such an object offers a + * control the server is certain to refuse, so the record pickers ask this + * first (objectui#12966). + * + * The answer is the spec's own `resolveSearchFieldResolution`, run over the + * same inputs the engine gives it: the object's field map with every + * `internal: true` field left out (the engine never scans one), and the + * OBJECT's declared `searchableFields`. ⛔ No second list of searchable types + * lives here. `displayField` is not passed: it only orders the auto-default + * set, it never adds or removes a member, so emptiness cannot depend on it. + * + * A view-level `searchableFields` is not an input. The client sends it as the + * `$searchFields` override, which the server intersects with the allowed set + * resolved here and refuses outside it, so it can narrow a non-empty set but + * never make an empty one searchable. + * + * Returns `undefined` when the schema is not known (absent, still loading, or + * carrying no field map): the caller keeps offering search, as it did before. + * + * ⛔ Deliberately NOT re-exported from the package barrel: it is shared by the + * picker widgets in this directory, not published surface. + */ +import { resolveSearchFieldResolution, type SearchFieldMeta } from '@objectstack/spec/data'; + +export function objectResolvesSearch(objectSchema: unknown): boolean | undefined { + if (!objectSchema || typeof objectSchema !== 'object') return undefined; + const schema = objectSchema as { fields?: unknown; searchableFields?: unknown }; + const raw = schema.fields; + if (!raw || typeof raw !== 'object') return undefined; + // The field map arrives keyed by name, or as an array of field definitions + // carrying their `name` — the two shapes the console's schema readers accept. + const entries: Array<[unknown, unknown]> = Array.isArray(raw) + ? raw.map((f) => [(f as { name?: unknown } | null)?.name, f]) + : Object.entries(raw as Record); + const fields: Record = {}; + for (const [name, meta] of entries) { + if (typeof name !== 'string' || !meta || typeof meta !== 'object') continue; + if ((meta as { internal?: unknown }).internal === true) continue; + fields[name] = meta as SearchFieldMeta; + } + const searchableFields = Array.isArray(schema.searchableFields) + ? schema.searchableFields.filter((f): f is string => typeof f === 'string') + : undefined; + return resolveSearchFieldResolution({ fields, searchableFields }).allowed.length > 0; +} diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx index f33d106eda..b59af0273e 100644 --- a/packages/plugin-list/src/ListView.tsx +++ b/packages/plugin-list/src/ListView.tsx @@ -44,7 +44,7 @@ import { useObjectLabel, useSafeFieldLabel, createSafeTranslation, useDisplayLoc // (objectui#5134). import { resolveI18nLabel as resolveInlineI18nLabel, normalizeFilterOperator, PaginationConfigSchema } from '@objectstack/spec/ui'; import type { GroupingConfig, ListView as SpecListView } from '@objectstack/spec/ui'; -import { resolveInjectedSystemColumns } from '@objectstack/spec/data'; +import { resolveInjectedSystemColumns, resolveSearchFieldResolution, type SearchFieldMeta } from '@objectstack/spec/data'; import { SystemFieldName } from '@objectstack/spec/system'; import { usePermissions } from '@object-ui/permissions'; @@ -1238,6 +1238,52 @@ function kanbanCardFieldsFromView(columns: readonly unknown[], skip: ReadonlyArr * `SystemFieldName.OWNER_ID` names the field the platform injects for it. The * object definition has to declare that field too, or there is nothing to read. */ +/** + * Does a `search` on this object resolve to at least one field? (objectui#12966) + * + * The platform refuses a non-blank `search` over an object whose resolved + * search set is empty with `400 INVALID_FIELD` (`param: 'search'`) — a + * junction object of two lookups and a composed formula title is the common + * shape — so the toolbar does not offer a quick-search box there. + * + * The answer is the spec's `resolveSearchFieldResolution`, run over the inputs + * the engine gives it: the object's field map with every `internal: true` + * field left out (the engine never scans one) and the OBJECT's declared + * `searchableFields`. ⛔ No second list of searchable types lives here. + * `displayField` is not passed: it orders the auto-default set and never + * changes its membership. The record pickers ask the same question through + * `@object-ui/fields`' `objectResolvesSearch`, which is this function's twin. + * + * The VIEW's `searchableFields` is not an input: the toolbar sends it as the + * `$searchFields` override, which the server intersects with the set resolved + * here and refuses outside it, so it narrows a non-empty set and can never make + * an empty one searchable. + * + * `undefined` = the schema is not known (none, or no field map): the toolbar + * keeps the box, as it did before. + */ +function objectResolvesSearch(objectDef: unknown): boolean | undefined { + if (!objectDef || typeof objectDef !== 'object') return undefined; + const def = objectDef as { fields?: unknown; searchableFields?: unknown }; + const raw = def.fields; + if (!raw || typeof raw !== 'object') return undefined; + // Keyed by name or an array of named definitions — the two shapes this file + // already reads `objectDef.fields` in. + const entries: Array<[unknown, unknown]> = Array.isArray(raw) + ? raw.map((f) => [(f as { name?: unknown } | null)?.name, f]) + : Object.entries(raw as Record); + const fields: Record = {}; + for (const [name, meta] of entries) { + if (typeof name !== 'string' || !meta || typeof meta !== 'object') continue; + if ((meta as { internal?: unknown }).internal === true) continue; + fields[name] = meta as SearchFieldMeta; + } + const searchableFields = Array.isArray(def.searchableFields) + ? def.searchableFields.filter((f): f is string => typeof f === 'string') + : undefined; + return resolveSearchFieldResolution({ fields, searchableFields }).allowed.length > 0; +} + function kanbanOwnerFieldOf(objectDef: any): string | undefined { if (!objectDef || typeof objectDef !== 'object') return undefined; if (!resolveInjectedSystemColumns(objectDef).owner) return undefined; @@ -1593,6 +1639,28 @@ export const ListView = React.forwardRef(({ */ const searchlessViewOnScreen = currentView === 'tree' || currentView === 'chart'; + // The object definition, read from the data source below ("Fetch object + // definition"). Declared here because the toolbar's search decision reads it. + const [objectDef, setObjectDef] = React.useState(null); + const [objectDefLoaded, setObjectDefLoaded] = React.useState(false); + + /** + * objectui#12966 — the object's `search` resolves to no field, so the server + * refuses any term with `400 INVALID_FIELD`: no quick-search box, and a term + * restored at mount or carried across a view switch is not applied, for the + * same two-readers reason as `searchlessViewOnScreen` above. Decided only + * from the definition (`objectResolvesSearch`); a definition the data source + * cannot serve keeps the box, as before. + * + * Until the definition is read the box is drawn, as before the card. On a + * searchless object it is withdrawn when the read lands; the list is in its + * loading state for that read anyway, since the data fetch waits on it. + */ + const searchlessObject = React.useMemo( + () => objectResolvesSearch(objectDef) === false, + [objectDef], + ); + // Resolve toolbar visibility flags: userActions overrides showX flags const toolbarFlags = React.useMemo(() => { // Every toolbar toggle reads from `userActions` (#2890). The legacy bare @@ -1621,7 +1689,8 @@ export const ListView = React.forwardRef(({ const addRecordPlacement = resolveAddRecordPlacement(schema.addRecord?.position); return { // Not offered on `tree` or `chart`: see `searchlessViewOnScreen`. - showSearch: ua?.search !== false && !searchlessViewOnScreen, + // Nor on an object whose search resolves to no field: see `searchlessObject`. + showSearch: ua?.search !== false && !searchlessViewOnScreen && !searchlessObject, showSort: ua?.sort !== false, // Not offered on a dataset-bound chart: see `datasetChartOnScreen`. showFilters: ua?.filter !== false && !datasetChartOnScreen, @@ -1642,7 +1711,7 @@ export const ListView = React.forwardRef(({ showAddRecordTop: addRecordEnabled && addRecordPlacement.top, showAddRecordBottom: addRecordEnabled && addRecordPlacement.bottom, }; - }, [schema.userActions, schema.compactToolbar, schema.addRecord, datasetChartOnScreen, searchlessViewOnScreen]); + }, [schema.userActions, schema.compactToolbar, schema.addRecord, datasetChartOnScreen, searchlessViewOnScreen, searchlessObject]); // What the user typed, or what the host restored at mount. const [searchInput, setSearchInput] = React.useState(() => initialSearchTerm ?? ''); @@ -1650,7 +1719,7 @@ export const ListView = React.forwardRef(({ // trigger. Empty on a view whose own query carries no term // (`searchlessViewOnScreen`, objectui#10326). A primitive, so each list that // names it re-runs on a switch into or out of such a view and on nothing else. - const searchTerm = searchlessViewOnScreen ? '' : searchInput; + const searchTerm = searchlessViewOnScreen || searchlessObject ? '' : searchInput; const [showSearchPopover, setShowSearchPopover] = React.useState(false); // Sort State @@ -1726,6 +1795,11 @@ export const ListView = React.forwardRef(({ // objectui#4693 — lifted from this file so `RecordAttachmentsPanel` can // reuse the same "api-disabled is retry-invariant" verdict). const [loadErrorKind, setLoadErrorKind] = React.useState('network'); + // objectui#12966 — the failure is the server refusing this request's search + // term (`INVALID_FIELD` on a request that carried `$search`). The panel then + // shows the server's own sentence, which names the object and the remedies, + // instead of the generic "query was rejected" copy. + const [loadErrorIsSearchRefusal, setLoadErrorIsSearchRefusal] = React.useState(false); // Start in loading state when we will fetch from a dataSource so the empty // state doesn't flash before the first effect runs. Inline data (schema.data // as an array or a `value` provider) starts as not-loading. @@ -1741,8 +1815,6 @@ export const ListView = React.forwardRef(({ } return true; }); - const [objectDef, setObjectDef] = React.useState(null); - const [objectDefLoaded, setObjectDefLoaded] = React.useState(false); const [refreshKey, setRefreshKey] = React.useState(0); const [dataLimitReached, setDataLimitReached] = React.useState(false); @@ -2818,6 +2890,7 @@ export const ListView = React.forwardRef(({ setLoading(true); setLoadError(null); + setLoadErrorIsSearchRefusal(false); // [objectui#10384] Set when this result clamps the pager (see below). // The window it moves to is already being fetched, so loading stays on: // settling it here would paint the empty window — and with it the @@ -3334,6 +3407,10 @@ export const ListView = React.forwardRef(({ setData([]); setLoadError((err as any)?.message ? String((err as any).message) : String(err ?? 'Unknown error')); setLoadErrorKind(classifyLoadError(err)); + setLoadErrorIsSearchRefusal( + // `searchTerm` is what put `$search` on this request's params. + !!searchTerm && (err as { code?: unknown } | null)?.code === 'INVALID_FIELD', + ); } } finally { if (isMounted && requestId === fetchRequestIdRef.current && clampedToPage === null) { @@ -6045,7 +6122,10 @@ export const ListView = React.forwardRef(({ : loadErrorKind === 'rejected' ? 'list.loadErrorRejectedTitle' : 'list.loadErrorTitle', )} - message={t( + // A refused search term shows the server's sentence: it names the + // object and the two remedies, and clearing the term (the toolbar + // box stays) is the reader's way out (objectui#12966). + message={loadErrorIsSearchRefusal ? loadError : t( loadErrorKind === 'api-disabled' ? 'list.loadErrorApiDisabledMessage' : loadErrorKind === 'forbidden' ? 'list.loadErrorForbiddenMessage' : loadErrorKind === 'unauthorized' ? 'list.loadErrorUnauthorizedMessage' diff --git a/packages/plugin-list/src/__tests__/ListView.displayPopover-12423.test.tsx b/packages/plugin-list/src/__tests__/ListView.displayPopover-12423.test.tsx index 048bd425a3..4080c85205 100644 --- a/packages/plugin-list/src/__tests__/ListView.displayPopover-12423.test.tsx +++ b/packages/plugin-list/src/__tests__/ListView.displayPopover-12423.test.tsx @@ -33,7 +33,12 @@ function makeDataSource() { create: vi.fn(), update: vi.fn(), delete: vi.fn(), - getObjectSchema: vi.fn().mockResolvedValue({ name: 'account', fields: {} }), + // `name` is declared so the object has a searchable field: an object with + // none draws no Search (objectui#12966), and this fixture offers every control. + getObjectSchema: vi.fn().mockResolvedValue({ + name: 'account', + fields: { name: { name: 'name', type: 'text', label: 'Name' } }, + }), }; } diff --git a/packages/plugin-list/src/__tests__/ListView.searchlessObject-12966.test.tsx b/packages/plugin-list/src/__tests__/ListView.searchlessObject-12966.test.tsx new file mode 100644 index 0000000000..4a4f687a9a --- /dev/null +++ b/packages/plugin-list/src/__tests__/ListView.searchlessObject-12966.test.tsx @@ -0,0 +1,212 @@ +/** + * 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 list's quick-search box is not offered on an object + * whose `search` resolves to no field. + * + * THE PLATFORM CHANGE. objectstack answers a non-blank `search` over such an + * object with `400 INVALID_FIELD` (`param: 'search'`); before, it answered + * every row. The common shape is a junction object: two lookups and a composed + * formula title, so the auto-default set (textual and enum types) is empty. + * + * WHAT IS PINNED. + * + * - (a) A searchless object draws no quick-search box, and a term the host + * restores at mount reaches no fetch. + * - (b) CONTROL: the same view on an object with a text field draws it. + * - (c) An object that DECLARES `searchableFields` naming a stored field (a + * lookup, which the auto-default skips) draws it: the resolver's declared + * branch. + * - A text field declared `internal: true` does not count: the engine never + * scans one. + * - A VIEW's `searchableFields` does not make a searchless object + * searchable: the server intersects that `$searchFields` override with the + * object's resolved set and refuses it outside, so the box would 400 on + * every term. + * - (e) Where the box still shows — the data source serves no object + * definition — and the server refuses the term, the panel renders the + * server's own message, not the generic "query was rejected" copy. + * + * Every case waits for the view's stand-in first, so a mount that never + * reached the render branch reads as a broken harness, never as a pass. + */ + +import React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, waitFor, cleanup, fireEvent } from '@testing-library/react'; +import { ComponentRegistry } from '@object-ui/core'; +import { SchemaRendererProvider } from '@object-ui/react'; +import { ListView } from '../ListView'; + +ComponentRegistry.register( + 'object-grid', + () =>
, + { namespace: 'test', label: 'object-grid stand-in', category: 'view' }, +); + +/** A junction object: two lookups and a composed formula title. */ +const JUNCTION_FIELDS = { + 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' }, + due: { name: 'due', type: 'date', label: 'Due' }, +}; + +const OBJECTS = { + searchless: { name: 'lib_loan', label: 'Loan', nameField: 'title', fields: JUNCTION_FIELDS }, + withText: { + name: 'lib_loan', + label: 'Loan', + fields: { ...JUNCTION_FIELDS, note: { name: 'note', type: 'text', label: 'Note' } }, + }, + declaresStoredField: { + name: 'lib_loan', + label: 'Loan', + nameField: 'title', + searchableFields: ['book'], + fields: JUNCTION_FIELDS, + }, + onlyInternalText: { + name: 'lib_loan', + label: 'Loan', + fields: { ...JUNCTION_FIELDS, digest: { name: 'digest', type: 'text', internal: true } }, + }, +} as const; + +const rows = [{ id: '1', book: 'b1', reader: 'r1', due: '2026-01-05' }]; + +const REFUSAL_MESSAGE = + "Object 'lib_loan' has no searchable field, so 'search' cannot be honoured on it."; + +function makeDataSource(objectDef: unknown, opts: { refuseSearch?: boolean } = {}) { + return { + find: vi.fn(async (_object: string, params?: Record) => { + if (opts.refuseSearch && params?.$search) { + // The data adapter's raw-GET failure shape: message, code, status. + const err = new Error(REFUSAL_MESSAGE) as Error & Record; + err.code = 'INVALID_FIELD'; + err.status = 400; + err.httpStatus = 400; + throw err; + } + return { data: rows, total: rows.length }; + }), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + ...(objectDef ? { getObjectSchema: vi.fn(async () => objectDef) } : {}), + } as never as { find: ReturnType }; +} + +function mount( + dataSource: unknown, + view: Record = {}, + listProps: Record = {}, +) { + const schema = { + type: 'list-view', + objectName: 'lib_loan', + viewType: 'grid', + columns: ['book', 'reader'], + compactToolbar: false, + ...view, + }; + render( + + + , + ); +} + +const searchTrigger = () => screen.queryByTestId('search-icon-button'); +const gridOnScreen = () => + waitFor(() => expect(screen.getByTestId('object-grid-standin')).toBeInTheDocument()); +const hostSearches = (dataSource: { find: ReturnType }) => + dataSource.find.mock.calls.map((c: unknown[]) => (c[1] as Record | undefined)?.$search); +const settle = () => new Promise((r) => setTimeout(r, 50)); + +afterEach(cleanup); + +describe('objectui#12966 — quick-search follows the object\'s resolved search set', () => { + it('(a) a junction object whose search resolves to no field draws no quick-search box', async () => { + mount(makeDataSource(OBJECTS.searchless)); + await gridOnScreen(); + expect(searchTrigger()).not.toBeInTheDocument(); + }); + + it('(b) CONTROL: the same object with a text field draws it', async () => { + mount(makeDataSource(OBJECTS.withText)); + await gridOnScreen(); + expect(searchTrigger()).toBeInTheDocument(); + }); + + it('(c) an object declaring `searchableFields` naming a stored field draws it', async () => { + mount(makeDataSource(OBJECTS.declaresStoredField)); + await gridOnScreen(); + expect(searchTrigger()).toBeInTheDocument(); + }); + + it('a text field declared `internal: true` does not count — the engine never scans it', async () => { + mount(makeDataSource(OBJECTS.onlyInternalText)); + await gridOnScreen(); + expect(searchTrigger()).not.toBeInTheDocument(); + }); + + it('a VIEW\'s `searchableFields` does not make a searchless object searchable', async () => { + mount(makeDataSource(OBJECTS.searchless), { searchableFields: ['book'] }); + await gridOnScreen(); + expect(searchTrigger()).not.toBeInTheDocument(); + }); + + it('on a searchless object, a term the host restores at mount reaches no fetch', async () => { + const dataSource = makeDataSource(OBJECTS.searchless); + mount(dataSource, {}, { initialSearchTerm: 'Dune' }); + await gridOnScreen(); + await waitFor(() => expect(dataSource.find).toHaveBeenCalled()); + await settle(); + expect(hostSearches(dataSource).every((s) => s === undefined)).toBe(true); + }); + + it('CONTROL: on a searchable object, the restored term reaches the fetch', async () => { + const dataSource = makeDataSource(OBJECTS.withText); + mount(dataSource, {}, { initialSearchTerm: 'Dune' }); + await gridOnScreen(); + await waitFor(() => expect(hostSearches(dataSource).at(-1)).toBe('Dune')); + }); +}); + +describe('objectui#12966 — a refused search renders the server\'s message', () => { + it('(e) with no object definition the box stays; a refused term shows the refusal\'s own sentence', async () => { + const dataSource = makeDataSource(null, { refuseSearch: true }); + mount(dataSource); + await gridOnScreen(); + + fireEvent.click(screen.getByTestId('search-icon-button')); + fireEvent.change(screen.getByPlaceholderText(/search/i), { target: { value: 'Dune' } }); + + const panel = await screen.findByTestId('list-error-state'); + expect(panel).toHaveAttribute('data-error-kind', 'rejected'); + expect(panel).toHaveTextContent(REFUSAL_MESSAGE); + // The box is still there to clear the term with. + expect(searchTrigger()).toBeInTheDocument(); + }); + + it('CONTROL: a 400 that is not a search refusal keeps the generic rejected copy', async () => { + const err = Object.assign(new Error('Bad filter'), { code: 'INVALID_QUERY', status: 400 }); + const dataSource = { + ...makeDataSource(null), + find: vi.fn(async () => { throw err; }), + }; + mount(dataSource); + const panel = await screen.findByTestId('list-error-state'); + expect(panel).toHaveAttribute('data-error-kind', 'rejected'); + expect(panel).not.toHaveTextContent('Bad filter'); + }); +});