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
62 changes: 62 additions & 0 deletions .changeset/9853-grid-display-page-size-from-spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
---
'@object-ui/plugin-grid': minor
'@object-ui/plugin-list': minor
'@object-ui/plugin-kanban': minor
---

A page size with nothing declared is now the one `@objectstack/spec` declares for
`pagination.pageSize`, on every surface that has a pager; a fetch that has no
pager keeps its own fetch batch, which does not follow it (objectui#9853).

"Page size" now means one page, and only where there is a pager. The grid,
the list and the board read the display default from the spec (`PaginationConfigSchema`)
instead of keeping numbers of their own, so it is declared once, in the protocol.
`@objectstack/spec` 17.5.0 declares **50**. The views that fetch one window and
page nothing keep a fetch batch of their own, with the value they had, so no
view loses records it could reach before.

⚠️ **Visible change where no page size is declared.** Measured on the renderer
with `@objectstack/spec` 17.5.0. Declare `pagination: { pageSize: N }` (or, on a
board, `limit`) to keep an old count:

| surface (nothing declared) | before | after |
|:--|--:|--:|
| `object-grid` that fetches its own rows: rows per page, and the `$top` of each page fetch | 50 | 50 (unchanged) |
| `object-grid` over inline `data`: rows per page | 10 | **50** |
| grouped `object-grid`: groups per page | 10 | **50** |
| grouped `object-grid` over a data source that groups on the server: rows per group page (`$top`) | 50 | 50 (unchanged) |
| `list-view`, grid view (paged on the server): rows per page, and `$top` | 100 | **50** |
| `list-view`, every view it does not page (kanban, calendar, gallery, timeline and the rest, and a grouped grid): the one fetch's `$top` | 100 | 100 (unchanged) |
| `object-kanban` with no `limit`: the board's `$top` | 100 | 100 (unchanged) |

What changed underneath:

- **`@object-ui/plugin-grid`.** `ObjectGrid` falls back to the spec's display
default on every page it shows, with no local number behind it: if a future
spec stops declaring the default, the module throws at load instead of
inventing one. The old "server window" constant is renamed
`DEFAULT_FETCH_BATCH_SIZE` and keeps its value. Since grouping moved to the
server, it is read only when the grid groups a window of rows in the browser
because the server does not group it (a grouping key the principal may not
read), and never as a page size. The grouped view's rows-per-page selector
also lists the size in force when it is not one of its fixed steps.
- **`@object-ui/plugin-list`.** `ListView` splits its undeclared fallback by
whether the view pages. The grid view, which it pages on the server, falls
back to the spec's display default. Every other view falls back to a fetch
batch of 100, the value it had. A declared `pagination.pageSize` sizes the
window on every view, as before. With no declared size, switching between the
grid view and an unpaged view now changes the window, so the list fetches
again: one request, whose `$top` is the fetch batch. That qualifies the
objectui#10512 note in this same release, which says a switch into a dataset
chart with no user filter set issues no new request: it still holds when a
page size is declared. A refused page size still warns, and the warning names
the fallback that view actually used.
- **`@object-ui/plugin-kanban`.** The board's default `limit` is renamed from
`DEFAULT_KANBAN_LIMIT` to `DEFAULT_KANBAN_FETCH_BATCH_SIZE` and stays 100. Its
diagnostic for a refused `limit` now calls it the board's fetch batch.

The display default follows the `@objectstack/spec` a host installs. A host
that resolves an older spec gets that spec's default, on every surface alike.

Released as `minor`, not `major`: this repository keeps its major aligned with
`@objectstack/spec`, so the behaviour change is spelled out here instead.
9 changes: 9 additions & 0 deletions content/docs/plugins/plugin-grid.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,15 @@ see [Row callbacks are component props](#row-callbacks-are-component-props).
always carries a rows-per-page picker, and `pageSizeOptions` only replaces the
choices it offers with your own.

With no `pageSize` declared, every page the grid shows — the server-paged table,
the table over inline `data`, the grouped view's page of groups, and a group's
own page of rows — uses the default `@objectstack/spec` declares for
`pagination.pageSize`; the grid reads it from the spec rather than keeping a
number of its own. Declare `pagination.pageSize` to choose the count yourself.
A window of rows the grid groups in the browser, because the server does not
group it, is not a page: undeclared, it is a fixed fetch batch of the grid's
own, and it does not follow the display default.

### Grouping is server-side

`grouping` is answered by the **server**, not by bucketing the records the
Expand Down
6 changes: 4 additions & 2 deletions content/docs/plugins/plugin-kanban.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -208,11 +208,13 @@ rendered on each card resolve in priority order:
Defaulting to `highlightFields` keeps a card's contents consistent with the
object's other views without every kanban view having to re-declare its fields.

### Row cap (object-driven boards)
### Fetch batch (object-driven boards)

An object-driven board fetches at most `limit` records, defaulting to **100**. A
board renders every fetched record into a lane and offers no pagination control,
so this is the author's window on the object rather than a page size:
so this is the author's window on the object — one fetch batch — rather than a
page size. The default does not follow the display page size `@objectstack/spec`
declares for `pagination.pageSize`:

```tsx
import type { ObjectKanbanSchema } from '@object-ui/types'
Expand Down
258 changes: 258 additions & 0 deletions packages/app-shell/src/__tests__/displayPageSizeFromSpec-9853.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
/**
* 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#9853, ruling 5824040487 — the display default is declared ONCE, in
* the protocol, and every paged surface reads it; the fetch batches of the
* unpaged surfaces are a different quantity and do not follow it (structure B:
* "page size" means one page only where there is a pager).
*
* ## Why this file lives in `app-shell`
*
* The surfaces sit in three packages — `ObjectGrid` in `plugin-grid`,
* `ListView` in `plugin-list`, `ObjectKanban` in `plugin-kanban` — and the
* point of the pin is ONE assertion across all of them, so that a surface
* keeping a default of its own shows up as the odd one out. `app-shell` is the
* package that already depends on all three, so the real components are
* mounted here with no new dependency edge anywhere (the same reason
* `rowClickModifierPayloadForward-9462.test.tsx` lives here).
*
* ## Why the spec's default is STOOD IN FOR
*
* Reading the real default cannot tell "reads the spec" from "hardcodes the
* number the spec says today". So the default is replaced with a number the
* spec has never shipped, and every expectation reads it back from the spec
* module the renderers import. The stand-in records that its factory ran and
* what the REAL default is, so the first row proves the substitution is live.
* With the stand-in in force the paged surfaces must move with it and the
* fetch batches must not — which is exactly the split the ruling makes.
*/
import React from 'react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, cleanup, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';

const { STUB_DISPLAY_DEFAULT, probe } = vi.hoisted(() => ({
/** Deliberately nothing the spec has shipped, and neither fetch batch. */
STUB_DISPLAY_DEFAULT: 13,
probe: { factoryRan: false, realDefault: undefined as unknown },
}));

vi.mock('@objectstack/spec/ui', async (importOriginal) => {
const actual = await importOriginal<typeof import('@objectstack/spec/ui')>();
probe.factoryRan = true;
probe.realDefault = actual.PaginationConfigSchema.parse({}).pageSize;
return {
...actual,
PaginationConfigSchema: actual.PaginationConfigSchema.extend({
pageSize: actual.PaginationConfigSchema.shape.pageSize.unwrap().default(STUB_DISPLAY_DEFAULT),
}),
};
});

import { PaginationConfigSchema } from '@objectstack/spec/ui';
import { ObjectGrid } from '@object-ui/plugin-grid';
import { ListView } from '@object-ui/plugin-list';
import { ObjectKanban } from '@object-ui/plugin-kanban';
import { ActionProvider, SchemaRendererProvider } from '@object-ui/react';

/** The protocol's answer, read the way the renderers read it. */
const SPEC_DISPLAY_DEFAULT: number = PaginationConfigSchema.parse({}).pageSize;

/**
* The fetch batch of every unpaged list view and of the board. Spelled by
* value: the ruling keeps the value, and what it asks is that neither moves
* with the display default.
*/
const FETCH_BATCH = 100;

const OBJECT = 'duly_task';
const OBJECT_FIELDS = {
id: { type: 'text', label: 'Id' },
subject: { type: 'text', label: 'Subject' },
unit: { type: 'text', label: 'Unit' },
status: { type: 'select', label: 'Status', options: [{ label: 'Todo', value: 'todo' }] },
};

/** More rows than any number here, every row its own group. */
const ROW_COUNT = 240;
interface Row { id: string; subject: string; unit: string; status: string }
const ROWS: Row[] = Array.from({ length: ROW_COUNT }, (_, i) => ({
id: `r-${i}`,
subject: `Task ${String(i).padStart(3, '0')}`,
unit: `Unit ${String(i).padStart(3, '0')}`,
status: 'todo',
}));

/** The `FilterCondition` subset the compiled group queries use. */
function matches(row: Record<string, unknown>, cond: unknown): boolean {
if (cond === undefined || cond === null) return true;
return Object.entries(cond as Record<string, unknown>).every(([key, value]) => {
if (key === '$and') return (value as unknown[]).every((c) => matches(row, c));
if (value && typeof value === 'object' && !Array.isArray(value)) {
const op = value as Record<string, unknown>;
if ('$eq' in op) return row[key] === op.$eq;
if ('$null' in op) return op.$null ? row[key] == null : row[key] != null;
throw new Error(`double: unsupported operator ${JSON.stringify(op)}`);
}
return row[key] === value;
});
}

/** Pages `find` by `$top` / `$skip` and answers the group header query. */
const makeDataSource = () => {
const calls: Array<Record<string, unknown>> = [];
return {
calls,
queryGroupHeaders: vi.fn(async (_object: string, query: { where?: unknown; groupBy?: string[]; aggregations?: Array<{ function: string; alias: string }> }) => {
const groupBy = query.groupBy ?? [];
const buckets = new Map<string, Row[]>();
for (const row of ROWS.filter((r) => matches(r as any, query.where))) {
const key = JSON.stringify(groupBy.map((f) => (row as any)[f] ?? null));
if (!buckets.has(key)) buckets.set(key, []);
buckets.get(key)!.push(row);
}
return [...buckets.entries()].map(([key, members]) => {
const values = JSON.parse(key) as unknown[];
const out: Record<string, unknown> = Object.fromEntries(groupBy.map((f, i) => [f, values[i]]));
for (const agg of query.aggregations ?? []) {
out[agg.alias] = agg.function === 'count' ? members.length : null;
}
return out as { count: number };
});
}),
find: vi.fn(async (_object: string, params: Record<string, unknown>) => {
calls.push(params);
const where = Array.isArray(params.$filter) ? undefined : params.$filter;
const matching = ROWS.filter((r) => matches(r as any, where));
const skip = (params.$skip as number | undefined) ?? 0;
const top = (params.$top as number | undefined) ?? matching.length;
return { data: matching.slice(skip, skip + top), total: matching.length };
}),
findOne: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
getObjectSchema: vi.fn(async () => ({ name: OBJECT, fields: OBJECT_FIELDS })),
};
};
type Ds = ReturnType<typeof makeDataSource>;

const mount = (node: React.ReactElement, ds?: Ds) =>
render(
<SchemaRendererProvider dataSource={ds as any}>
<ActionProvider>{node}</ActionProvider>
</SchemaRendererProvider>,
);

const bodyRows = (container: HTMLElement) => Array.from(container.querySelectorAll('tbody tr'));
const groupRows = () => [...document.querySelectorAll('[data-testid^="group-row-"]')];
const tops = (ds: Ds) => ds.calls.map((p) => p.$top);
/** The `$top` of every per-group row query (a row query carries a group `$filter`). */
const groupRowTops = (ds: Ds) =>
[...new Set(ds.calls.filter((p) => p.$filter && !Array.isArray(p.$filter)).map((p) => p.$top))];

afterEach(() => cleanup());

describe('one display default from the spec across grid, list and board; fetch batches apart (objectui#9853)', () => {
it('the spec stand-in is live: the renderers\' spec module answers it, and it is not the real default', () => {
expect(probe.factoryRan).toBe(true);
expect(SPEC_DISPLAY_DEFAULT).toBe(STUB_DISPLAY_DEFAULT);
expect(probe.realDefault).not.toBe(STUB_DISPLAY_DEFAULT);
expect(FETCH_BATCH).not.toBe(SPEC_DISPLAY_DEFAULT);
});

it('every paged surface shows the spec default when nothing is declared', async () => {
const gridSchema = { type: 'object-grid', objectName: OBJECT, columns: ['subject'] };

// The grid that fetches its own rows: the page on screen and its `$top`.
const flatDs = makeDataSource();
const flat = mount(<ObjectGrid schema={gridSchema as any} dataSource={flatDs as any} />, flatDs);
await waitFor(() => expect(bodyRows(flat.container).length).toBeGreaterThan(0));
const gridServerPaged = bodyRows(flat.container).length;
const gridServerTop = tops(flatDs).at(-1);
cleanup();

// The grid over inline rows.
const inline = mount(
<ObjectGrid schema={{ ...gridSchema, data: { provider: 'value', items: ROWS } } as any} />,
);
await waitFor(() => expect(bodyRows(inline.container).length).toBeGreaterThan(0));
const gridInline = bodyRows(inline.container).length;
cleanup();

// The server-grouped grid: a page of groups, and each open group's page.
const groupedDs = makeDataSource();
mount(
<ObjectGrid
schema={{ ...gridSchema, grouping: { fields: [{ field: 'unit' }] } } as any}
dataSource={groupedDs as any}
/>,
groupedDs,
);
await waitFor(() => expect(groupRows().length).toBeGreaterThan(0));
await waitFor(() => expect(groupRowTops(groupedDs).length).toBeGreaterThan(0));
const groupsShown = groupRows().length;
const groupRowTop = groupRowTops(groupedDs);
cleanup();

// The paged list: `ListView` pages its grid view on the server and hands
// the real grid the window and the pager.
const listDs = makeDataSource();
const list = mount(
<ListView
schema={{ type: 'list-view', objectName: OBJECT, viewType: 'grid', columns: ['subject'] } as any}
dataSource={listDs as any}
/>,
listDs,
);
await waitFor(() => expect(bodyRows(list.container).length).toBeGreaterThan(0));
const listPaged = bodyRows(list.container).length;
const listTop = tops(listDs);

expect({ gridServerPaged, gridServerTop, gridInline, groupsShown, groupRowTop, listPaged, listTop }).toEqual({
gridServerPaged: SPEC_DISPLAY_DEFAULT,
gridServerTop: SPEC_DISPLAY_DEFAULT,
gridInline: SPEC_DISPLAY_DEFAULT,
groupsShown: SPEC_DISPLAY_DEFAULT,
groupRowTop: [SPEC_DISPLAY_DEFAULT],
listPaged: SPEC_DISPLAY_DEFAULT,
listTop: [SPEC_DISPLAY_DEFAULT],
});
});

it('the unpaged list and the board keep their fetch batch, which does not move with the page size', async () => {
// An unpaged list view: no pager, so its one window is the batch.
const listDs = makeDataSource();
mount(
<ListView
schema={{ type: 'list-view', objectName: OBJECT, viewType: 'kanban', columns: ['subject'], kanban: { groupByField: 'status' } } as any}
dataSource={listDs as any}
/>,
listDs,
);
await waitFor(() => expect(listDs.find).toHaveBeenCalled());
const listUnpagedTop = tops(listDs);
cleanup();

// The board on its own: `limit` undeclared, its default fetch batch.
const boardDs = makeDataSource();
mount(
<ObjectKanban
schema={{ type: 'object-kanban', objectName: OBJECT, groupBy: 'status' } as any}
dataSource={boardDs as any}
/>,
boardDs,
);
await waitFor(() => expect(boardDs.find).toHaveBeenCalled());
const boardTop = tops(boardDs);

expect({ listUnpagedTop, boardTop }).toEqual({ listUnpagedTop: [FETCH_BATCH], boardTop: [FETCH_BATCH] });
expect([...listUnpagedTop, ...boardTop]).not.toContain(SPEC_DISPLAY_DEFAULT);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ describe('object-kanban inspector · the declared controls are on screen (object
expect(screen.getByLabelText('Limit')).toBeTruthy();
});

it('the row cap states DEFAULT_KANBAN_LIMIT while it is empty, and commits a number', () => {
it('the fetch batch box states DEFAULT_KANBAN_FETCH_BATCH_SIZE while it is empty, and commits a number', () => {
const onPatch = renderInspector(pageDraft({ objectName: 'opportunity' }));
const box = screen.getByLabelText('Limit') as HTMLInputElement;
// Empty box, and the placeholder says what applies anyway.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -389,7 +389,7 @@ describe('placeholders that must NOT be translated (#3979)', () => {
'record:related_list.limit': { literal: '10', because: 'a row limit' },
'object-kanban.limit': {
literal: '100',
because: "a row limit — DEFAULT_KANBAN_LIMIT, the board's fetch cap when the box is empty",
because: "a row limit — DEFAULT_KANBAN_FETCH_BATCH_SIZE, the board's fetch batch when the box is empty",
},
'record:details.sections.columns': { literal: '2', because: 'a column count' },
};
Expand All @@ -403,7 +403,7 @@ describe('placeholders that must NOT be translated (#3979)', () => {
// PR objectstack#7115 retired `PageHeaderProps.icon`. Then 17 / 7 / 10
// until objectui#7772 gave `object-kanban` the `limit` control its schema
// has declared since objectui#7322 — a VALUE placeholder (`100`,
// `DEFAULT_KANBAN_LIMIT`), so it lands on the literal side and the keyed
// `DEFAULT_KANBAN_FETCH_BATCH_SIZE`), so it lands on the literal side and the keyed
// half is what stays untouched this time. Then 18 / 7 / 11 until
// objectui#8280 removed the `ai:input` panel, whose `agentName` box was a
// KEYED placeholder — the palette never offered that block.
Expand Down
Loading
Loading