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
48 changes: 48 additions & 0 deletions .changeset/8769-gantt-inline-query-keys.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
---
'@object-ui/plugin-gantt': minor
---

Honour `filter`, `sort` and the platform row ceiling on a gantt's inline
(`provider: 'value'`) data (objectui#8769).

**The defect was fail-open.** `ObjectGantt`'s `reload` short-circuited the
inline provider — it set the authored rows and returned BEFORE the adapter
query, which is the one site that lowers `schema.filter` to `$filter`,
`schema.sort` to `$orderby` and the objectui#7210 ceiling to `$top`. So an
inline gantt that declared a `filter` drew **every** authored row, with no
diagnostic. The key that was dropped is the key that NARROWS, which is why this
matters: the chart answered a wider question than the author asked. ⛔ Nothing
was exposed that was not already in the authored schema — this is a
correctness defect, not a data-access one.

Measured two-sided before the repair: the same five rows and the same
`status = open` filter drew 3 rows through a fetching data source and 5 rows
inline, with the same matcher on both sides.

**What changed.** The inline branch is gone; `provider: 'value'` now goes
through `resolveDataSource`'s `ValueDataSource` like every other provider,
which already implements `$filter` / `$orderby` / `$skip` / `$top` / `$select`
over its own array. No filter combinator was written for this change.

**Behaviour you may notice.**

- An authored `filter` / `sort` now narrows and orders inline rows. Both
spellings reach it: `data: { provider: 'value', items }` and `staticData`.
- The row ceiling now applies to inline rows: past 2,000 drawn rows the chart
draws 2,000 and shows the footnote naming both numbers, as it already did for
fetched rows. It is applied to the **filtered** set, so a large inline array
that a `filter` cuts below the ceiling draws every matching row and stays
quiet. Rows a host passes down through the `data` React prop are still never
capped — those are not ours to cap.
- Inline rows now reach the chart as the adapter's own deep copy rather than as
the authored array's object identities. `ViewData.items` is serializable
metadata, so every value the contract admits survives that copy unchanged;
code comparing a row handed to `onTaskClick` against the authored array with
`===` would need `id` equality instead.
- An inline gantt's own write-backs (drag, dependency edit, inline edit,
delete) now survive the reload that follows them, because the reload reads
the adapter that took the write instead of re-reading the authored array over
the top of it.
- A filter comparand that `@object-ui/core`'s `convertFiltersToAST` refuses does
**not** throw at render on this path: the inline matcher is local and never
reaches that converter. The row is excluded and the reason is logged once.
9 changes: 9 additions & 0 deletions content/docs/plugins/plugin-gantt.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -127,9 +127,18 @@ const schema: ObjectGanttSchema = {
gantt?: GanttConfig, // Gantt-specific configuration
viewMode?: 'day' | 'week' | 'month' | 'quarter' | 'year',
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
}
```

`filter` and `sort` are not object-only keys. They narrow and order inline rows
(`staticData`, or `data: { provider: 'value' }`) exactly as they narrow and
order fetched ones, and the platform row ceiling — 2,000 drawn rows with a
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.

`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
10 changes: 10 additions & 0 deletions packages/plugin-gantt/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,16 @@ const recordSource = {
`{ provider: 'value', items }`, `{ provider: 'api', read, write }` or
`{ provider: 'schema', schemaId }`.

**The provider does not change which query keys apply.** An authored `filter`
and `sort` narrow and order the rows on **every** provider, inline ones
included, and the platform row ceiling (2,000 drawn rows, with a footnote
naming both numbers) applies to all of them — inline rows cost the browser what
fetched rows cost. Inline rows go through the same in-memory adapter the other
providers go through, so `filter` is evaluated with the same matcher and the
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.

**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
272 changes: 272 additions & 0 deletions packages/plugin-gantt/src/ObjectGantt.inlineQueryKeys-8769.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,272 @@
/**
* 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#8769 — a `provider: 'value'` gantt honours the three query keys the
* fetching path honours: `filter`, `sort`, and the objectui#7210 row ceiling.
*
* ## What was wrong
*
* `reload` short-circuited the inline provider (`setData(dataItems)`; return)
* BEFORE `effectiveDataSource.find(...)`, which is the ONE site that lowers
* `schema.filter` to `$filter`, `schema.sort` to `$orderby` and the ceiling to
* `$top`. An authored `filter` therefore reached nothing and every inline row
* was charted — the fail-OPEN direction: the key that was ignored is the key
* that NARROWS, so the author saw MORE rows than declared, with no diagnostic.
*
* ⛔ Not a data-exposure boundary. The rows are already in the authored schema;
* what is wrong is that the chart answers a question nobody asked.
*
* ## The two-sided reading is the finding
*
* A one-sided reproduction cannot tell "the filter was ignored" from "the
* filter matched everything", so the first case renders the SAME rows and the
* SAME filter twice — once inline, once through a context adapter — and reads
* the DISAGREEMENT. The adapter is a `ValueDataSource` over the same rows, so
* the matcher is literally the same object on both sides and the only variable
* left is which branch of `reload` ran.
*
* ## ORDER: filter first, ceiling second (objectui#7210 ruling a′)
*
* The ceiling is applied to the FILTERED set, matching the fetching path
* exactly — there `$filter` and `$top` travel in one query and every backend
* filters before it limits. `ceilingOrder` pins it from the observable side: a
* set that is over the ceiling BEFORE filtering and under it after draws every
* matching row and shows NO footnote. A ceiling-then-filter implementation
* would draw at most 2,000 rows there and would still footnote.
*
* ## The `never capped by us` case this file replaces
*
* `ObjectGantt.rowCeiling-7210.test.tsx` asserted that an inline set is never
* capped and never footnoted. That case pinned the SHORT-CIRCUIT's behaviour,
* not ruling a′: the ruling's own budget is measured in DOM ELEMENTS PER
* RECORD (its table was measured over "real child views, inline `value`
* provider"), and an inline row costs the browser exactly what a fetched row
* costs. Nothing in the ruling text carves the provider out. So the exemption
* moves here, inverted, with the triage ruling on objectui#8769 as its
* authority — and `ceilingNote` below is what makes the cut loud rather than
* silent, which is the half ruling a′ actually protects.
*
* ## Live adjacency, measured (objectui#9001 / PR objectui#9049)
*
* That card makes core's `convertFiltersToAST` THROW `FilterOperatorError` for
* an `$icontains` comparand that is not a non-empty string. This repair does
* NOT inherit it: the inline provider resolves to `ValueDataSource`, whose
* matcher is local — it imports `@object-ui/types`, `@objectstack/spec/data`
* and `./batchTransaction.js`, and never the converter, which is reached only
* by `@object-ui/data-objectstack` on the wire path. `refusedComparandDoesNotThrow`
* pins the consequence from the outside: a render, not a throw.
*
* REVERSE VERIFICATION — direction predicted BEFORE running, from the committed
* fix, by restoring the short-circuit: `twoSidedFilter`, `inlineSort`,
* `staticDataSpelling`, `ceilingCap`, `ceilingNote` and `ceilingOrder` go RED;
* `control` and `refusedComparandDoesNotThrow` stay GREEN (the control draws
* the same rows in the same order either way, which is what makes it a
* control).
*/

import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { NON_GRID_ROW_CEILING, NON_GRID_ROW_CEILING_TOP } from '@object-ui/react';
import { ValueDataSource } from '@object-ui/core';
import { ObjectGantt } from './ObjectGantt';

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

// The bar canvas is irrelevant here — every assertion is about WHICH rows
// reached the chart and in WHAT ORDER. Same stub the sibling ObjectGantt pins
// use, widened by the id list because `sort` is unreadable from a count.
vi.mock('./GanttView', () => ({
GanttView: ({ tasks }: any) => (
<div
data-testid="gantt-view"
data-task-count={String(tasks.length)}
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', status: 'open', rank: 30, visible_from: '2026-01-01', due_date: '2026-01-31' },
{ id: '2', subject: 'Bravo', status: 'closed', rank: 10, visible_from: '2026-02-01', due_date: '2026-02-28' },
{ id: '3', subject: 'Charlie', status: 'open', rank: 40, visible_from: '2026-03-01', due_date: '2026-03-31' },
{ id: '4', subject: 'Delta', status: 'closed', rank: 20, visible_from: '2026-04-01', due_date: '2026-04-30' },
{ id: '5', subject: 'Echo', status: 'open', rank: 50, visible_from: '2026-05-01', due_date: '2026-05-31' },
];

/** The three rows an authored `status = open` filter declares. */
const OPEN_IDS = '1,3,5';
const OPEN_FILTER = [['status', '=', 'open']];

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

function drawn() {
const el = screen.getByTestId('gantt-view');
return {
count: el.getAttribute('data-task-count'),
ids: el.getAttribute('data-task-ids'),
};
}

function makeRows(n: number, status: (i: number) => string = () => 'open') {
return Array.from({ length: n }, (_, i) => ({
id: String(i + 1),
subject: `Task ${i + 1}`,
status: status(i),
visible_from: '2026-01-01',
due_date: '2026-12-31',
}));
}

describe('objectui#8769 — the inline `value` provider honours filter / sort / the row ceiling', () => {
let warn: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
});
afterEach(() => {
warn.mockRestore();
vi.unstubAllGlobals();
});

it('twoSidedFilter: the inline path and the fetching path agree on the SAME rows and the SAME filter', async () => {
// One matcher, two branches of `reload`. Any disagreement here is the
// short-circuit and nothing else.
const dataSource = new ValueDataSource({ items: ROWS }) as any;

const { unmount } = render(
<ObjectGantt
schema={{ ...base, data: { provider: 'value', items: ROWS }, filter: OPEN_FILTER }}
/>,
);
await waitFor(() => expect(screen.getByTestId('gantt-view')).toBeTruthy());
await waitFor(() => expect(drawn().count).toBe('3'));
const inline = drawn();
unmount();

render(
<ObjectGantt
schema={{ ...base, objectName: 'duly_task', filter: OPEN_FILTER }}
dataSource={dataSource}
/>,
);
await waitFor(() => expect(drawn().count).toBe('3'));
const fetching = drawn();

expect(inline.ids).toBe(OPEN_IDS);
expect(fetching.ids).toBe(OPEN_IDS);
// The finding, stated as the two paths agreeing.
expect(inline.ids).toBe(fetching.ids);
});

it('inlineSort: an authored `sort` orders the inline rows', async () => {
render(
<ObjectGantt
schema={{
...base,
data: { provider: 'value', items: ROWS },
sort: [{ field: 'rank', order: 'desc' }],
}}
/>,
);
await waitFor(() => expect(drawn().count).toBe('5'));
// rank 50,40,30,20,10 → Echo, Charlie, Alpha, Delta, Bravo
expect(drawn().ids).toBe('5,3,1,4,2');
});

it('staticDataSpelling: the `staticData` rung reaches the same repair', async () => {
// The ruled three-rung ladder wraps `staticData` into
// `{ provider: 'value', items }`, so it lands on exactly this path. It is
// the second spelling an author can use and it needs its own row.
render(<ObjectGantt schema={{ ...base, staticData: ROWS, filter: OPEN_FILTER }} />);
await waitFor(() => expect(drawn().count).toBe('3'));
expect(drawn().ids).toBe(OPEN_IDS);
});

it('ceilingCap: an inline set past the ceiling draws exactly the ceiling', async () => {
render(
<ObjectGantt
schema={{
...base,
data: { provider: 'value', items: makeRows(NON_GRID_ROW_CEILING_TOP + 500) },
}}
/>,
);
await waitFor(() => expect(drawn().count).toBe(String(NON_GRID_ROW_CEILING)));
});

it('ceilingNote: the cut is LOUD — the footnote names both numbers', async () => {
const total = NON_GRID_ROW_CEILING_TOP + 500;
render(
<ObjectGantt schema={{ ...base, data: { provider: 'value', items: makeRows(total) } }} />,
);
await waitFor(() => expect(drawn().count).toBe(String(NON_GRID_ROW_CEILING)));

const note = await screen.findByRole('note');
expect(note.getAttribute('data-row-ceiling-note')).toBe('non-grid');
expect(note.textContent).toContain(String(NON_GRID_ROW_CEILING));
expect(note.textContent).toContain(String(total));
});

it('ceilingOrder: the ceiling is applied to the FILTERED set, not to the raw one', async () => {
// Over the ceiling before filtering, under it after: 2,400 rows of which
// only every third is `open` (800). Filter-then-ceiling draws all 800 and
// stays quiet; ceiling-then-filter could not.
const rows = makeRows(2400, (i) => (i % 3 === 0 ? 'open' : 'closed'));
render(
<ObjectGantt
schema={{ ...base, data: { provider: 'value', items: rows }, filter: OPEN_FILTER }}
/>,
);
await waitFor(() => expect(drawn().count).toBe('800'));
expect(screen.queryByRole('note')).toBeNull();
});

it('control: an inline chart with NO filter, NO sort and under the ceiling is unchanged', async () => {
// ⭐ Green on BOTH ablation legs by construction. Without it a reviewer
// cannot tell this repair from "the inline path now drops rows".
render(<ObjectGantt schema={{ ...base, data: { provider: 'value', items: ROWS } }} />);
await waitFor(() => expect(drawn().count).toBe('5'));
expect(drawn().ids).toBe('1,2,3,4,5');
expect(screen.queryByRole('note')).toBeNull();
});

it('refusedComparandDoesNotThrow: a comparand core would refuse RENDERS, it does not throw', async () => {
// objectui#9001 / PR objectui#9049 makes `convertFiltersToAST` throw for
// this comparand. The inline path resolves to `ValueDataSource`, which
// never reaches that converter — it excludes the row and logs once.
render(
<ObjectGantt
schema={{
...base,
data: { provider: 'value', items: ROWS },
filter: [['subject', 'icontains', '']],
}}
/>,
);
await waitFor(() => expect(screen.getByTestId('gantt-view')).toBeTruthy());
await waitFor(() => expect(drawn().count).toBe('0'));
expect(
warn.mock.calls.some((c: unknown[]) => String(c[0]).includes('icontains')),
).toBe(true);
});
});
Loading
Loading