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
5 changes: 3 additions & 2 deletions .changeset/10250-self-querying-views-toolbar-state.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
21 changes: 21 additions & 0 deletions .changeset/10326-toolbar-search-withheld-tree-chart.md
Original file line number Diff line number Diff line change
@@ -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.
43 changes: 37 additions & 6 deletions packages/plugin-list/src/ListView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1154,6 +1154,29 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
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
Expand Down Expand Up @@ -1181,7 +1204,8 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
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,
Expand All @@ -1199,9 +1223,15 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
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
Expand Down Expand Up @@ -2820,7 +2850,7 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
}, [storageKey, onViewChange]);

const handleSearchChange = React.useCallback((value: string) => {
setSearchTerm(value);
setSearchInput(value);
onSearchChange?.(value);
}, [onSearchChange]);

Expand Down Expand Up @@ -3041,7 +3071,8 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
* 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 : '';

Expand Down
Original file line number Diff line number Diff line change
@@ -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,
() => <div data-testid={`${type}-standin`} />,
{ 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<typeof vi.fn> };
}

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<keyof typeof VIEWS, string> = {
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<string, unknown>, listProps: Record<string, unknown> = {}) {
const dataSource = makeDataSource();
const schema = { type: 'list-view', objectName: 'duly_task', columns: ['subject'], ...view };
render(
<SchemaRendererProvider dataSource={dataSource as never}>
<ListView schema={schema as never} dataSource={dataSource as never} {...listProps} />
</SchemaRendererProvider>,
);
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<typeof vi.fn> }) =>
dataSource.find.mock.calls.map((c: unknown[]) => (c[1] as Record<string, unknown> | 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']]);
});
});
Loading