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
23 changes: 23 additions & 0 deletions .changeset/7333-gantt-empty-host-data.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
'@object-ui/plugin-gantt': patch
---

`ObjectGantt` no longer treats an EMPTY host `data` array as the rows it was handed (objectui#7333).

`ObjectGantt` read a host `data` prop at two places, and both accepted `[]`. The
reload's short-circuit tested `data && Array.isArray(data)`, and an empty array
is truthy, so the chart adopted it and returned before its own query. The gating
flag tested `Array.isArray(data)` alone, so the same array also switched off the
object-schema gate and the data-invalidation subscription of that query. A gantt
whose `data` names its own source, for example `{ provider: 'api', read }`,
therefore painted an EMPTY chart whenever a host handed it an array it had not
filled yet.

Both places now read one predicate. A host `data` array is adopted only when it
has rows. An empty one reads as "no host rows yet": the chart queries its own
source, exactly as it does with no host array at all, and adopts the host's rows
when they arrive.

Who can reach this: the registered `object-gantt` renderer still forwards no
host prop, and `ObjectGanttProps` does not declare `data`, so only a caller that
renders `ObjectGantt` directly and spreads `data` in was affected.
218 changes: 218 additions & 0 deletions packages/plugin-gantt/src/ObjectGantt.emptyHostData-7333.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
/**
* 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#7333 — an EMPTY host `data` array is "no host rows yet", not "the
* host owns zero rows".
*
* `ObjectGantt` read a host `data` prop at two sites, and both took `[]` as an
* authoritative answer: `reload`'s short-circuit (`data && Array.isArray(data)`
* — `[]` is truthy) adopted it and returned before the chart's own query, and
* the gating flag (`Array.isArray(data)`) switched off the object-schema gate
* and the invalidation subscription that belong to that query. So a gantt whose
* `data` names its own source painted an EMPTY chart the moment a host handed
* it an array it had not filled yet.
*
* Both sites now read one predicate, `hostRows` in `ObjectGantt.tsx`: a
* non-empty host array is adopted as it always was, and an empty one leaves the
* chart reading from its own source, exactly as with no host array at all.
*
* ⚠️ Reach: the registered `object-gantt` renderer forwards no host prop, which
* `ObjectGantt.hostDataProp-7210.test.tsx` pins, and `ObjectGanttProps` does
* not declare `data`. So every case here renders `ObjectGantt` directly and
* spreads the prop in, as a direct caller would have to.
*
* Cases 1 and 4 are the pins; 2 and 3 are their controls, and 5 is the "yet":
* 1. api provider + host `[]` ⇒ the endpoint is read and its rows paint;
* 2. api provider + host rows ⇒ those rows paint, the endpoint is never read;
* 3. api provider + no host `data` ⇒ the endpoint is read;
* 4. object provider + host `[]` ⇒ the query waits for the object schema and
* goes out ONCE, already expanded (the gating flag's half);
* 5. host rows that arrive after an empty first render are adopted.
*/

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

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

vi.mock('./GanttView', () => ({
GanttView: ({ tasks }: any) => (
<div data-testid="gantt-view" data-task-count={String(tasks.length)}>
{tasks.map((t: any) => (
<div key={t.id} data-testid={`gv-task-${t.id}`}>{t.title}</div>
))}
</div>
),
}));

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

/** What the gantt's own `api` endpoint answers with. */
const ENDPOINT_ROWS = [
{ id: 'e1', name: 'Endpoint task 1', start_date: '2026-01-01', end_date: '2026-01-05' },
{ id: 'e2', name: 'Endpoint task 2', start_date: '2026-01-02', end_date: '2026-01-06' },
];

/** What a host hands down as `data` when it DOES have rows. */
const HOST_ROWS = [
{ id: 'h1', name: 'Host task 1', start_date: '2026-02-01', end_date: '2026-02-03' },
];

const ENDPOINT = '/api/gantt/tree';

function apiSchema(): any {
return {
type: 'object-gantt',
objectName: 'tasks',
gantt: { titleField: 'name', startDateField: 'start_date', endDateField: 'end_date' },
data: { provider: 'api', read: { url: ENDPOINT, method: 'GET' } },
};
}

function makeApiFetch() {
return vi.fn(async () =>
new Response(JSON.stringify({ data: ENDPOINT_ROWS, total: ENDPOINT_ROWS.length }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);
}

/** The URLs the endpoint was read at, in order. */
function readUrls(apiFetch: ReturnType<typeof makeApiFetch>): string[] {
return (apiFetch.mock.calls as unknown as Array<[unknown]>).map(([url]) => String(url));
}

/** Renders an api-provider gantt, spreading `hostProps` in as a direct caller would. */
function renderApiGantt(apiFetch: ReturnType<typeof makeApiFetch>, hostProps: Record<string, unknown>) {
const tree = (props: Record<string, unknown>) => (
<SchemaRendererProvider dataSource={null} apiFetch={apiFetch as any}>
<ObjectGantt schema={apiSchema()} {...(props as any)} />
</SchemaRendererProvider>
);
const utils = render(tree(hostProps));
return { ...utils, rerenderWith: (props: Record<string, unknown>) => utils.rerender(tree(props)) };
}

const taskCount = () => screen.getByTestId('gantt-view').getAttribute('data-task-count');

/** Lets every in-flight read settle, so a query that would follow is counted. */
const settle = () => act(() => new Promise<void>((resolve) => setTimeout(resolve, 100)));

describe('objectui#7333 — an api-provider gantt handed an EMPTY host array still reads its endpoint', () => {
// No test here may reach the network through the bare global fetch.
const bareFetch = vi.fn(async () => new Response('{}', { status: 599 }));

beforeEach(() => {
bareFetch.mockClear();
vi.stubGlobal('fetch', bareFetch);
});

afterEach(() => {
vi.unstubAllGlobals();
});

it('host `data: []` ⇒ the endpoint is read and its rows paint', async () => {
const apiFetch = makeApiFetch();

renderApiGantt(apiFetch, { data: [] });

await waitFor(() => expect(screen.getByTestId('gv-task-e2')).toBeTruthy());
expect(taskCount()).toBe(String(ENDPOINT_ROWS.length));
expect(readUrls(apiFetch).some((url) => url.includes(ENDPOINT))).toBe(true);
expect(bareFetch).not.toHaveBeenCalled();
});

it('CONTROL — host `data` with rows ⇒ those rows paint and the endpoint is never read', async () => {
const apiFetch = makeApiFetch();

renderApiGantt(apiFetch, { data: HOST_ROWS });

await waitFor(() => expect(screen.getByTestId('gv-task-h1')).toBeTruthy());
expect(taskCount()).toBe(String(HOST_ROWS.length));
expect(screen.queryByTestId('gv-task-e1')).toBeNull();
expect(apiFetch).not.toHaveBeenCalled();
});

it('CONTROL — no host `data` ⇒ the endpoint is read', async () => {
const apiFetch = makeApiFetch();

renderApiGantt(apiFetch, {});

await waitFor(() => expect(screen.getByTestId('gv-task-e2')).toBeTruthy());
expect(readUrls(apiFetch).some((url) => url.includes(ENDPOINT))).toBe(true);
});

it('host rows that arrive after an empty first render are adopted', async () => {
const apiFetch = makeApiFetch();

const { rerenderWith } = renderApiGantt(apiFetch, { data: [] });
await waitFor(() => expect(screen.getByTestId('gv-task-e2')).toBeTruthy());

rerenderWith({ data: HOST_ROWS });

await waitFor(() => expect(screen.getByTestId('gv-task-h1')).toBeTruthy());
expect(taskCount()).toBe(String(HOST_ROWS.length));
expect(screen.queryByTestId('gv-task-e1')).toBeNull();
});
});

describe('objectui#7333 — an EMPTY host array does not switch off the object-schema gate', () => {
/** A definition with a lookup, so the gated query has a real `$expand` to carry. */
const OBJECT_DEF = {
name: 'task',
fields: {
id: { name: 'id', type: 'text' },
subject: { name: 'subject', type: 'text' },
owner: { name: 'owner', type: 'lookup', reference_to: 'user' },
visible_from: { name: 'visible_from', type: 'date' },
due_date: { name: 'due_date', type: 'date' },
},
};

const OBJECT_ROWS = [
{ id: '1', subject: 'Renewal', owner: 'u1', visible_from: '2026-01-01', due_date: '2026-01-05' },
];

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

it('host `data: []` on an object-bound gantt ⇒ ONE query, issued after the schema settles, already expanded', async () => {
const adapter = {
find: vi.fn(async () => ({ data: OBJECT_ROWS, total: OBJECT_ROWS.length })),
findOne: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
getObjectSchema: vi.fn(async () => OBJECT_DEF),
} as any;

render(<ObjectGantt schema={objectSchema} dataSource={adapter} {...({ data: [] } as any)} />);

await waitFor(() => expect(taskCount()).toBe(String(OBJECT_ROWS.length)));
await waitFor(() => expect(adapter.find).toHaveBeenCalled());
await settle();
// Ungated, the first query goes out before the definition lands and
// carries no expansion, and a second follows once it does: `[null,
// ['owner']]` — the signature `ObjectGantt.fetchGate-7225.test.tsx`
// records for the ungated regime.
const expandSets = adapter.find.mock.calls.map(([, params]: [string, any]) => params?.$expand ?? null);
expect(expandSets).toEqual([['owner']]);
});
});
56 changes: 46 additions & 10 deletions packages/plugin-gantt/src/ObjectGantt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -840,6 +840,37 @@ export const ObjectGantt: React.FC<ObjectGanttProps> = ({
* over by hand.
*/
const reportOwedRef = useRef(false);
/**
* Rows a HOST component handed down as a `data` prop, or `null` when it
* handed none (objectui#7333).
*
* ⭐ An EMPTY array is `null` here. It is "no host rows yet", ⛔ not "the
* host owns zero rows". Both sites that read this prop — the short-circuit
* at the head of `reload` and `recordQueryDerivesExpand` below — used to
* take `[]` as an authoritative answer (`[]` is truthy, and
* `Array.isArray([])` holds): the chart adopted it and returned before its
* own query, and painted an EMPTY chart in place of the rows its `data`
* config names. A host array that has not been filled yet looks exactly like
* that. So an empty array leaves the chart reading from its own source, as it
* does with no host array at all; a NON-EMPTY one is adopted as it always
* was. Pinned in `ObjectGantt.emptyHostData-7333.test.tsx`.
*
* One predicate, evaluated once per render, for BOTH sites. `reload` reads it
* through `hostRowsRef` rather than closing over `rest`: `reload` does not
* key on the host rows, so a closed-over value is the one from whichever
* render last rebuilt `reload`. When a host's rows arrive after an empty
* first render, `recordQueryDerivesExpand` flips and fires the fetch effect,
* and that run must see the rows that flipped it, not the `[]` before them.
*
* ⚠️ The registry path never hands this prop: the registered `object-gantt`
* renderer forwards no host prop (`ObjectGantt.hostDataProp-7210.test.tsx`
* pins that), and `ObjectGanttProps` does not declare `data`. What reaches it
* is a direct caller that spreads one in.
*/
const hostRowsProp: unknown = (rest as Record<string, unknown>).data;
const hostRows = Array.isArray(hostRowsProp) && hostRowsProp.length > 0 ? hostRowsProp : null;
const hostRowsRef = useRef(hostRows);
hostRowsRef.current = hostRows;
const reload = useCallback(async ({ silent = false, inPlace = silent }: { silent?: boolean; inPlace?: boolean } = {}) => {
const seq = ++reloadSeqRef.current;
const isCurrent = () => reloadSeqRef.current === seq;
Expand All @@ -848,10 +879,12 @@ export const ObjectGantt: React.FC<ObjectGanttProps> = ({
try {
if (inPlace) setRefreshing(true);
else setLoading(true);
// 1. Check for data prop (Unified ListView)
if ((rest as any).data && Array.isArray((rest as any).data)) {
// 1. Rows a host handed down — non-empty only (objectui#7333); see
// `hostRows` above for why this reads the ref.
const handedRows = hostRowsRef.current;
if (handedRows) {
if (isCurrent()) {
setData((rest as any).data);
setData(handedRows);
setRowCeiling(null);
// Committed rows clear an earlier failure (objectui#10578) — the
// reasoning sits on the adapter's commit below.
Expand Down Expand Up @@ -1003,18 +1036,21 @@ export const ObjectGantt: React.FC<ObjectGanttProps> = ({
// The fetch effect below repeats this list for the same reason; the pins in
// `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
// eslint-disable-next-line react-hooks/exhaustive-deps -- `searchFields` is keyed by its serialised value, `searchFieldsKey`; the host rows are read through `hostRowsRef` (objectui#7333)
}, [adapterInputsKey, dataSource, apiFetch, resource, hasInlineData, dataProvider, queryFilter, schema.sort, searchTerm, searchFieldsKey, objectSchema, perms]);

/**
* Does the query this effect is about to issue DERIVE anything from the
* object schema? Only the adapter branch does. A host-supplied `data` array
* and an inline `value` set both paint with no metadata read at all, so
* gating them would hold a paint on a resolution that buys them nothing.
* Same scoping ObjectCalendar's gate uses, and for the same reason.
* object schema? Only the adapter branch does. Rows a host handed down and
* an inline `value` set both paint with no metadata read at all, so gating
* them would hold a paint on a resolution that buys them nothing. Same
* scoping ObjectCalendar's gate uses, and for the same reason.
*
* "Rows a host handed down" is `hostRows`, the predicate `reload` reads — an
* EMPTY host array is not one (objectui#7333), so it keeps the gate and the
* invalidation subscription of the query the chart still issues.
*/
const hasHostData = Array.isArray((rest as any).data);
const recordQueryDerivesExpand = !hasHostData && !hasInlineData;
const recordQueryDerivesExpand = hostRows === null && !hasInlineData;

// ⭐ objectui#7225 ask 2 (objectui#6482's undischarged gating half) — the
// object schema GATES this query; it does not refine it afterwards.
Expand Down
12 changes: 6 additions & 6 deletions packages/plugin-list/src/ListView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2071,12 +2071,12 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
// The withholding is kept anyway: it is unreachable, not wrong, and it stops
// being unreachable the moment that wrapper forwards host props — whether a
// non-grid view may fetch unbounded at all is an open maintainer decision
// (objectui#7210, half 2). `ObjectGantt.reload` takes its `rest.data`
// short-circuit on `data && Array.isArray(data)`, and `[]` satisfies both,
// while this view's rows array is never filled (the fetch effect below
// returns early for it). Forwarding it would therefore replace the endpoint's
// tree with an EMPTY chart — not with the stale object rows the old comment
// warned about.
// (objectui#7210, half 2). This view's rows array is not the endpoint's
// answer (the fetch effect below returns early for it), and `ObjectGantt`
// adopts any NON-EMPTY host `data` array as its rows. An EMPTY one no longer
// blanks the chart: it reads as "no host rows yet" and the chart still
// queries its endpoint (objectui#7333). So the withholding guards against
// this array ever being adopted in the endpoint's place.
const ganttOwnsData =
currentView === 'gantt' &&
!!schema.data &&
Expand Down
8 changes: 4 additions & 4 deletions packages/plugin-list/src/__tests__/ListView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2686,10 +2686,10 @@ describe('ListView — gantt view fed by an api-provider ViewData', () => {
// `plugin-gantt/src/ObjectGantt.hostDataProp-7210.test.tsx`). That makes (c)
// a guard on a path the registry does not currently reach — keep it. The day
// that wrapper forwards host props (objectui#7210, half 2, an open maintainer
// decision) it becomes load-bearing: `ObjectGantt.reload` short-circuits on
// `data && Array.isArray(data)`, and this view's rows array is a truthy `[]`
// forever, so handing it down would paint an EMPTY chart in place of the
// endpoint's tree.
// decision) it becomes load-bearing: this view's rows array is not the
// endpoint's answer, and `ObjectGantt` adopts any NON-EMPTY host array as its
// rows. (An EMPTY one no longer blanks the chart: it reads as "no host rows
// yet" and the chart still queries its endpoint, objectui#7333.)
let prevObjectGantt: ReturnType<typeof ComponentRegistry.get>;
let ganttCalls: any[];

Expand Down
Loading