Skip to content
Draft
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
46 changes: 46 additions & 0 deletions .changeset/9061-calendar-map-inline-query-keys.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
---
'@object-ui/plugin-calendar': minor
'@object-ui/plugin-map': minor
---

Honour `filter`, `sort` and the platform row ceiling on a calendar's and a map's
inline (`provider: 'value'`) data (objectui#9061) — the port of objectui#8769's
repair off `ObjectGantt`.

**The defect was fail-open.** Both renderers' fetch effect short-circuited the
inline provider: it set the authored rows and returned BEFORE the adapter query,
which is the one site in each file that lowers `schema.filter` to `$filter`,
`schema.sort` to `$orderby` and the objectui#7210 ceiling to `$top`. So an
inline calendar or map 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 view 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.

**What changed.** Each renderer resolves a `ValueDataSource` for the inline
provider and issues the same query the `object` arm issues. The `api` arm is
untouched, and no dependency array moves. `ValueDataSource` already implements
`$filter` / `$orderby` / `$skip` / `$top` / `$select` over its own array, so no
filter combinator was written for this change.

**Behaviour you may notice.**

- An authored `filter` / `sort` now narrows and orders inline rows. Every
spelling reaches it: `data: { provider: 'value', items }` and `staticData` on
both renderers, plus the map's bare-array `data` shorthand.
- The row ceiling now applies to inline rows: past 2,000 drawn rows the view
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 view as the adapter's own deep copy rather than as
the authored array's object identities. Code comparing a row handed to
`onEventClick` / `onMarkerClick` against the authored array with `===` needs
`id` equality instead.
- That copy is a JSON round-trip, so inline rows must be JSON-serializable.
A record graph carrying a back-reference, or a `BigInt` id, now renders an
error panel instead of the view. `ObjectGantt` has refused the same input
since before objectui#8769; `ObjectMap` did not, and pinned that it need not
(objectui#6018). ⚠️ That pin is left RED and untouched in this change — see
the pull request body.
10 changes: 10 additions & 0 deletions content/docs/plugins/plugin-calendar.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,16 @@ const schema: ObjectCalendarSchema = {
}
```

`filter` and `sort` are not object-only keys (objectui#9061). They narrow and
order inline records — `staticData` or `data: { provider: 'value' }` — exactly as
they narrow and order fetched ones, and the platform row ceiling (2,000 drawn
records with a footnote naming both numbers) applies to inline records too. The
ceiling is applied to the **filtered** set, so a large inline array that a
`filter` cuts below the ceiling draws every matching record and shows no
footnote. Inline records reach the calendar as the in-memory adapter's own deep
copy, so they must be JSON-serializable and a record handed to `onEventClick` is
not `===` the authored object.

### CalendarConfig

```plaintext
Expand Down
10 changes: 10 additions & 0 deletions content/docs/plugins/plugin-map.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,16 @@ const schema: ObjectMapSchema = {
}
```

`filter` and `sort` are not object-only keys (objectui#9061). They narrow and
order inline rows — `staticData`, a bare array under `data`, or
`data: { provider: 'value' }` — exactly as they narrow and order fetched ones,
and the platform row ceiling (2,000 plotted 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 plots every
matching row and shows no footnote. Inline rows reach the map as the in-memory
adapter's own deep copy, so they must be JSON-serializable and a record handed
to `onMarkerClick` is not `===` the authored object.

### ObjectMapConfig

```plaintext
Expand Down
18 changes: 18 additions & 0 deletions packages/plugin-calendar/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,24 @@ const schema: ObjectCalendarSchema = {

Pass the adapter to `SchemaRendererProvider` to wire the fetch up.

**The provider does not change which query keys apply** (objectui#9061, the port
of objectui#8769). An authored `filter` and `sort` narrow and order the records
on **every** provider, inline ones included — both `staticData` and
`data: { provider: 'value', items }` reach the same in-memory adapter the
`object` provider goes through, so `filter` is evaluated with the same matcher.
Before objectui#9061 the inline provider skipped that query and drew every
authored record with an authored `filter` silently dropped. The platform row
ceiling (2,000 drawn rows, with a footnote naming both numbers — objectui#7210,
ruling a′) applies to inline records too, and it is applied to the **filtered**
set, never to the raw one: a large inline array that a `filter` cuts below the
ceiling draws every matching record and shows no footnote.

⚠️ Two consequences of routing inline records through the adapter. They reach the
calendar as that adapter's own deep copy rather than as the authored array's
object identities, so code comparing a record handed to `onEventClick` against
the authored array with `===` needs `id` equality instead; and the copy is a JSON
round-trip, so inline records must be JSON-serializable.

## Customization

Style the calendar with Tailwind classes:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,278 @@
/**
* 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#9061 — a `provider: 'value'` calendar honours the three query keys
* the fetching path honours: `filter`, `sort`, and the objectui#7210 row
* ceiling. The port of objectui#8769, which removed the same short-circuit
* from `ObjectGantt`; `ObjectMap.inlineQueryKeys-9061.test.tsx` is the twin.
*
* ## What was wrong
*
* The record-fetch effect short-circuited the inline provider
* (`setData(dataItems)`; return) BEFORE the `find` below it, which is the ONE
* site in the file 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 drawn — the fail-OPEN direction: the key
* that was ignored is the key that NARROWS, so the author saw MORE events than
* declared, with no diagnostic.
*
* ⛔ Not a data-exposure boundary. The rows are already in the authored schema;
* what is wrong is that the calendar 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 `twoSidedFilter` renders the SAME rows and the
* SAME filter twice — once inline, once through a context adapter that is
* itself a `ValueDataSource` over those rows — and reads the DISAGREEMENT. The
* matcher is literally the same implementation on both sides, so the only
* variable left is which branch of the effect ran.
*
* ## ORDER: filter first, ceiling second (objectui#7210 ruling a′)
*
* The ceiling is applied to the FILTERED set, matching the fetching path, where
* `$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.
*
* ## What this repair does NOT inherit from the gantt
*
* `ObjectGantt` had a standing pin asserting an inline set is never capped and
* never footnoted, which objectui#8769 had to invert. `ObjectCalendar` has no
* such pin — `ObjectCalendar.rowCeiling-7210.test.tsx` grades the `object`
* provider only — so the ceiling rows below are NEW coverage rather than an
* inversion. Verified by reading that file's case list before writing this one.
*
* ⚠️ MEASURED CONSEQUENCE, reported rather than hidden: an author who supplies
* more than `NON_GRID_ROW_CEILING` inline rows now sees fewer events than they
* supplied. `ceilingCap` and `ceilingNote` are that measurement. It is the
* ruled behaviour rather than a silent loss — ruling a′'s budget is measured in
* DOM ELEMENTS PER RECORD and its own table was taken over the inline `value`
* provider, and `NonGridRowCeilingNote` names BOTH numbers on screen, which is
* the half the ruling actually protects. The calendar is the view where a cut
* is hardest to see from the picture (a month grid draws at most four events
* per day cell), so the footnote is the whole signal and `ceilingNote` is not
* optional.
*
* REVERSE VERIFICATION — direction predicted BEFORE running, from the committed
* fix, by restoring the short-circuit in `ObjectCalendar.tsx` ONLY (the map's
* fix left in place): `twoSidedFilter`, `inlineSort`, `staticDataSpelling`,
* `ceilingCap`, `ceilingNote` and `ceilingOrder` go RED; `control` and
* `providerBackedControl` stay GREEN — the first draws the same rows in the
* same order either way, the second never touches the inline path at all,
* which is what makes them controls.
*/

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

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

// The month grid is irrelevant here — every assertion is about WHICH records
// reached the view layer and in WHAT ORDER, and the grid deliberately hides
// both (at most four events per day cell, then a "+N more"). Same stub the
// sibling `ObjectCalendar.rowCeiling-7210` pin uses, widened by the id list
// because `sort` is unreadable from a count.
vi.mock('./CalendarView', async (importOriginal) => {
const actual = await importOriginal<any>();
return {
...actual,
CalendarView: ({ events }: any) => (
<div
data-testid="calendar-view"
data-event-count={String(events.length)}
data-event-ids={events.map((e: any) => String(e.id)).join(',')}
/>
),
};
});

afterEach(cleanup);

const NOW = new Date();

/** A date inside the month the calendar opens on, so the row is drawable. */
function dayOfThisMonth(i: number) {
const d = new Date(NOW.getFullYear(), NOW.getMonth(), (i % 28) + 1);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(
d.getDate(),
).padStart(2, '0')}`;
}

const ROWS = [
{ id: '1', subject: 'Alpha', status: 'open', rank: 30, start_at: dayOfThisMonth(0), end_at: dayOfThisMonth(0) },
{ id: '2', subject: 'Bravo', status: 'closed', rank: 10, start_at: dayOfThisMonth(1), end_at: dayOfThisMonth(1) },
{ id: '3', subject: 'Charlie', status: 'open', rank: 40, start_at: dayOfThisMonth(2), end_at: dayOfThisMonth(2) },
{ id: '4', subject: 'Delta', status: 'closed', rank: 20, start_at: dayOfThisMonth(3), end_at: dayOfThisMonth(3) },
{ id: '5', subject: 'Echo', status: 'open', rank: 50, start_at: dayOfThisMonth(4), end_at: dayOfThisMonth(4) },
];

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

const base: any = {
type: 'calendar',
calendar: { titleField: 'subject', startDateField: 'start_at', endDateField: 'end_at' },
};

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

function makeRows(n: number, status: (i: number) => string = () => 'open') {
return Array.from({ length: n }, (_, i) => ({
id: String(i + 1),
subject: `Event ${i + 1}`,
status: status(i),
start_at: dayOfThisMonth(i),
end_at: dayOfThisMonth(i),
}));
}

describe('objectui#9061 — the calendar honours filter / sort / the row ceiling on inline `value` data', () => {
it('twoSidedFilter: the inline path and the fetching path agree on the SAME rows and the SAME filter', async () => {
// One matcher, two branches of the record-fetch effect. Any disagreement
// here is the short-circuit and nothing else.
const dataSource = new ValueDataSource({ items: ROWS }) as any;
dataSource.getObjectSchema = vi.fn(async () => ({ name: 'event', fields: {} }));

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

render(
<ObjectCalendar
schema={{ ...base, objectName: 'event', 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(
<ObjectCalendar
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 () => {
// `resolveRecordSourceConfig` 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(<ObjectCalendar 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(
<ObjectCalendar
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(
<ObjectCalendar 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(
<ObjectCalendar
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 calendar 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(<ObjectCalendar 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('providerBackedControl: a NON-inline view is untouched by this repair', async () => {
// ⭐ The control that BOUNDS the change to the inline path: same filter,
// same sort, same rows, resolved through the context adapter. Green before
// this repair, green after it, and green on both ablation legs.
const dataSource = new ValueDataSource({ items: ROWS }) as any;
dataSource.getObjectSchema = vi.fn(async () => ({ name: 'event', fields: {} }));

render(
<ObjectCalendar
schema={{
...base,
objectName: 'event',
filter: OPEN_FILTER,
sort: [{ field: 'rank', order: 'desc' }],
}}
dataSource={dataSource}
/>,
);
await waitFor(() => expect(drawn().count).toBe('3'));
// rank 50,40,30 → Echo, Charlie, Alpha
expect(drawn().ids).toBe('5,3,1');
expect(screen.queryByRole('note')).toBeNull();
});
});
Loading
Loading