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
60 changes: 60 additions & 0 deletions .changeset/19228-view-row-limit-route-record.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
---
"@objectstack/spec": patch
---

fix(spec): state the row-cap guard `ElementDataSourceGate` implements, and record where the per-kind view `limit` actually lands (#19228)

Prose and pins only — zero accept-set movement, zero export movement. The same documents parse
to the same values before and after. ⛔ No `.default()` moves, ⛔ no precedence is picked: which
of the per-kind view `limit`, a view's `pagination.pageSize` and a component's flat `limit`
should win is the open half of #19228 and is not answered here.

## What the published text said, and what an author can actually reach

`ObjectKanbanPropsSchema.limit` tells authors that a bound view's `pagination.pageSize` fills it
「only when unset」. Measured first-hand at the objectui pin this repo builds against
(`.objectui-sha` = `87af769e9`), that sentence is exactly right for this face, and the describe
now says WHY rather than leaving it to look narrower than the mechanism.

The gate's branch is `if (!fromView || !isUsableRowLimit(authored))`
(`react/src/element-data-source/ElementDataSourceGate.tsx:316-331`), and `isUsableRowLimit` is
`typeof v === 'number' && Number.isInteger(v) && v > 0` (`:192-194`). Every cap this key ACCEPTS
is one that predicate already calls usable — the accept set is a subset of the usable set — so
across the whole accept set the guard has exactly two outcomes and 「set but not usable」 is
empty. The extra arm, a cap displaced and reported because it is zero, negative or fractional,
is reachable only for a node this contract refuses, so it is recorded in the docblock rather
than in an author-facing sentence.

The view half is `pagination.pageSize` ALONE on this face. `savedViewLimit` does fall back to a
flat `view.limit` (`core/src/data-scope/element-data-source.ts:237-241`), but that names a
saved-view RECORD as the adapter's `listViews()` returns it — a third face, not an authored view
document. Measured on this tree: `ListViewSchema` REFUSES a flat `limit` with
`unrecognized_keys: ["limit"]`, the verdict a bogus key gets, while the same minimal document
parses with `pagination.pageSize: 50` and with a per-kind `kanban.limit: 50`. No view document
declares a flat `limit` and none carries a tombstone for one.

## Where the per-kind VIEW `limit` lands

⚠️ Two different keys are easy to confuse here, so each statement names its face. The **view
face** is a `ListViewSchema` document's `kanban` / `gallery` / `timeline` block — that is where
this key lives. The **element face** is a page component node's own flat `limit`, declared in
`component.zod.ts`, and that is the key every renderer actually reads. An adapter turns the
first into the second.

The adapters spread a view's per-kind block FLAT onto the node they generate — `...restKanban`
(`plugin-list/src/ListView.tsx:2979`, `plugin-view/src/ObjectView.tsx:1638`; neither destructure
strips `limit`) and `...(viewOptions.gallery || {})` / `...(viewOptions.timeline || {})`
(`ObjectView.tsx:1697` / `:1725`). So a view's `kanban.limit` — including the 100 the applied
default materializes — becomes the node's flat `limit`, which `ObjectKanban.tsx:553` reads. A
view's `timeline.limit` is route-dependent: `plugin-view` flattens it and `ObjectTimeline.tsx:279`
reads it, while `plugin-list` forwards the block nested, where nothing does. A view's
`gallery.limit` is flattened too and read by nobody — `ObjectGallery.tsx` contains no `limit` at
all.

Where it is read, the `$top` it would govern is still not issued on either adapter route today,
because both hosts hand rows down as a React `data` prop and both children short-circuit their
own fetch; ⛔ that is a statement about the query, not about the key being unread.

⚠️ For authors of an `object-timeline` NODE: a `limit` written inside that node's own `timeline`
block is read by no renderer on any route — the rail is capped by the flat `limit` beside it,
which is also the only one a bound `dataSource` lowers into. Write the flat one.
6 changes: 3 additions & 3 deletions content/docs/references/ui/component.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -617,7 +617,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** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Base query filter, handed to the wire `$filter` — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` |
| **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 |
| **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 this key only when it is unset — and on this face unset is the whole rule, because every cap this key accepts is one the binding gate already treats as authored |
| **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 Expand Up @@ -803,7 +803,7 @@ View filter rule
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **objectName** | `string` | optional | Object this timeline binds to. Optional because the component-level `dataSource` binding can supply the object instead — this block registers through `ElementDataSourceGate`, which lowers the binding onto this key before the renderer sees the node |
| **timeline** | `{ startDateField: string; endDateField?: string; titleField: string; groupByField?: string; … }` | optional | Timeline configuration, the author face — the same block `ListViewSchema.timeline` declares: `{ startDateField, endDateField, titleField, groupByField, colorField, scale }`. The flat top-level spellings beside it are the runtime handoff, not a second authoring spelling |
| **timeline** | `{ startDateField: string; endDateField?: string; titleField: string; groupByField?: string; … }` | optional | Timeline configuration, the author face — the same block `ListViewSchema.timeline` declares: `{ startDateField, endDateField, titleField, groupByField, colorField, scale, limit }`. The flat top-level spellings beside it are the runtime handoff, not a second authoring spelling. ⚠️ `limit` is the one member of this block NO renderer reads on any route: the rail is capped by the FLAT `limit` beside this key, which is also the only one a bound `dataSource` lowers into. A `limit` written inside this block is accepted, defaulted to 100, and never read |
| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Base query filter — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares; lowered to the wire `$filter`. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` |
| **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Row order for the fetched entries — the SortItem array form `[{ field, order }, ...]`, the one sort orthography every declared `sort` door on this platform shares; lowered to the wire `$orderby`. The legacy string clause (`name desc`) is refused — see migration `object-block-sort-item-array` |
| **limit** | `integer` | optional | Maximum number of records loaded onto the rail (row cap); lowered to the query's top-level `$top` (renderer default 100). A timeline renders one rail with no pagination control, so this is the author's window rather than a page size |
Expand All @@ -828,7 +828,7 @@ View filter rule
| **groupByField** | `string` | optional | Field to group timeline rows. NO leading or trailing whitespace: the renderer reads this name off every row verbatim, so a padded spelling drops every row into one ungrouped band. |
| **colorField** | `string` | optional | Field to derive each item color from (it names a field, not a color): the option color declared on that field for the record value, else the value itself when it already is a color literal (hex, rgb() or hsl()), else the timeline default marker color |
| **scale** | `Enum<'hour' \| 'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>` | optional (default: `"week"`) | Default timeline scale |
| **limit** | `integer` | optional (default: `100`) | Row ceiling — the most rows the timeline fetches onto its rail, sent as the query `$top`; default 100 when the key is absent. When the ceiling APPLIES (the filtered set is larger than it), the renderer must show a visible truncation signal saying what is on screen is not the whole set — a bounded view that looks complete is worse than an unbounded one. |
| **limit** | `integer` | optional (default: `100`) | Row ceiling — the most rows the timeline fetches onto its rail; default 100 when the key is absent. The renderer owes two things: bound its fetch at this number, and, when the ceiling APPLIES (the filtered set is larger than it), show a visible truncation signal saying what is on screen is not the whole set — a bounded view that looks complete is worse than an unbounded one. ⚠️ Not every view kind has a renderer that reads this key yet; which do is recorded on the declaration. |

### Nested Shape: `ObjectTimelineProps.filter[number]`

Expand Down
Loading
Loading