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
17 changes: 17 additions & 0 deletions .changeset/object-kanban-limit-row-cap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
"@objectstack/spec": minor
---

feat(spec): `ComponentPropsMap['object-kanban']` declares `limit`, the row cap four objectui faces already implement (#16503, the spec half of objectui#8172)

`object-kanban` gains one optional authorable key:

```ts
limit: z.number().int().positive().optional()
```

Maximum number of records loaded onto the board (row cap), lowered to the top-level `$top` of the board's one query. The renderer default stays 100 and is documented rather than declared, so an unset key remains unset. The component-level `dataSource.limit` wins when both are set, and a bound named view's `pagination.pageSize` fills the key only when the component authored none — the `ElementDataSourceGate` precedence table, unchanged.

Measured at the objectui pin this repo builds against (`.objectui-sha` = `a472b0716`): `plugin-kanban` reads `schema.limit` as the query's `$top` (wired by objectui#4025), `OBJECT_KANBAN_DATA_SOURCE` maps `limit: 'limit'`, `KanbanSchema` declares `limit?: number`, and `content/docs/plugins/plugin-kanban.mdx` teaches it with a typed snippet (`limit: 250`) plus a Properties row. The strict props map refused the key by name, so an author following the published docs wrote a node the save gate rejected with the same `unrecognized_keys` verdict a typo gets. Decision batch #68 (2026-09-07, option A): the contract declares the capability that is already implemented, documented and in use.

Widening a published accept set (Clause-② yes): `safeParse({ objectName: 'x', limit: 250 })` now succeeds; every other undeclared key on the node is refused exactly as before. objectui#8172 publishes the key in the registry declaration on its side.
1 change: 1 addition & 0 deletions content/docs/references/ui/component.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,7 @@ Sort field and direction pair
| **groupBy** | `string` | optional | Field whose values become the board columns |
| **columns** | `any[]` | optional | Swimlane definitions (`{ id, title }` per `groupBy` value, or bare value strings) — NOT a field projection |
| **filter** | `any` | optional | Base query filter, handed to the wire `$filter` |
| **limit** | `integer` | optional | Maximum number of records loaded onto the board (row cap); lowered to the query's top-level `$top` (renderer default 100). The component-level `dataSource.limit` wins when both are set; a bound view's `pagination.pageSize` fills it only when unset |
| **data** | `any[]` | optional | Static inline cards — bypasses the object query |
| **cardTitle** | `string` | optional | Field rendered as each card title |
| **titleField** | `string` | optional | Legacy fallback for `cardTitle` (the board reads `cardTitle \|\| titleField`). Prefer `cardTitle` |
Expand Down
1 change: 1 addition & 0 deletions packages/spec/authorable-surface/ui.json
Original file line number Diff line number Diff line change
Expand Up @@ -778,6 +778,7 @@
"ui/ObjectKanbanProps:filter",
"ui/ObjectKanbanProps:groupBy",
"ui/ObjectKanbanProps:grouping",
"ui/ObjectKanbanProps:limit",
"ui/ObjectKanbanProps:objectName",
"ui/ObjectKanbanProps:quickAdd",
"ui/ObjectKanbanProps:swimlaneField",
Expand Down
63 changes: 63 additions & 0 deletions packages/spec/src/ui/component.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
ElementRecordPickerPropsSchema,
ElementTextInputPropsSchema,
ObjectMetricPropsSchema,
ObjectKanbanPropsSchema,
} from './component.zod';
import { PageComponentSchema, PageSchema, PageComponentType, ElementDataSourceSchema } from './page.zod';

Expand Down Expand Up @@ -2772,6 +2773,68 @@ describe('#7751 — object-* block props schemas', () => {
});
});

// #16503 — the spec half of objectui#8172 (decision batch #68, 2026-09-07,
// option A: the contract declares the capability that already ships, is
// documented and is in use). Measured at the objectui pin this repo builds
// against (`.objectui-sha` = `a472b0716`): `plugin-kanban/src/ObjectKanban.tsx:264`
// queries `$top: schema.limit ?? DEFAULT_KANBAN_LIMIT` (100, `:71`),
// `plugin-kanban/src/index.tsx:395-398` maps `limit: 'limit'` in
// `OBJECT_KANBAN_DATA_SOURCE`, `plugin-kanban/src/types.ts:134` declares
// `KanbanSchema.limit?: number`, and `content/docs/plugins/plugin-kanban.mdx`
// teaches `limit: 250` with a Properties row. The strict map refused the key by
// name — the same `unrecognized_keys` verdict as the `bogusProp` control — so an
// author following the published docs wrote a node the save gate rejected.
describe('ObjectKanbanPropsSchema limit — the row cap four objectui faces already implement (#16503)', () => {
const kanban = ComponentPropsMap['object-kanban'];

it("accepts the documented shape `{ objectName: 'x', limit: 250 }` and carries the value through", () => {
const result = kanban.safeParse({ objectName: 'x', limit: 250 });
expect(result.success).toBe(true);
const parsed = (result.success ? result.data : undefined) as { limit?: number } | undefined;
// Carried through to the parsed output, not stripped: what the board
// lowers to `$top` is what the author wrote.
expect(parsed?.limit).toBe(250);
});

it('still refuses an undeclared sibling on the same node — the accept above is not vacuous', () => {
// The card's own control, and the half that proves the object stayed
// strict: without it the green above would also be green on a map that
// had stopped refusing anything.
const result = kanban.safeParse({ objectName: 'x', bogusProp: 250 });
expect(result.success).toBe(false);
const issue = result.error?.issues.find((i) => i.code === 'unrecognized_keys') as
| { keys?: string[] }
| undefined;
expect(issue?.keys).toEqual(['bogusProp']);
});

it('refuses a cap the query could not lower to `$top` — zero, negative, fractional, or a string — at the VALUE, not the key', () => {
// `z.number().int().positive()`: the shape `element:record_picker` and
// `record:related_list` declare for the same `$top` read, so the flat row
// caps in this map are one contract rather than three dialects. The key is
// recognised (no `unrecognized_keys`); the value is what fails.
for (const limit of [0, -1, 1.5, '250']) {
const result = kanban.safeParse({ objectName: 'x', limit });
expect(result.success, JSON.stringify(limit)).toBe(false);
const codes = (result.error?.issues ?? []).map((i) => i.code);
expect(codes, JSON.stringify(limit)).not.toContain('unrecognized_keys');
expect(result.error?.issues[0]?.path, JSON.stringify(limit)).toEqual(['limit']);
}
});

it('keeps a `.describe()` that names the `$top` the board lowers it to and the binding that outranks it', () => {
// The describe is the artifact an auditor reads instead of hunting across
// repos, and the row the generated reference page prints; deleting it is
// what re-opens the "is this key live?" question this record answers.
const shape = (ObjectKanbanPropsSchema as unknown as {
def: { shape: Record<string, { description?: string }> };
}).def.shape;
expect(shape.limit?.description).toContain('$top');
expect(shape.limit?.description).toContain('row cap');
expect(shape.limit?.description).toContain('dataSource.limit');
});
});

