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
10 changes: 5 additions & 5 deletions .changeset/10037-gantt-toolbar-filter-reaches-chart.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@
On a `gantt` list view, the toolbar's Filter control and the `UserFilters` chips now
narrow the chart (objectui#10037).

Both controls were offered on a gantt view and changed nothing the user could see. Every
other view draws the rows `ListView` fetched, and that fetch applies the authored filter,
the toolbar's filter group and the chips together. The gantt chart is the one view that
queries for itself — its registered renderer takes only the node's `schema` — and the
node it was handed carried the authored `filter` alone.
Both controls were offered on a gantt view and changed nothing the user could see.
`ListView`'s own fetch applies the authored filter, the toolbar's filter group and the
chips together, but the gantt chart does not draw those rows: it queries for itself —
its registered renderer takes only the node's `schema` — and the node it was handed
carried the authored `filter` alone.

The gantt node now carries the same effective filter `ListView`'s own fetch sends, so the
chart's query honours both controls. The value keeps its identity while its content is
Expand Down
25 changes: 25 additions & 0 deletions .changeset/10250-self-querying-views-toolbar-state.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
'@object-ui/plugin-list': patch
'@object-ui/plugin-gantt': minor
'@object-ui/types': minor
---

On `tree` and `chart` list views, the toolbar's Filter control and the `UserFilters`
chips now narrow the view, and on a `gantt` list view the toolbar's Search box now
narrows the chart (objectui#10250).

These three views run their own query instead of drawing the rows `ListView` fetched.
The tree and chart nodes carried only the authored `filter`, and the gantt node carried
no search term, so each control changed `ListView`'s own fetch and nothing on screen.

- The `tree` node and the object-bound `chart` node now carry the same effective filter
`ListView`'s own fetch sends, as the `gantt` node has since objectui#10037. The value
keeps its identity while its content is unchanged, so re-rendering the list does not
re-query the view.
- `ObjectGanttSchema` declares two optional keys, `search` and `searchableFields`.
`ObjectGantt` sends `search` as `$search` and, only alongside a term,
`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.
8 changes: 8 additions & 0 deletions content/docs/plugins/plugin-gantt.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,8 @@ const schema: ObjectGanttSchema = {
readOnly?: boolean // disable every edit path
filter?: Array<any>, // JSON-Rules query filter — applies on EVERY provider
sort?: Array<{ field, order }>, // ordering — applies on EVERY provider
search?: string, // full-text term, sent as $search — applies on EVERY provider
searchableFields?: string[], // narrows what `search` matches, sent as $searchFields with a term
}
```

Expand All @@ -139,6 +141,12 @@ footnote naming both numbers — applies to inline rows too. The ceiling is
applied to the **filtered** set, so a large inline array that a `filter` cuts
below the ceiling draws every matching row and shows no footnote.

`search` is the chart's full-text term. A list view's toolbar Search box writes
it (with the view's `searchableFields`) onto its gantt node, because the chart
runs its own query; the server decides which fields a term matches unless
`searchableFields` narrows them, and a `searchableFields` with no term sends
nothing.

`onTaskClick` and `className` are **React props on `<ObjectGantt>`**, not schema
keys — the renderer never reads them off the schema, and a function cannot
survive serializable metadata anyway. `viewMode` is read through the gantt
Expand Down
5 changes: 5 additions & 0 deletions packages/plugin-gantt/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,11 @@ ceiling is applied to the **filtered** set, never to the raw one. Before
objectui#8769 the inline provider skipped that query and drew every authored
row with an authored `filter` silently dropped.

The same holds for the full-text pair: `search` is sent as `$search`, and
`searchableFields` as `$searchFields` alongside it (never without a term). A
list view's toolbar Search box writes both onto its gantt node — the chart runs
its own query, so the node is the only way the term reaches it.

**2. How the fields map — `getGanttConfig`.** Two spellings, checked in order.
The **`gantt` block wins whenever it is present**, and it is taken WHOLE — the
flat top-level keys are not merged into it. The flat spelling is read only when
Expand Down
165 changes: 165 additions & 0 deletions packages/plugin-gantt/src/ObjectGantt.searchTerm-10250.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
/**
* 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#10250 — the gantt's record query carries the node's full-text
* search: `search` as `$search`, and `searchableFields` as `$searchFields`
* alongside it.
*
* ## What was wrong
*
* `ListView` offers its toolbar Search box on a gantt view, and its own fetch
* sends `$search` (plus `$searchFields` when the view declares
* `searchableFields`). The gantt chart does not draw those rows: the registered
* `object-gantt` wrapper hands `ObjectGantt` the node's `schema` and nothing
* else, and `reload` issues the chart's own query. That query had no search
* channel at all, so the term changed the list's fetch and nothing drawn. The
* node is the only door: `ListView` writes the term there, and this file pins
* the half that reads it. The writing half is pinned in
* `plugin-list/src/__tests__/ListView.selfQueryingViewsToolbarState-10250.test.tsx`.
*
* ## The shape is the list's own
*
* The pair travels together or not at all — a field list with no term is
* inert, exactly as it is on `ListView`'s fetch and on `ObjectGrid`'s. Nothing
* here decides WHICH fields a term matches: that is the server's answer from
* the object's metadata (ADR-0061), narrowed only by an authored list.
*
* REVERSE VERIFICATION. Delete the `$search` spread from `reload`: TERM,
* FIELDS, CHANGE and INLINE go red, and so does STABILITY, whose precondition
* waits for the pair; CONTROL and INERT FIELDS stay green (they assert an
* absence, which a query without the pair satisfies). Key the effect on
* `schema.searchableFields` itself instead of its serialised value: STABILITY
* alone goes red.
*/

import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ObjectGantt } from './ObjectGantt';

vi.mock('sonner', () => ({ toast: { error: vi.fn() } }));

// The bar canvas is irrelevant — every assertion is about the QUERY, plus one
// about which inline rows reached the chart.
vi.mock('./GanttView', () => ({
GanttView: ({ tasks }: any) => (
<div data-testid="gantt-view" data-task-ids={tasks.map((t: any) => String(t.id)).join(',')} />
),
}));

vi.mock('@object-ui/plugin-detail', async (importOriginal) => ({
...(await importOriginal<typeof import('@object-ui/plugin-detail')>()),
RecordDetailDrawer: () => null,
deriveRecordPageHref: () => null,
}));

const ROWS = [
{ id: '1', subject: 'Alpha needle', visible_from: '2026-01-01', due_date: '2026-01-31' },
{ id: '2', subject: 'Bravo', visible_from: '2026-02-01', due_date: '2026-02-28' },
{ id: '3', subject: 'Charlie needle', visible_from: '2026-03-01', due_date: '2026-03-31' },
];

let calls: Array<Record<string, any>> = [];

function makeDataSource() {
return {
find: vi.fn(async (_resource: string, params: any) => {
calls.push({ ...(params ?? {}) });
return { data: ROWS, total: ROWS.length };
}),
findOne: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
getObjectSchema: vi.fn(async () => ({
name: 'duly_task',
fields: {
id: { name: 'id', type: 'text' },
subject: { name: 'subject', type: 'text' },
visible_from: { name: 'visible_from', type: 'date' },
due_date: { name: 'due_date', type: 'date' },
},
})),
} as any;
}

const base: any = {
type: 'object-gantt',
objectName: 'duly_task',
gantt: { titleField: 'subject', startDateField: 'visible_from', endDateField: 'due_date' },
};

const last = () => calls[calls.length - 1] ?? {};
const settle = () => new Promise((r) => setTimeout(r, 50));

beforeEach(() => {
calls = [];
});

describe('ObjectGantt — the node\'s search reaches the record query (objectui#10250)', () => {
it('CONTROL: a node with no `search` sends neither $search nor $searchFields', async () => {
render(<ObjectGantt schema={base} dataSource={makeDataSource()} />);
await waitFor(() => expect(calls.length).toBeGreaterThan(0));
expect(last()).not.toHaveProperty('$search');
expect(last()).not.toHaveProperty('$searchFields');
});

it('TERM: `search` is sent as $search', async () => {
render(<ObjectGantt schema={{ ...base, search: 'needle' }} dataSource={makeDataSource()} />);
await waitFor(() => expect(last().$search).toBe('needle'));
expect(last()).not.toHaveProperty('$searchFields');
});

it('FIELDS: `searchableFields` is sent as $searchFields alongside the term', async () => {
render(
<ObjectGantt schema={{ ...base, search: 'needle', searchableFields: ['subject'] }} dataSource={makeDataSource()} />,
);
await waitFor(() => expect(last().$search).toBe('needle'));
expect(last().$searchFields).toEqual(['subject']);
});

it('INERT FIELDS: `searchableFields` with no term sends neither key', async () => {
render(<ObjectGantt schema={{ ...base, searchableFields: ['subject'] }} dataSource={makeDataSource()} />);
await waitFor(() => expect(calls.length).toBeGreaterThan(0));
await settle();
expect(last()).not.toHaveProperty('$search');
expect(last()).not.toHaveProperty('$searchFields');
});

it('CHANGE: a new term re-queries with it; clearing it re-queries without it', async () => {
const dataSource = makeDataSource();
const { rerender } = render(<ObjectGantt schema={{ ...base, search: 'needle' }} dataSource={dataSource} />);
await waitFor(() => expect(last().$search).toBe('needle'));
rerender(<ObjectGantt schema={{ ...base, search: 'thread' }} dataSource={dataSource} />);
await waitFor(() => expect(last().$search).toBe('thread'));
rerender(<ObjectGantt schema={{ ...base }} dataSource={dataSource} />);
await waitFor(() => expect(last()).not.toHaveProperty('$search'));
});

it('STABILITY: a new but equal node — same term, a fresh equal field list — does not re-query', async () => {
const dataSource = makeDataSource();
const node = () => ({ ...base, search: 'needle', searchableFields: ['subject'] });
const { rerender } = render(<ObjectGantt schema={node()} dataSource={dataSource} />);
await waitFor(() => expect(last().$searchFields).toEqual(['subject']));
await settle();
const settled = calls.length;
rerender(<ObjectGantt schema={node()} dataSource={dataSource} />);
rerender(<ObjectGantt schema={node()} dataSource={dataSource} />);
await settle();
expect(calls.length).toBe(settled);
});

it('INLINE: the term narrows inline rows too, through the same in-memory adapter', async () => {
// The provider does not change which query keys apply (objectui#8769):
// `provider: 'value'` goes through `ValueDataSource`, which honours
// `$search` the way it honours `$filter`.
render(<ObjectGantt schema={{ ...base, objectName: undefined, data: { provider: 'value', items: ROWS }, search: 'needle' }} />);
await waitFor(() => expect(screen.getByTestId('gantt-view').getAttribute('data-task-ids')).toBe('1,3'));
});
});
33 changes: 31 additions & 2 deletions packages/plugin-gantt/src/ObjectGantt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -758,6 +758,27 @@ export const ObjectGantt: React.FC<ObjectGanttProps> = ({
// (objectui#7230, the structural note PR #7229 recorded for `ListView`).
const perms = usePermissions();

/**
* The full-text search the record query carries (objectui#10250).
*
* `search` is the term, sent as `$search`; `searchableFields` narrows the
* server-resolved searchable set and is sent as `$searchFields` — only
* alongside a term, exactly as a list's own query sends the pair (ADR-0061:
* the client sends the term, the server decides which fields it matches). A
* `ListView` gantt writes both from its toolbar Search box: the chart
* queries for itself, so the node is the only door the term has.
*
* Both are held as PRIMITIVES for the dependency lists below. The term is a
* string; the field list is keyed on its serialised value, so a host handing
* a fresh but equal array does not refetch (AGENTS.md #10 — key on the
* payload, never on an identity).
*/
const searchTerm = typeof schema.search === 'string' && schema.search !== '' ? schema.search : undefined;
const searchFields = searchTerm && Array.isArray(schema.searchableFields) && schema.searchableFields.length > 0
? schema.searchableFields
: undefined;
const searchFieldsKey = searchFields ? JSON.stringify(searchFields) : '';

// Load (and re-load) data through the resolved adapter. `silent: true`
// re-reads the source WITHOUT flipping `loading`, so GanttView stays mounted
// and keeps its scroll/collapse state — used by the write-readback below and
Expand Down Expand Up @@ -854,6 +875,14 @@ export const ObjectGantt: React.FC<ObjectGanttProps> = ({
// cannot reach this query, by the same ruling.
$top: NON_GRID_ROW_CEILING_TOP,
...(expand.length > 0 ? { $expand: expand } : {}),
// objectui#10250 — the term and its field narrowing, sent together
// or not at all. See `searchTerm` above.
...(searchTerm
? {
$search: searchTerm,
...(searchFields ? { $searchFields: searchFields } : {}),
}
: {}),
});
const capped = applyNonGridRowCeiling(result);
if (isCurrent()) {
Expand Down Expand Up @@ -896,7 +925,7 @@ export const ObjectGantt: React.FC<ObjectGanttProps> = ({
// `ObjectGantt.discardedReloadIdentity-10036.test.tsx` hold the two in
// parity by exercising each entry.
// eslint-disable-next-line react-hooks/exhaustive-deps -- (rest as any).data intentionally untracked, matching the original effect
}, [adapterInputsKey, dataSource, apiFetch, resource, hasInlineData, dataProvider, schema.filter, schema.sort, objectSchema, perms]);
}, [adapterInputsKey, dataSource, apiFetch, resource, hasInlineData, dataProvider, schema.filter, schema.sort, searchTerm, searchFieldsKey, objectSchema, perms]);

/**
* Does the query this effect is about to issue DERIVE anything from the
Expand Down Expand Up @@ -944,7 +973,7 @@ export const ObjectGantt: React.FC<ObjectGanttProps> = ({
useEffect(() => {
if (recordQueryDerivesExpand && !objectSchemaReady) return;
reloadRef.current();
}, [adapterInputsKey, dataSource, apiFetch, resource, hasInlineData, dataProvider, schema.filter, schema.sort, objectSchema, perms, recordQueryDerivesExpand, objectSchemaReady]);
}, [adapterInputsKey, dataSource, apiFetch, resource, hasInlineData, dataProvider, schema.filter, schema.sort, searchTerm, searchFieldsKey, objectSchema, perms, recordQueryDerivesExpand, objectSchemaReady]);

// Transform data to gantt tasks
const tasks = useMemo(() => {
Expand Down
Loading
Loading