diff --git a/.changeset/10250-self-querying-views-toolbar-state.md b/.changeset/10250-self-querying-views-toolbar-state.md index 74297995c8..83244c6790 100644 --- a/.changeset/10250-self-querying-views-toolbar-state.md +++ b/.changeset/10250-self-querying-views-toolbar-state.md @@ -21,5 +21,6 @@ no search term, so each control changed `ListView`'s own fetch and nothing on sc `searchableFields` as `$searchFields` — the pair a list's own query sends. A `gantt` list view writes both from its toolbar Search box and the view's `searchableFields`. -Unchanged: a `chart` view bound to a semantic `dataset`, and the toolbar Search box on -`tree` and `chart` views, still do not reach those views' queries. +Unchanged: the toolbar state still does not reach the query of a `chart` view bound to a +semantic `dataset`, and the toolbar Search box still does not reach the `tree` or `chart` +query. objectui#10326 stops offering that Search box on `tree` and `chart` views. diff --git a/.changeset/10326-toolbar-search-withheld-tree-chart.md b/.changeset/10326-toolbar-search-withheld-tree-chart.md new file mode 100644 index 0000000000..a1b803c3f1 --- /dev/null +++ b/.changeset/10326-toolbar-search-withheld-tree-chart.md @@ -0,0 +1,21 @@ +--- +'@object-ui/plugin-list': patch +--- + +On `tree` and `chart` list views, the toolbar no longer offers Search (objectui#10326). + +Both views draw what they query for themselves, and neither query carries a search +term: the tree's own `find` sends its filter, its row ceiling and its expansions, and +the chart's queries, aggregate or dataset, take no term at all. A term typed there +changed `ListView`'s own fetch, behind the record count and the export, and nothing +the view drew. + +- The Search control is not rendered while a `tree` or `chart` view is on screen, + whatever the chart is bound to. An authored `userActions.search: true` does not bring + it back on those views. Grid, kanban, calendar, gallery, timeline, map and gantt views + keep it as before. +- A term that is already set, whether the host restored it at mount through + `initialSearchTerm` or the user typed it on another view before switching, is not + applied to `ListView`'s own fetch or export while a `tree` or `chart` view is on + screen. The term is kept rather than cleared: switching back to a view that offers + Search applies it again, and `onSearchChange` is not called for the switch. diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx index 860eeb7350..5aa0118263 100644 --- a/packages/plugin-list/src/ListView.tsx +++ b/packages/plugin-list/src/ListView.tsx @@ -1154,6 +1154,29 @@ export const ListView = React.forwardRef(({ const datasetChartOnScreen = currentView === 'chart' && resolveListChartBinding(schema).shape === 'dataset'; + /** + * Is the view on screen one whose own query carries no search term? + * (objectui#10326, ruling 5825585515, letter A.) + * + * `tree` and `chart` draw what they query for themselves, and neither query + * has a search channel: `ObjectTree`'s object-provider `find` sends + * `$filter`, `$top` and `$expand`; `ObjectChart`'s queries — the aggregate + * (`field`, `function`, `groupBy`, `filter`), its `find` fallback and the + * dataset query — take no term. A typed term changed only this component's + * own fetch — the record-count bar and the export — and nothing drawn. The + * ruling withholds the control instead of widening either query (option B). + * + * Every chart binding: the dataset shape carries no term either. + * + * Two readers, one answer: `showSearch` below, and the APPLIED term + * (`searchTerm`, after the toolbar flags). A term restored at mount or + * carried across a view switch therefore does not go on narrowing this + * component's fetch with no control on screen to show or clear it. ⛔ The + * typed term itself is kept: switching back to a view that draws the + * fetched rows applies it again. + */ + const searchlessViewOnScreen = currentView === 'tree' || currentView === 'chart'; + // Resolve toolbar visibility flags: userActions overrides showX flags const toolbarFlags = React.useMemo(() => { // Every toolbar toggle reads from `userActions` (#2890). The legacy bare @@ -1181,7 +1204,8 @@ export const ListView = React.forwardRef(({ const addRecordEnabled = schema.addRecord?.enabled === true && ua?.addRecordForm !== false; const addRecordPlacement = resolveAddRecordPlacement(schema.addRecord?.position); return { - showSearch: ua?.search !== false, + // Not offered on `tree` or `chart`: see `searchlessViewOnScreen`. + showSearch: ua?.search !== false && !searchlessViewOnScreen, showSort: ua?.sort !== false, // Not offered on a dataset-bound chart: see `datasetChartOnScreen`. showFilters: ua?.filter !== false && !datasetChartOnScreen, @@ -1199,9 +1223,15 @@ export const ListView = React.forwardRef(({ showAddRecordTop: addRecordEnabled && addRecordPlacement.top, showAddRecordBottom: addRecordEnabled && addRecordPlacement.bottom, }; - }, [schema.userActions, schema.compactToolbar, schema.addRecord, datasetChartOnScreen]); - - const [searchTerm, setSearchTerm] = React.useState(() => initialSearchTerm ?? ''); + }, [schema.userActions, schema.compactToolbar, schema.addRecord, datasetChartOnScreen, searchlessViewOnScreen]); + + // What the user typed, or what the host restored at mount. + const [searchInput, setSearchInput] = React.useState(() => initialSearchTerm ?? ''); + // The term this component APPLIES — its fetch, the export, the Search + // 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 [showSearchPopover, setShowSearchPopover] = React.useState(false); // Sort State @@ -2820,7 +2850,7 @@ export const ListView = React.forwardRef(({ }, [storageKey, onViewChange]); const handleSearchChange = React.useCallback((value: string) => { - setSearchTerm(value); + setSearchInput(value); onSearchChange?.(value); }, [onSearchChange]); @@ -3041,7 +3071,8 @@ export const ListView = React.forwardRef(({ * a string compares by value, so an unchanged term rebuilds nothing. * ⛔ `tree` and `chart` are not handed a term: neither renderer has a search * channel in its own query today, so a key written onto their nodes would be - * accepted and read by nothing. + * accepted and read by nothing. The toolbar does not offer Search on them + * either (`searchlessViewOnScreen`, objectui#10326). */ const ganttSearchTerm = currentView === 'gantt' ? searchTerm : ''; diff --git a/packages/plugin-list/src/__tests__/ListView.searchWithheldTreeChart-10326.test.tsx b/packages/plugin-list/src/__tests__/ListView.searchWithheldTreeChart-10326.test.tsx new file mode 100644 index 0000000000..46c28f3d02 --- /dev/null +++ b/packages/plugin-list/src/__tests__/ListView.searchWithheldTreeChart-10326.test.tsx @@ -0,0 +1,249 @@ +/** + * 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#10326 — the toolbar Search is not offered on `tree` or `chart` list + * views (ruling 5825585515, letter A: a control that changes nothing is + * withheld). + * + * THE DEFECT. The toolbar offered Search on every view. `tree` and `chart` + * draw what they query for themselves, and neither query carries a term: + * `ObjectTree`'s object-provider `find` sends `$filter` / `$top` / `$expand`, + * and `ObjectChart`'s aggregate and dataset queries take no search at all. A + * typed term changed only `ListView`'s own fetch — the record-count bar and + * the export — and nothing drawn. + * + * WHAT IS PINNED. + * + * - Search is absent on `tree` and on `chart`, whatever the chart's binding + * (bound to the list object, or to a semantic dataset), and present on + * `grid` (the CONTROL) and on every other view kind, `gantt` included + * (objectui#10250 made its chart honour the term). + * - An authored `userActions.search: true` does not bring it back on those + * two views: `true` is the default, and the view still has no query to + * carry the term. + * - A term that is already set does not go on narrowing `ListView`'s own + * fetch while such a view is on screen, with no control there to show or + * clear it: neither a term the host restores at mount (`initialSearchTerm`, + * which app-shell's `ObjectView` reads back from per-view storage) nor one + * typed on a grid before switching. The term is KEPT, not cleared: + * switching back to the grid applies it again, and the host is never told + * to drop it. + * + * Each case first waits for the view's stand-in, so a mount that never reached + * the view's 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'; + +const VIEW_NODE_TYPES = [ + 'object-grid', + 'object-kanban', + 'object-calendar', + 'object-gallery', + 'object-timeline', + 'object-gantt', + 'object-map', + 'object-tree', + 'object-chart', +] as const; + +for (const type of VIEW_NODE_TYPES) { + ComponentRegistry.register( + type, + () =>
, + { namespace: 'test', label: `${type} stand-in`, category: 'view' }, + ); +} + +const objectDef = { + name: 'duly_task', + label: 'Task', + fields: { + id: { name: 'id', type: 'text' }, + subject: { name: 'subject', type: 'text', label: 'Subject' }, + status: { name: 'status', type: 'text', label: 'Status' }, + estimate: { name: 'estimate', type: 'number', label: 'Estimate' }, + parent: { name: 'parent', type: 'text', label: 'Parent' }, + cover: { name: 'cover', type: 'text', label: 'Cover' }, + lat: { name: 'lat', type: 'number', label: 'Latitude' }, + lng: { name: 'lng', type: 'number', label: 'Longitude' }, + visible_from: { name: 'visible_from', type: 'date', label: 'Visible From' }, + due_date: { name: 'due_date', type: 'date', label: 'Due Date' }, + }, +}; + +const rows = [ + { id: '1', subject: 'Task 1', status: 'open', estimate: 3, visible_from: '2026-01-01', due_date: '2026-01-05' }, +]; + +function makeDataSource() { + return { + find: vi.fn(async () => ({ data: rows, total: rows.length })), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn(async () => objectDef), + } as never as { find: ReturnType }; +} + +const TREE_BLOCK = { parentField: 'parent' }; +const OBJECT_CHART_BLOCK = { chartType: 'bar', xAxisField: 'status', yAxisFields: ['estimate'], aggregation: 'sum' }; +const DATASET_CHART_BLOCK = { chartType: 'bar', dataset: 'task_ds', dimensions: ['status'], values: ['total_estimate'] }; + +/** Every view kind, bound so its render branch has what it reads. */ +const VIEWS = { + grid: { viewType: 'grid' }, + kanban: { viewType: 'kanban', kanban: { groupByField: 'status' } }, + calendar: { viewType: 'calendar', calendar: { startDateField: 'due_date' } }, + gallery: { viewType: 'gallery', gallery: { coverField: 'cover' } }, + timeline: { viewType: 'timeline', timeline: { startDateField: 'due_date' } }, + gantt: { viewType: 'gantt', gantt: { titleField: 'subject', startDateField: 'visible_from', endDateField: 'due_date' } }, + map: { viewType: 'map', map: { latitudeField: 'lat', longitudeField: 'lng' } }, + tree: { viewType: 'tree', tree: TREE_BLOCK }, + objectChart: { viewType: 'chart', chart: OBJECT_CHART_BLOCK }, + datasetChart: { viewType: 'chart', chart: DATASET_CHART_BLOCK }, +} as const; + +const STANDIN_OF: Record = { + grid: 'object-grid-standin', + kanban: 'object-kanban-standin', + calendar: 'object-calendar-standin', + gallery: 'object-gallery-standin', + timeline: 'object-timeline-standin', + gantt: 'object-gantt-standin', + map: 'object-map-standin', + tree: 'object-tree-standin', + objectChart: 'object-chart-standin', + datasetChart: 'object-chart-standin', +}; + +function mount(view: Record, listProps: Record = {}) { + const dataSource = makeDataSource(); + const schema = { type: 'list-view', objectName: 'duly_task', columns: ['subject'], ...view }; + render( + + + , + ); + return dataSource; +} + +const searchTrigger = () => screen.queryByTestId('search-icon-button'); +const onScreen = (testId: string) => waitFor(() => expect(screen.getByTestId(testId)).toBeInTheDocument()); +/** The `$search` of every host (`ListView`) fetch, in order. */ +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#10326 — the toolbar Search is offered only where the view on screen can carry the term', () => { + it('CONTROL: a grid view offers Search', async () => { + mount(VIEWS.grid); + await onScreen(STANDIN_OF.grid); + expect(searchTrigger()).toBeInTheDocument(); + }); + + it.each([ + ['tree', 'tree'], + ['chart bound to the list object', 'objectChart'], + ['chart bound to a semantic dataset', 'datasetChart'], + ] as const)('withholds Search on a %s view', async (_label, view) => { + mount(VIEWS[view]); + await onScreen(STANDIN_OF[view]); + expect(searchTrigger()).not.toBeInTheDocument(); + }); + + it.each(['kanban', 'calendar', 'gallery', 'timeline', 'gantt', 'map'] as const)( + 'keeps Search on a %s view', + async (view) => { + mount(VIEWS[view]); + await onScreen(STANDIN_OF[view]); + expect(searchTrigger()).toBeInTheDocument(); + }, + ); + + it.each([ + ['tree', 'tree'], + ['chart', 'objectChart'], + ] as const)('an authored `userActions.search: true` does not bring Search back on a %s view', async (_label, view) => { + mount({ ...VIEWS[view], userActions: { search: true } }); + await onScreen(STANDIN_OF[view]); + expect(searchTrigger()).not.toBeInTheDocument(); + }); +}); + +describe('objectui#10326 — a term already set does not narrow ListView\'s fetch while a search-less view is on screen', () => { + it('CONTROL: on a grid, a term the host restores at mount reaches the fetch and shows on the trigger', async () => { + const dataSource = mount(VIEWS.grid, { initialSearchTerm: 'needle' }); + await onScreen(STANDIN_OF.grid); + await waitFor(() => expect(hostSearches(dataSource).at(-1)).toBe('needle')); + expect(screen.getByTestId('search-active-keyword')).toHaveTextContent('needle'); + }); + + it.each([ + ['tree', 'tree'], + ['chart bound to the list object', 'objectChart'], + ['chart bound to a semantic dataset', 'datasetChart'], + ] as const)('on a %s view, a term the host restores at mount reaches no fetch', async (_label, view) => { + const dataSource = mount(VIEWS[view], { initialSearchTerm: 'needle' }); + await onScreen(STANDIN_OF[view]); + await waitFor(() => expect(dataSource.find).toHaveBeenCalled()); + await settle(); + expect(hostSearches(dataSource).every((s) => s === undefined)).toBe(true); + }); + + it('a term typed on a grid stops narrowing the fetch on a switch to tree or chart, and applies again back on the grid', async () => { + const onSearchChange = vi.fn(); + const dataSource = mount( + { + viewType: 'grid', + tree: TREE_BLOCK, + chart: OBJECT_CHART_BLOCK, + appearance: { allowedVisualizations: ['grid', 'tree', 'chart'] }, + }, + { showViewSwitcher: true, onSearchChange }, + ); + await onScreen(STANDIN_OF.grid); + + fireEvent.click(screen.getByTestId('search-icon-button')); + fireEvent.change(screen.getByPlaceholderText(/search/i), { target: { value: 'needle' } }); + await waitFor(() => expect(hostSearches(dataSource).at(-1)).toBe('needle')); + + const pick = (name: string) => { + const trigger = screen.queryByTestId('view-switcher-dropdown'); + if (trigger) fireEvent.click(trigger); + fireEvent.click(screen.queryByRole('tab', { name }) ?? screen.getByRole('button', { name })); + }; + + pick('Tree'); + await onScreen(STANDIN_OF.tree); + expect(searchTrigger()).not.toBeInTheDocument(); + await waitFor(() => expect(hostSearches(dataSource).at(-1)).toBeUndefined()); + + pick('Chart'); + await onScreen(STANDIN_OF.objectChart); + expect(searchTrigger()).not.toBeInTheDocument(); + await settle(); + expect(hostSearches(dataSource).at(-1)).toBeUndefined(); + + pick('Grid'); + await onScreen(STANDIN_OF.grid); + await waitFor(() => expect(hostSearches(dataSource).at(-1)).toBe('needle')); + expect(screen.getByTestId('search-active-keyword')).toHaveTextContent('needle'); + // Kept, not cleared: the host was told the term once, and never to drop it. + expect(onSearchChange.mock.calls).toEqual([['needle']]); + }); +});