// #10053 — the accept-pins for the last two `icon` slots in this file whose
// describes stated only the VOCABULARY. "Icon name (Lucide)" is equally true of
// the `page:header` `icon` retired in #6946 *because nothing reads it*, so the
Expand Down
37 changes: 37 additions & 0 deletions packages/spec/src/ui/component.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2644,6 +2644,9 @@ export type ObjectMetricProps = z.input<typeof ObjectMetricPropsSchema>;
* forwarded schema `quickAdd`/`coverImageField`/`conditionalFormatting`
* (`KanbanRenderer`, index.tsx). `groupField` is the DESIGNER's spelling with
* zero read points (#7973 class) — aliased to the `groupBy` the board reads.
* `limit` (#16503) was measured later, at the pin this repo builds against
* (`.objectui-sha` = `a472b0716`): `ObjectKanban.tsx:264`, the `$top` of the
* board's one query — its docblock below carries the four-face record.
*/
export const ObjectKanbanPropsSchema = lazySchema(() => strictObject({
surface: 'this `object-kanban`',
Expand All @@ -2662,6 +2665,40 @@ export const ObjectKanbanPropsSchema = lazySchema(() => strictObject({
columns: z.array(z.unknown()).optional()
.describe('Swimlane definitions ({ id, title } per `groupBy` value, or bare value strings) — NOT a field projection'),
filter: z.unknown().optional().describe('Base query filter, handed to the wire `$filter`'),
/**
* Row cap (#16503 — the spec half of objectui#8172; decision batch #68,
* 2026-09-07, option A: the contract declares the capability that already
* ships, is documented and is in use). Measured at the objectui pin this
* repo builds against (`.objectui-sha` = `a472b0716`), four faces agreed
* while this map refused the key by name: the board's one query is
* `dataSource.find(objectName, { $filter: schema.filter, $top: schema.limit
* ?? DEFAULT_KANBAN_LIMIT })` (`plugin-kanban/src/ObjectKanban.tsx:262-266`,
* the default `100` at `:71` — a REAL top-level `$top` since objectui#4025;
* before that the cap sat under a `options` key no adapter read),
* `OBJECT_KANBAN_DATA_SOURCE` maps `limit: 'limit'`
* (`plugin-kanban/src/index.tsx:395-398`), `KanbanSchema` — the type
* `ObjectKanban.tsx:143` reads `schema` through — declares `limit?: number`
* (`plugin-kanban/src/types.ts:134`), and `content/docs/plugins/plugin-kanban.mdx`
* teaches it with a typed snippet (`limit: 250`) plus a Properties row. So
* an author following the published docs wrote a node the save gate
* refused, with the same `unrecognized_keys` verdict a typo gets.
*
* Why the carrier is `limit` and not the bound view's `pagination.pageSize`
* (the alternative the card opened): precedence is the `ElementDataSourceGate`
* table, not this key's. The component-level `dataSource.limit` overrides
* this key, and a bound named view's `pagination.pageSize` is LOWERED INTO
* it through the `limit: 'limit'` mapping only when the component authored
* none (`react/src/element-data-source/ElementDataSourceGate.tsx:236-241`,
* `readLimit`/`writeLimit` keyed by `ElementDataSourceLimitKey`). The board
* has no `pagination` read point, so declaring that spelling here would name
* a key the renderer ignores — the accepted-and-dropped defect this section
* exists to remove. Same shape as the `element:record_picker` and
* `record:related_list` row caps (one `$top` contract, not a third dialect),
* and like them the renderer's 100 is documented rather than declared:
* a schema default would materialize `limit: 100` on every parsed board.
*/
limit: z.number().int().positive().optional()
.describe("Maximum number of records loaded onto the board (row cap); lowered to the query's top-level `$top` (renderer default 100). The component-level `dataSource.limit` wins when both are set; a bound view's `pagination.pageSize` fills it only when unset"),
data: z.array(z.unknown()).optional().describe('Static inline cards — bypasses the object query'),
cardTitle: z.string().optional().describe('Field rendered as each card title'),
titleField: z.string().optional().describe('Legacy fallback for `cardTitle` (the board reads `cardTitle || titleField`). Prefer `cardTitle`'),
Expand Down
Loading