From 431fc8732d506982d2eeeb1b077dc852e3847e12 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 09:34:38 +0000 Subject: [PATCH 1/3] fix(plugin-calendar,plugin-map): honour filter, sort and the row ceiling on inline `value` data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports objectui#8769's repair off `ObjectGantt` to the two siblings that carry a hand copy of the same short-circuit. Their fetch effect exited on `provider: 'value'` with `setData(dataItems)` BEFORE the adapter query, which is the one site in each file that lowers `schema.filter` onto `$filter`, `schema.sort` onto `$orderby` and the objectui#7210 ceiling onto `$top`. An authored `filter` therefore reached nothing and every authored row was drawn — the fail-OPEN direction, because the key that was dropped is the key that NARROWS. Not a literal transplant: `ObjectGantt` resolves ONE adapter for every provider and could delete the branch and fall through, while these two call `find` inside their `dataProvider === 'object'` arm, behind an `$expand` projection an inline set has no metadata to build. So each resolves `ValueDataSource` for the inline provider only, leaving the `api` arm exactly as it was, and no dependency array moves. Part of objectui#9061. ⚠️ BLOCKED on a decision — see the PR body: routing the inline rows through `ValueDataSource` makes them pass through that adapter's `JSON.parse(JSON.stringify(...))` constructor clone, which retires objectui#6018's pinned guarantee that a map's inline rows never have to be serializable. Three standing assertions are left RED and untouched on purpose. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ --- ...jectCalendar.inlineQueryKeys-9061.test.tsx | 278 +++++++++++++++++ .../plugin-calendar/src/ObjectCalendar.tsx | 59 +++- .../ObjectMap.inlineQueryKeys-9061.test.tsx | 293 ++++++++++++++++++ packages/plugin-map/src/ObjectMap.tsx | 68 +++- 4 files changed, 692 insertions(+), 6 deletions(-) create mode 100644 packages/plugin-calendar/src/ObjectCalendar.inlineQueryKeys-9061.test.tsx create mode 100644 packages/plugin-map/src/ObjectMap.inlineQueryKeys-9061.test.tsx diff --git a/packages/plugin-calendar/src/ObjectCalendar.inlineQueryKeys-9061.test.tsx b/packages/plugin-calendar/src/ObjectCalendar.inlineQueryKeys-9061.test.tsx new file mode 100644 index 0000000000..94d1d4342e --- /dev/null +++ b/packages/plugin-calendar/src/ObjectCalendar.inlineQueryKeys-9061.test.tsx @@ -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()), + 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(); + return { + ...actual, + CalendarView: ({ events }: 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( + , + ); + await waitFor(() => expect(drawn().count).toBe('3')); + const inline = drawn(); + unmount(); + + render( + , + ); + 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( + , + ); + 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(); + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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(); + 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( + , + ); + 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(); + }); +}); diff --git a/packages/plugin-calendar/src/ObjectCalendar.tsx b/packages/plugin-calendar/src/ObjectCalendar.tsx index 5ede88b21f..460e680d5b 100644 --- a/packages/plugin-calendar/src/ObjectCalendar.tsx +++ b/packages/plugin-calendar/src/ObjectCalendar.tsx @@ -63,6 +63,7 @@ import { createFieldColorResolver, resolveRecordSourceConfig, resolveRecordSourceObjectName, + ValueDataSource, } from '@object-ui/core'; export interface CalendarSchema { @@ -453,9 +454,63 @@ export const ObjectCalendar: React.FC = ({ setLoading(true); if (hasInlineData && dataProvider === 'value') { + // THE INLINE PROVIDER NO LONGER EXITS BEFORE THE QUERY + // (objectui#9061, porting objectui#8769's repair off `ObjectGantt`). + // + // This branch used to be `setData(dataItems); return;` — taken + // BEFORE the `find` below, which is the ONE site in this file that + // lowers `schema.filter` onto `$filter`, `schema.sort` onto + // `$orderby` (via `convertSortToQueryParams`) and the objectui#7210 + // ceiling onto `$top`. So an authored `filter` reached nothing and + // the grid drew EVERY authored row: the fail-OPEN direction, because + // the key that was dropped is the key that NARROWS. Accepting a + // declared key one cannot honour is the defect, and `ValueDataSource` + // honours all three over its own array, so they are honoured here. + // + // ⚠️ NOT a literal transplant of the gantt's diff, and the difference + // is structural rather than cosmetic. `ObjectGantt` resolves ONE + // `effectiveDataSource` for every provider, so its repair was to + // delete the branch and let the inline case fall through to the + // shared query. This effect's `find` sits INSIDE the + // `dataProvider === 'object'` arm, behind an `$expand` projection an + // inline set has no metadata to build and behind the + // `objectSchemaReady` gate deliberately scoped to that same arm. + // Falling through here would therefore throw + // `DataSource required for object/api providers` on a calendar that + // needs no DataSource at all. So the adapter is resolved for the + // inline provider ONLY — `api` keeps exactly the behaviour it had — + // and the same three keys are lowered onto the same query shape. + // + // Built here rather than memoised at render scope so this effect goes + // on reading only the primitive fields objectui#6592 named + // (`dataProvider`, `dataItems`): no dependency is added or removed, + // so nothing about WHEN this effect re-runs changes with this repair. + // + // `ValueDataSource` ignores the resource name — it queries its own + // array — so this branch needs none of the object-name ladder the + // `object` arm below resolves. + const inlineSource = new ValueDataSource({ items: (dataItems as any[]) ?? [] }); + const result = await inlineSource.find('', { + $filter: schema.filter, + $orderby: convertSortToQueryParams(schema.sort), + // The same platform ceiling the `object` arm sends, on the same + // probe-row convention (objectui#7210, ruling a′). The ruling's + // budget is measured in DOM elements PER RECORD and its own + // measurement table was taken over the inline `value` provider, so + // an inline event costs the browser exactly what a fetched one + // costs and the ruling text carves out no provider. + // ⛔ Still not authorable: no view key reaches this `$top`. + $top: NON_GRID_ROW_CEILING_TOP, + }); + // Filter first, ceiling second — `ValueDataSource` applies `$filter` + // before `$top`, which is what the fetching path gets for free from + // every backend. A large inline array that an authored `filter` cuts + // below the ceiling therefore draws every matching row and stays + // quiet. + const capped = applyNonGridRowCeiling(result); if (isMounted) { - setData(dataItems as any[]); - setRowCeiling({ truncated: false }); + setData(capped.rows); + setRowCeiling({ truncated: capped.truncated, total: capped.total }); setLoading(false); } return; diff --git a/packages/plugin-map/src/ObjectMap.inlineQueryKeys-9061.test.tsx b/packages/plugin-map/src/ObjectMap.inlineQueryKeys-9061.test.tsx new file mode 100644 index 0000000000..b62ecc3f82 --- /dev/null +++ b/packages/plugin-map/src/ObjectMap.inlineQueryKeys-9061.test.tsx @@ -0,0 +1,293 @@ +/** + * 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'` map 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`; `ObjectCalendar.inlineQueryKeys-9061.test.tsx` is the twin. + * + * ## What was wrong + * + * The 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 plotted — the fail-OPEN direction: the key that was ignored is + * the key that NARROWS, so the author saw MORE markers than declared, with no + * diagnostic. + * + * ⛔ Not a data-exposure boundary. The rows are already in the authored schema; + * what is wrong is that the map 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. + * + * ## ORDER: filter first, ceiling second (objectui#7210 ruling a′) + * + * The ceiling is applied to the FILTERED set, matching the fetching path. + * `ceilingOrder` pins it from the observable side: a set that is over the + * ceiling BEFORE filtering and under it after plots every matching row and + * shows NO footnote. + * + * ## ⚠️ `enableClustering={false}` is what makes the count observable at all + * + * The map clusters above 100 markers by default, and a cluster bubble is + * exactly a marker count folded into one DOM node — the same reason + * `ObjectMap.rowCeiling-7210.test.tsx` passes the prop. Clustering is a pure + * function of the marker array, so turning it off changes what is on screen and + * not what reached the view. + * + * ## 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. `ObjectMap` has no such + * pin — `ObjectMap.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 markers 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. On a map + * the footnote carries a second fact the picture cannot: the CAMERA is fitted + * to the drawn box, which is not the authored set's box once the ceiling bites. + * + * REVERSE VERIFICATION — direction predicted BEFORE running, from the committed + * fix, by restoring the short-circuit in `ObjectMap.tsx` ONLY (the calendar's + * fix left in place): `twoSidedFilter`, `inlineSort`, `staticDataSpelling`, + * `arrayShorthandSpelling`, `ceilingCap`, `ceilingNote` and `ceilingOrder` go + * RED; `control` and `providerBackedControl` stay GREEN — the first plots 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 { ObjectMap } from './ObjectMap'; + +// Same stub the sibling ObjectMap pins use (no WebGL in this lane), widened on +// `Marker` by the longitude it is handed: `sort` is unreadable from a count, +// and the marker array carries `[lng, lat]` straight from the record, so the +// longitude sequence IS the row order. +vi.mock('react-map-gl/maplibre', () => ({ + default: ({ children }: any) =>
{children}
, + Map: ({ children }: any) =>
{children}
, + NavigationControl: () =>
, + Marker: ({ children, longitude }: any) => ( +
+ {children} +
+ ), + Popup: ({ children }: any) =>
{children}
, +})); + +afterEach(cleanup); + +/** `longitude` doubles as the row's identity — see the `Marker` stub above. */ +const ROWS = [ + { id: '1', name: 'Alpha', status: 'open', rank: 30, latitude: 10, longitude: 1 }, + { id: '2', name: 'Bravo', status: 'closed', rank: 10, latitude: 11, longitude: 2 }, + { id: '3', name: 'Charlie', status: 'open', rank: 40, latitude: 12, longitude: 3 }, + { id: '4', name: 'Delta', status: 'closed', rank: 20, latitude: 13, longitude: 4 }, + { id: '5', name: 'Echo', status: 'open', rank: 50, latitude: 14, longitude: 5 }, +]; + +/** The three rows an authored `status = open` filter declares. */ +const OPEN_LNGS = '1,3,5'; +const OPEN_FILTER = [['status', '=', 'open']]; + +const base: any = { + type: 'map', + map: { latitudeField: 'latitude', longitudeField: 'longitude', titleField: 'name' }, +}; + +function drawn() { + const els = screen.queryAllByTestId('map-marker'); + return { + count: String(els.length), + lngs: els.map((el) => el.getAttribute('data-lng')).join(','), + }; +} + +function makeRows(n: number, status: (i: number) => string = () => 'open') { + return Array.from({ length: n }, (_, i) => ({ + id: String(i + 1), + name: `Place ${i + 1}`, + status: status(i), + latitude: -80 + ((i * 37) % 160), + longitude: -179 + ((i * 53) % 358), + })); +} + +describe('objectui#9061 — the map 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 fetch effect. Any disagreement here is + // the short-circuit and nothing else. + const dataSource = new ValueDataSource({ items: ROWS }) as any; + + const { unmount } = render( + , + ); + await waitFor(() => expect(drawn().count).toBe('3')); + const inline = drawn(); + unmount(); + + render( + , + ); + await waitFor(() => expect(drawn().count).toBe('3')); + const fetching = drawn(); + + expect(inline.lngs).toBe(OPEN_LNGS); + expect(fetching.lngs).toBe(OPEN_LNGS); + // The finding, stated as the two paths agreeing. + expect(inline.lngs).toBe(fetching.lngs); + }); + + it('inlineSort: an authored `sort` orders the inline rows', async () => { + render( + , + ); + await waitFor(() => expect(drawn().count).toBe('5')); + // rank 50,40,30,20,10 → Echo, Charlie, Alpha, Delta, Bravo + expect(drawn().lngs).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. + render( + , + ); + await waitFor(() => expect(drawn().count).toBe('3')); + expect(drawn().lngs).toBe(OPEN_LNGS); + }); + + it('arrayShorthandSpelling: the map-only bare-array `data` rung reaches it too', async () => { + // ⭐ A spelling `ObjectCalendar` does NOT have: this file's `getDataConfig` + // normalizes a bare array under `data` into `{ provider: 'value', items }` + // (objectui#5305) before delegating to the shared ladder. Three author + // spellings reach this repair on the map against two on the calendar, and + // the extra one needs its own row or the normalization could regress + // without a red. + render( + , + ); + await waitFor(() => expect(drawn().count).toBe('3')); + expect(drawn().lngs).toBe(OPEN_LNGS); + }); + + it('ceilingCap: an inline set past the ceiling plots exactly the ceiling', async () => { + render( + , + ); + 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( + , + ); + 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 plots all 800 and + // stays quiet; ceiling-then-filter could not. + const rows = makeRows(2400, (i) => (i % 3 === 0 ? 'open' : 'closed')); + render( + , + ); + await waitFor(() => expect(drawn().count).toBe('800')); + expect(screen.queryByRole('note')).toBeNull(); + }); + + it('control: an inline map 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( + , + ); + await waitFor(() => expect(drawn().count).toBe('5')); + expect(drawn().lngs).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; + + render( + , + ); + await waitFor(() => expect(drawn().count).toBe('3')); + // rank 50,40,30 → Echo, Charlie, Alpha + expect(drawn().lngs).toBe('5,3,1'); + expect(screen.queryByRole('note')).toBeNull(); + }); +}); diff --git a/packages/plugin-map/src/ObjectMap.tsx b/packages/plugin-map/src/ObjectMap.tsx index 2eb19f5d85..42d8b3124d 100644 --- a/packages/plugin-map/src/ObjectMap.tsx +++ b/packages/plugin-map/src/ObjectMap.tsx @@ -38,6 +38,7 @@ import { getRecordDisplayName, resolveRecordSourceConfig, resolveRecordSourceObjectName, + ValueDataSource, } from '@object-ui/core'; import MapGL, { NavigationControl, Marker, Popup } from 'react-map-gl/maplibre'; import type { MapRef } from 'react-map-gl/maplibre'; @@ -555,8 +556,14 @@ export const ObjectMap: React.FC = ({ * Did the platform row ceiling bite, and how large was the whole filtered * result set (objectui#7210)? Carried from the response that knew it — * `data.length === NON_GRID_ROW_CEILING` cannot tell a capped result set - * apart from one that is exactly that size. A host `data` prop and an inline - * `value` set are never truncated by us, so both reset it. + * apart from one that is exactly that size. + * + * ⚠️ The exempt path is the HOST `data` prop and only it — rows a host + * component handed down are not ours to cap, and we issued no query whose + * total a footnote could name. An inline `value` set IS capped + * (objectui#9061, porting objectui#8769): it goes through the same adapter + * query as every other provider, so the ceiling arrives with the same `$top` + * and the same footnote. This docblock used to say both paths were exempt. */ const [rowCeiling, setRowCeiling] = useState<{ truncated: boolean; total?: number }>({ truncated: false, @@ -728,8 +735,61 @@ export const ObjectMap: React.FC = ({ } if (hasInlineData && dataProvider === 'value') { - setData(dataItems as any[]); - setRowCeiling({ truncated: false }); + // THE INLINE PROVIDER NO LONGER EXITS BEFORE THE QUERY + // (objectui#9061, porting objectui#8769's repair off `ObjectGantt`). + // + // This branch used to be `setData(dataItems); return;` — taken + // BEFORE the `find` below, which is the ONE site in this file that + // lowers `schema.filter` onto `$filter`, `schema.sort` onto + // `$orderby` (via `convertSortToQueryParams`) and the objectui#7210 + // ceiling onto `$top`. So an authored `filter` reached nothing and + // every authored row was plotted: the fail-OPEN direction, because + // the key that was dropped is the key that NARROWS. Accepting a + // declared key one cannot honour is the defect, and `ValueDataSource` + // honours all three over its own array, so they are honoured here. + // + // ⚠️ NOT a literal transplant of the gantt's diff, and the difference + // is structural rather than cosmetic. `ObjectGantt` resolves ONE + // `effectiveDataSource` for every provider, so its repair was to + // delete the branch and let the inline case fall through to the + // shared query. This effect's `find` sits INSIDE the + // `dataProvider === 'object'` arm, behind an `$expand` projection an + // inline set has no metadata to build. Falling through here would + // therefore throw `DataSource required for object/api providers` on a + // map that needs no DataSource at all. So the adapter is resolved for + // the inline provider ONLY — `api` keeps exactly the behaviour it had + // — and the same three keys are lowered onto the same query shape. + // + // Built here rather than memoised at render scope so this effect goes + // on reading only the primitive fields objectui#6592 named + // (`dataProvider`, `dataObjectName`, `dataItems`): no dependency is + // added or removed, so nothing about WHEN this effect re-runs changes + // with this repair. + // + // `ValueDataSource` ignores the resource name — it queries its own + // array — so this branch needs none of the object-name ladder the + // `object` arm below resolves. + const inlineSource = new ValueDataSource({ items: (dataItems as any[]) ?? [] }); + const result = await inlineSource.find('', { + $filter: schema.filter, + $orderby: convertSortToQueryParams(schema.sort), + // The same platform ceiling the `object` arm sends, on the same + // probe-row convention (objectui#7210, ruling a′). The ruling's + // budget is measured in DOM elements PER RECORD and its own + // measurement table was taken over the inline `value` provider, so + // an inline marker costs the browser exactly what a fetched one + // costs and the ruling text carves out no provider. + // ⛔ Still not authorable: no view key reaches this `$top`. + $top: NON_GRID_ROW_CEILING_TOP, + }); + // Filter first, ceiling second — `ValueDataSource` applies `$filter` + // before `$top`, which is what the fetching path gets for free from + // every backend. A large inline array that an authored `filter` cuts + // below the ceiling therefore plots every matching row and stays + // quiet. + const capped = applyNonGridRowCeiling(result); + setData(capped.rows); + setRowCeiling({ truncated: capped.truncated, total: capped.total }); setLoading(false); return; } From 7bd112303587d2846f17b027248f94bc73bb190f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 09:51:26 +0000 Subject: [PATCH 2/3] docs(plugin-calendar,plugin-map): state that the provider does not change which query keys apply MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The changeset and both docs sets for objectui#9061: an authored `filter` / `sort` narrows and orders inline rows on every provider, the objectui#7210 ceiling applies to inline rows and is applied to the FILTERED set, and the two consequences of routing inline rows through the in-memory adapter — deep-copied row identity, and the JSON round-trip that makes inline rows have to be serializable. Part of objectui#9061. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ --- .../9061-calendar-map-inline-query-keys.md | 46 +++++++++++++++++++ content/docs/plugins/plugin-calendar.mdx | 10 ++++ content/docs/plugins/plugin-map.mdx | 10 ++++ packages/plugin-calendar/README.md | 18 ++++++++ packages/plugin-map/README.md | 20 +++++++- 5 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 .changeset/9061-calendar-map-inline-query-keys.md diff --git a/.changeset/9061-calendar-map-inline-query-keys.md b/.changeset/9061-calendar-map-inline-query-keys.md new file mode 100644 index 0000000000..05407d8d56 --- /dev/null +++ b/.changeset/9061-calendar-map-inline-query-keys.md @@ -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. diff --git a/content/docs/plugins/plugin-calendar.mdx b/content/docs/plugins/plugin-calendar.mdx index 30e96a5c04..63ad83b8b4 100644 --- a/content/docs/plugins/plugin-calendar.mdx +++ b/content/docs/plugins/plugin-calendar.mdx @@ -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 diff --git a/content/docs/plugins/plugin-map.mdx b/content/docs/plugins/plugin-map.mdx index 61e1ef6576..7579f6bd48 100644 --- a/content/docs/plugins/plugin-map.mdx +++ b/content/docs/plugins/plugin-map.mdx @@ -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 diff --git a/packages/plugin-calendar/README.md b/packages/plugin-calendar/README.md index 0097191725..da33aa6a2f 100644 --- a/packages/plugin-calendar/README.md +++ b/packages/plugin-calendar/README.md @@ -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: diff --git a/packages/plugin-map/README.md b/packages/plugin-map/README.md index 00f02c24f0..8800efc4c9 100644 --- a/packages/plugin-map/README.md +++ b/packages/plugin-map/README.md @@ -84,7 +84,25 @@ const schema: ObjectMapSchema = { `filter` and `sort` are the **query's** filter and order — they reach the data source as `$filter` / `$orderby`, and the spec's per-element `dataSource` binding -is honoured as well. The map issues no row cap of its own. +is honoured as well. + +**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 rows on +**every** provider, inline ones included — `staticData`, a bare array under +`data`, and `data: { provider: 'value', items }` all reach the same in-memory +adapter the other providers go through, so `filter` is evaluated with the same +matcher. Before objectui#9061 the inline provider skipped that query and plotted +every authored row 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 rows 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 +plots every matching row and shows no footnote. + +⚠️ Two consequences of routing inline rows through the adapter. They reach the +map as that adapter's own deep copy rather than as the authored array's object +identities, so code comparing a record handed to `onMarkerClick` against the +authored array with `===` needs `id` equality instead; and the copy is a JSON +round-trip, so inline rows must be JSON-serializable. ## The `map` block From cae685a72f44c00f289ccffa632a421b52635b5d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 13 Sep 2026 04:05:21 +0000 Subject: [PATCH 3/3] test(plugin-calendar,plugin-map): spell each block's inline rows on the arm its published `data` row declares MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The nine reds on this branch were fixture-side, not a production defect, and eight of them were one cause seen at two altitudes. Rung 1 of the record-source ladder is judged against the BLOCK's own published `data` row (objectui#8348, decision batch #83 — "the row decides"): - `object-calendar`.data is `z.array(z.unknown()).optional()` -> arm 'array', so a `{ provider, items }` CONFIG OBJECT is refused by kind. - `object-map`.data is `ViewData` -> arm 'view-data', so a BARE ARRAY is refused by kind, and the objectui#5305 normalizing head is gone. Both new pin files used the other block's spelling. Refused by kind, the ladder fell through `staticData` and `objectName` to null, so the view had no record source at all and hit `DataSource required for object/api providers`. The calendar helper reads that through `getByTestId` ("unable to find calendar-view"); the map helper reads it through `queryAllByTestId`, which counts an error screen as '0' — the same failure, two altitudes. - calendar: the six inline rows now use `staticData`, the one rung that wraps into `{ provider: 'value', items }` on this block. Every assertion is unchanged. - map: `arrayShorthandSpelling` asserted the retired bare-array rung. Replaced by `offArmDataSpelling`, the mirror image of the calendar's new row of the same name; each carries a lit control, so the absence half is a reading about the SPELLING and not about the harness. `ObjectMap.filterConfig.test.tsx` is a second, genuinely different cause: it grades map CONFIG resolution and authored `filter` values purely as a vehicle. Those filters were inert on an inline set before this branch — the fail-OPEN bug #9061 repairs — and now correctly select nothing. - describe (d)'s ordinary `[['owner','=','me']]`: the row now satisfies it, so the case reads BOTH that the filter is honoured and that it did not eat `schema.map`. - describe (a)'s legacy `filter.map` stash is a query filter that no row can satisfy, so its two marker-counting rows take the exempt host `data` prop. This also repairs a phantom green: the `toHaveLength(0)` row had started passing because the set was empty, and would have gone on passing with config resolution completely broken. No production byte is changed by this commit. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ --- ...jectCalendar.inlineQueryKeys-9061.test.tsx | 69 ++++++++++++++----- .../src/ObjectMap.filterConfig.test.tsx | 66 ++++++++++++++---- .../ObjectMap.inlineQueryKeys-9061.test.tsx | 52 +++++++++++--- 3 files changed, 145 insertions(+), 42 deletions(-) diff --git a/packages/plugin-calendar/src/ObjectCalendar.inlineQueryKeys-9061.test.tsx b/packages/plugin-calendar/src/ObjectCalendar.inlineQueryKeys-9061.test.tsx index 94d1d4342e..a70182a958 100644 --- a/packages/plugin-calendar/src/ObjectCalendar.inlineQueryKeys-9061.test.tsx +++ b/packages/plugin-calendar/src/ObjectCalendar.inlineQueryKeys-9061.test.tsx @@ -61,13 +61,31 @@ * per day cell), so the footnote is the whole signal and `ceilingNote` is not * optional. * + * ## HOW THE INLINE ROWS ARE SPELLED HERE, and why it is not the map's spelling + * + * `staticData`, on every row. Rung 1 of the record-source ladder is judged + * against the BLOCK's own published `data` row (objectui#8348, decision batch + * #83 — "the row decides"), and `object-calendar`'s row is + * `z.array(z.unknown()).optional()` — an ARRAY. A `{ provider, items }` config + * object under `data` is therefore refused BY KIND on this block, the ladder + * falls through to a `staticData` and an `objectName` that are not there, and + * the calendar is left with no record source at all. `offArmDataSpelling` pins + * exactly that, with a lit control beside it. + * + * ⚠️ The twin file `ObjectMap.inlineQueryKeys-9061.test.tsx` is the MIRROR + * IMAGE, not a copy: `object-map`'s row is `ViewData`, so there the config + * object is the honoured spelling and the bare array is the refused one. The + * two files use opposite spellings on purpose; copying either one's `data:` + * line into the other is the defect each file's off-arm row exists to catch. + * * 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 + * fix left in place): `twoSidedFilter`, `inlineSort`, `ceilingCap`, + * `ceilingNote` and `ceilingOrder` go RED; `control`, `offArmDataSpelling` 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. + * same order either way, the second never reaches the inline branch, and the + * third never touches the inline path at all, which is what makes them + * controls. */ import React from 'react'; @@ -158,7 +176,7 @@ describe('objectui#9061 — the calendar honours filter / sort / the row ceiling const { unmount } = render( , ); await waitFor(() => expect(drawn().count).toBe('3')); @@ -185,7 +203,7 @@ describe('objectui#9061 — the calendar honours filter / sort / the row ceiling , @@ -195,13 +213,30 @@ describe('objectui#9061 — the calendar honours filter / sort / the row ceiling 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(); - await waitFor(() => expect(drawn().count).toBe('3')); - expect(drawn().ids).toBe(OPEN_IDS); + it('offArmDataSpelling: a `{ provider, items }` config under `data` is NOT an inline source here', async () => { + // ⭐ The rung ruling, pinned on the block it actually bites (objectui#8348, + // decision batch #83 — "the row decides"). `object-calendar`'s published + // `data` row is `z.array(z.unknown()).optional()`, so `resolveRecordSourceConfig` + // judges rung 1 on the `'array'` arm and refuses a CONFIG OBJECT by kind. + // With no `staticData` and no `objectName` left to fall to, the ladder + // returns null and the calendar has no record source at all — which is why + // every inline row above is spelled `staticData`, the one rung that does + // wrap into `{ provider: 'value', items }` on this block. + // + // ⛔ This is the row that must stay red if anyone "fixes" the reds above by + // teaching the `'array'` arm to also accept the config object: that is the + // AGENTS.md #0.1 tolerant-fallback defect, and it would re-open the drift + // objectui#8348 closed. + render(); + await waitFor(() => expect(screen.queryByText(/Loading calendar/)).toBeNull()); + expect(screen.queryByTestId('calendar-view')).toBeNull(); + + // The LIT CONTROL, so the line above is a reading about the SPELLING and + // not about these rows, this stub or this harness: the same rows, same + // component, on the arm the row does declare, draw all five. + cleanup(); + render(); + await waitFor(() => expect(drawn().count).toBe('5')); }); it('ceilingCap: an inline set past the ceiling draws exactly the ceiling', async () => { @@ -209,7 +244,7 @@ describe('objectui#9061 — the calendar honours filter / sort / the row ceiling , ); @@ -219,7 +254,7 @@ describe('objectui#9061 — the calendar honours filter / sort / the row ceiling it('ceilingNote: the cut is LOUD — the footnote names both numbers', async () => { const total = NON_GRID_ROW_CEILING_TOP + 500; render( - , + , ); await waitFor(() => expect(drawn().count).toBe(String(NON_GRID_ROW_CEILING))); @@ -236,7 +271,7 @@ describe('objectui#9061 — the calendar honours filter / sort / the row ceiling const rows = makeRows(2400, (i) => (i % 3 === 0 ? 'open' : 'closed')); render( , ); await waitFor(() => expect(drawn().count).toBe('800')); @@ -246,7 +281,7 @@ describe('objectui#9061 — the calendar honours filter / sort / the row ceiling 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(); + render(); await waitFor(() => expect(drawn().count).toBe('5')); expect(drawn().ids).toBe('1,2,3,4,5'); expect(screen.queryByRole('note')).toBeNull(); diff --git a/packages/plugin-map/src/ObjectMap.filterConfig.test.tsx b/packages/plugin-map/src/ObjectMap.filterConfig.test.tsx index 617f0e5036..b406132d3d 100644 --- a/packages/plugin-map/src/ObjectMap.filterConfig.test.tsx +++ b/packages/plugin-map/src/ObjectMap.filterConfig.test.tsx @@ -51,8 +51,20 @@ vi.mock('react-map-gl/maplibre', () => ({ Popup: ({ children }: any) =>
{children}
, })); -/** Rows whose coordinates live under NON-default field names. */ -const ROWS = [{ id: '1', name: 'HQ', lat: 40, lng: -74 }]; +/** + * Rows whose coordinates live under NON-default field names. + * + * ⭐ `owner: 'me'` is LOAD-BEARING since objectui#9061, and only since then. + * Describe (d) authors `filter: [['owner', '=', 'me']]` beside the map config; + * that filter used to be inert on an inline `value` set (the very fail-OPEN bug + * #9061 repairs), so the row survived it by accident. Now the inline path + * lowers `schema.filter` onto `$filter` exactly as the fetching path does, so a + * row that does not satisfy the authored filter is correctly dropped and the + * config assertion below would be measuring an empty set instead of the config. + * Satisfying the filter — rather than removing it — keeps BOTH readings: the + * filter is honoured AND it did not eat `schema.map`. + */ +const ROWS = [{ id: '1', name: 'HQ', lat: 40, lng: -74, owner: 'me' }]; /** The same place, spelled the way the DEFAULT config expects. */ const ROWS_DEFAULT_SPELLING = [{ id: '1', name: 'HQ', latitude: 40, longitude: -74 }]; @@ -69,8 +81,24 @@ afterEach(() => { warnSpy.mockRestore(); }); -const renderMap = async (schema: Record) => { - const utils = render(); +/** + * `hostRows`, when given, hands the records down the `data` PROP — the path a + * host component (`ListView`) uses, which bypasses this component's own query + * and is therefore exempt from `filter` / `sort` / the row ceiling by design. + * + * ⭐ Why describe (a) needs it, post-objectui#9061: the legacy shape under test + * there is authored under `filter`, and `filter` is the QUERY FILTER and nothing + * else (objectui#4034) — so `{ map: DECLARED_MAP }` is now read as "the field + * named `map` equals that object", which no row satisfies, and the inline set + * comes back EMPTY. That is correct behaviour and the same thing the fetching + * path has always sent on the wire; but it makes a marker count unable to say + * anything about CONFIG resolution, which is the only thing this file grades. + * Handing those rows down the exempt prop puts the config back as the single + * variable. `filter` still reaches the query verbatim — describe (c) grades + * that separately, against a mock adapter. + */ +const renderMap = async (schema: Record, hostRows?: any[]) => { + const utils = render(); await waitFor(() => expect(screen.queryByText('Loading map...')).toBeNull()); return utils; }; @@ -82,23 +110,33 @@ const warnings = () => warnSpy.mock.calls.map((c: unknown[]) => String(c[0])).jo // --------------------------------------------------------------------------- describe('legacy `filter.map` is not map configuration (objectui#4034)', () => { it('ignores a MapConfig stashed under `filter.map` and falls to the default config', async () => { - await renderMap({ - type: 'object-map', - data: { provider: 'value', items: ROWS }, - filter: { map: DECLARED_MAP }, - }); + await renderMap( + { + type: 'object-map', + filter: { map: DECLARED_MAP }, + }, + ROWS, + ); // The stash named `lat`/`lng`; it is not read, so the default config // (`latitude`/`longitude`) applies and finds no coordinates on these rows. + // + // ⚠️ The rows come down the exempt host prop deliberately. Read as an + // inline `value` set this row would still assert 0 — but for the WRONG + // reason (the stash-as-query-filter selecting nothing), and it would go on + // passing with config resolution completely broken. Its whole job is to be + // the 0 half of a 0/1 pair with the row below. expect(screen.queryAllByTestId('map-marker')).toHaveLength(0); }); it('really is the DEFAULT config that applies, not "no config at all"', async () => { - await renderMap({ - type: 'object-map', - data: { provider: 'value', items: ROWS_DEFAULT_SPELLING }, - filter: { map: { latitudeField: 'lat', longitudeField: 'lng' } }, - }); + await renderMap( + { + type: 'object-map', + filter: { map: { latitudeField: 'lat', longitudeField: 'lng' } }, + }, + ROWS_DEFAULT_SPELLING, + ); // Same legacy stash, rows spelled the default way: the default config is // live and places the marker. (Pre-fix this rendered nothing — the stash diff --git a/packages/plugin-map/src/ObjectMap.inlineQueryKeys-9061.test.tsx b/packages/plugin-map/src/ObjectMap.inlineQueryKeys-9061.test.tsx index b62ecc3f82..adb185c90d 100644 --- a/packages/plugin-map/src/ObjectMap.inlineQueryKeys-9061.test.tsx +++ b/packages/plugin-map/src/ObjectMap.inlineQueryKeys-9061.test.tsx @@ -67,10 +67,20 @@ * REVERSE VERIFICATION — direction predicted BEFORE running, from the committed * fix, by restoring the short-circuit in `ObjectMap.tsx` ONLY (the calendar's * fix left in place): `twoSidedFilter`, `inlineSort`, `staticDataSpelling`, - * `arrayShorthandSpelling`, `ceilingCap`, `ceilingNote` and `ceilingOrder` go - * RED; `control` and `providerBackedControl` stay GREEN — the first plots the - * same rows in the same order either way, the second never touches the inline - * path at all, which is what makes them controls. + * `ceilingCap`, `ceilingNote` and `ceilingOrder` go RED; `control`, + * `offArmDataSpelling` and `providerBackedControl` stay GREEN — the first plots + * the same rows in the same order either way, the second never reaches the + * inline branch, and the third never touches the inline path at all, which is + * what makes them controls. + * + * ## HOW THE INLINE ROWS ARE SPELLED HERE (objectui#8348, decision batch #83) + * + * `{ provider: 'value', items }` under `data`, or `staticData` — the two rungs + * `object-map`'s published `ViewData` row admits. A BARE ARRAY under `data` is + * refused by kind on this block and reaches no inline source at all; + * `offArmDataSpelling` pins that, with a lit control beside it. The twin file + * `ObjectCalendar.inlineQueryKeys-9061.test.tsx` is the MIRROR IMAGE — that + * block's row is an array, so there the spellings are swapped. */ import React from 'react'; @@ -195,16 +205,36 @@ describe('objectui#9061 — the map honours filter / sort / the row ceiling on i expect(drawn().lngs).toBe(OPEN_LNGS); }); - it('arrayShorthandSpelling: the map-only bare-array `data` rung reaches it too', async () => { - // ⭐ A spelling `ObjectCalendar` does NOT have: this file's `getDataConfig` - // normalizes a bare array under `data` into `{ provider: 'value', items }` - // (objectui#5305) before delegating to the shared ladder. Three author - // spellings reach this repair on the map against two on the calendar, and - // the extra one needs its own row or the normalization could regress - // without a red. + it('offArmDataSpelling: a bare ARRAY under `data` is NOT an inline source here', async () => { + // ⭐ The rung ruling, pinned on the block it actually bites (objectui#8348, + // decision batch #83 — "the row decides"). This row used to assert the + // opposite: that `getDataConfig` normalizes a bare array under `data` into + // `{ provider: 'value', items }` (objectui#5305). That normalizing head is + // GONE — `getDataConfig` now delegates to `resolveRecordSourceConfig(schema, + // 'view-data')`, and `object-map`'s published `data` row is `ViewData`, so a + // bare array is refused BY KIND. With no `staticData` and no `objectName` + // to fall to, the ladder returns null and the map has no record source. + // + // ⛔ The mirror image of `ObjectCalendar.inlineQueryKeys-9061`'s row of the + // same name, and deliberately so: there the ARRAY is the honoured spelling + // and the config object is refused. Copying either file's `data:` line into + // the other is the defect these two rows exist to catch. render( , ); + await waitFor(() => expect(screen.queryByText(/Loading map/)).toBeNull()); + expect(drawn().count).toBe('0'); + + // The LIT CONTROL, so the line above is a reading about the SPELLING and + // not about these rows, this stub or this harness: the same rows, same + // filter, same component, on the arm the row does declare, plot the three. + cleanup(); + render( + , + ); await waitFor(() => expect(drawn().count).toBe('3')); expect(drawn().lngs).toBe(OPEN_LNGS); });