diff --git a/.changeset/17393-view-row-ceiling.md b/.changeset/17393-view-row-ceiling.md new file mode 100644 index 0000000000..675b2c05b0 --- /dev/null +++ b/.changeset/17393-view-row-ceiling.md @@ -0,0 +1,36 @@ +--- +'@objectstack/spec': minor +--- + +Gallery, kanban and timeline view configs declare an author-settable row ceiling. + +`GalleryConfigSchema`, `KanbanConfigSchema` and `TimelineConfigSchema` each gain a +`limit` member — a positive integer, default **100** — saying how many records the +view fetches. The default is APPLIED by the schema rather than only described, and +the key's own text states the other half of the contract: when the ceiling applies, +the renderer must show a visible truncation signal, because a bounded view that +looks complete is worse than an unbounded one. `DEFAULT_VIEW_ROW_LIMIT` is exported +so a consumer reads that number instead of re-declaring it. + +The knob belongs in the protocol because two renderers already cap by author choice +off keys the protocol never declared: objectui's kanban board fetches +`$top: schema.limit ?? DEFAULT_KANBAN_LIMIT` with `limit` declared in +`@object-ui/types` alone, its timeline does the same off a component props +interface, and its gallery caps not at all. `limit` is the name those consumers +already read, so this declaration absorbs the consumer-local keys instead of +introducing a second spelling of one concept. + +Nothing is removed, renamed or narrowed, and no document that parsed before is +refused now. Two things to know when upgrading: + +- a parsed gallery / kanban / timeline config carries `limit: 100` where the author + wrote no ceiling, so code that compares a parsed config against a literal object + sees the new member; +- `KanbanConfigParsed` is now declared (ADR-0122) because that schema has two shapes + for the first time; `KanbanConfig` is unchanged and remains the author state. + +The non-grid four — gantt, calendar, map and tree — are deliberately untouched: +their rows stay bounded by a platform ceiling the renderer owns, because a gantt's +range, a map's camera fit and a tree's parent chain are computed over the whole set. + +Clause-②: yes (widening) diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 008b1df006..921b6dd49e 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1674,7 +1674,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **selection** | `{ type?: Enum<'none' \| 'single' \| 'multiple'> }` | optional | Row selection configuration | | **navigation** | `{ mode?: Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>; preventNavigation?: boolean; openNewTab?: boolean; size?: Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>; … }` | optional | Configuration for item click navigation (page, drawer, modal, etc.) | | **pagination** | `{ pageSize?: integer; pageSizeOptions?: integer[] }` | optional | Pagination configuration | -| **kanban** | `{ groupByField: string; summarizeField?: string; titleField?: string; columns: string[] }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | +| **kanban** | `{ groupByField: string; summarizeField?: string; titleField?: string; columns: string[]; … }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | | **calendar** | `{ startDateField: string; endDateField?: string; titleField?: string; colorField?: string; … }` | optional | Calendar configuration — applies when the view renders as a calendar layout | | **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … }` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | | **gallery** | `{ coverField?: string; coverFit?: Enum<'cover' \| 'contain'>; cardSize?: Enum<'small' \| 'medium' \| 'large'>; titleField?: string; … }` | optional | Gallery/card view configuration | @@ -1759,7 +1759,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **selection** | `{ type?: Enum<'none' \| 'single' \| 'multiple'> }` | optional | Row selection configuration | | **navigation** | `{ mode?: Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>; preventNavigation?: boolean; openNewTab?: boolean; size?: Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>; … }` | optional | Configuration for item click navigation (page, drawer, modal, etc.) | | **pagination** | `{ pageSize?: integer; pageSizeOptions?: integer[] }` | optional | Pagination configuration | -| **kanban** | `{ groupByField: string; summarizeField?: string; titleField?: string; columns: string[] }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | +| **kanban** | `{ groupByField: string; summarizeField?: string; titleField?: string; columns: string[]; … }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | | **calendar** | `{ startDateField: string; endDateField?: string; titleField?: string; colorField?: string; … }` | optional | Calendar configuration — applies when the view renders as a calendar layout | | **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … }` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | | **gallery** | `{ coverField?: string; coverFit?: Enum<'cover' \| 'contain'>; cardSize?: Enum<'small' \| 'medium' \| 'large'>; titleField?: string; … }` | optional | Gallery/card view configuration | diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index e241f90d29..cde88732c5 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -374,7 +374,7 @@ const result = ApiMethod.parse(data); | **selection** | `{ type?: Enum<'none' \| 'single' \| 'multiple'> }` | optional | Row selection configuration | | **navigation** | `{ mode?: Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>; preventNavigation?: boolean; openNewTab?: boolean; size?: Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>; … }` | optional | Configuration for item click navigation (page, drawer, modal, etc.) | | **pagination** | `{ pageSize?: integer; pageSizeOptions?: integer[] }` | optional | Pagination configuration | -| **kanban** | `{ groupByField: string; summarizeField?: string; titleField?: string; columns: string[] }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | +| **kanban** | `{ groupByField: string; summarizeField?: string; titleField?: string; columns: string[]; … }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | | **calendar** | `{ startDateField: string; endDateField?: string; titleField?: string; colorField?: string; … }` | optional | Calendar configuration — applies when the view renders as a calendar layout | | **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … }` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | | **gallery** | `{ coverField?: string; coverFit?: Enum<'cover' \| 'contain'>; cardSize?: Enum<'small' \| 'medium' \| 'large'>; titleField?: string; … }` | optional | Gallery/card view configuration | diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index 4c6811e7a7..4c71915193 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -828,6 +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. | ### Nested Shape: `ObjectTimelineProps.filter[number]` diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index 00d53fa03c..32acc8aaa2 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -545,6 +545,7 @@ Gallery/card view configuration | **cardSize** | `Enum<'small' \| 'medium' \| 'large'>` | optional (default: `"medium"`) | Card size in gallery view | | **titleField** | `string` | optional | Field to display as card title | | **visibleFields** | `string[]` | optional | Fields to display on card body | +| **limit** | `integer` | optional (default: `100`) | Row ceiling — the most cards the gallery fetches and draws, 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. | --- @@ -700,6 +701,7 @@ HTTP methods a view data source may request — the subset of `HttpMethod` witho | **summarizeField** | `string` | optional | Field to sum at top of column (e.g. amount) | | **titleField** | `string` | optional | Field displayed as the card title. Omit to fall back to the record display name (ADR-0079 resolver chain) | | **columns** | `string[]` | ✅ | Fields to show on cards | +| **limit** | `integer` | optional (default: `100`) | Row ceiling — the most records the board fetches across all its lanes, 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. | --- @@ -801,7 +803,7 @@ Map view configuration | **selection** | `{ type?: Enum<'none' \| 'single' \| 'multiple'> }` | optional | Row selection configuration | | **navigation** | `{ mode?: Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>; preventNavigation?: boolean; openNewTab?: boolean; size?: Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>; … }` | optional | Configuration for item click navigation (page, drawer, modal, etc.) | | **pagination** | `{ pageSize?: integer; pageSizeOptions?: integer[] }` | optional | Pagination configuration | -| **kanban** | `{ groupByField: string; summarizeField?: string; titleField?: string; columns: string[] }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | +| **kanban** | `{ groupByField: string; summarizeField?: string; titleField?: string; columns: string[]; … }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | | **calendar** | `{ startDateField: string; endDateField?: string; titleField?: string; colorField?: string; … }` | optional | Calendar configuration — applies when the view renders as a calendar layout | | **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … }` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | | **gallery** | `{ coverField?: string; coverFit?: Enum<'cover' \| 'contain'>; cardSize?: Enum<'small' \| 'medium' \| 'large'>; titleField?: string; … }` | optional | Gallery/card view configuration | @@ -938,6 +940,7 @@ View filter rule | **summarizeField** | `string` | optional | Field to sum at top of column (e.g. amount) | | **titleField** | `string` | optional | Field displayed as the card title. Omit to fall back to the record display name (ADR-0079 resolver chain) | | **columns** | `string[]` | ✅ | Fields to show on cards | +| **limit** | `integer` | optional (default: `100`) | Row ceiling — the most records the board fetches across all its lanes, 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. | ### Nested Shape: `ListView.calendar` @@ -992,6 +995,7 @@ View filter rule | **cardSize** | `Enum<'small' \| 'medium' \| 'large'>` | optional (default: `"medium"`) | Card size in gallery view | | **titleField** | `string` | optional | Field to display as card title | | **visibleFields** | `string[]` | optional | Fields to display on card body | +| **limit** | `integer` | optional (default: `100`) | Row ceiling — the most cards the gallery fetches and draws, 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. | ### Nested Shape: `ListView.timeline` @@ -1003,6 +1007,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. | ### Nested Shape: `ListView.chart` @@ -1209,7 +1214,7 @@ Tab configuration for multi-tab view interface | **selection** | `{ type?: Enum<'none' \| 'single' \| 'multiple'> }` | optional | Row selection configuration | | **navigation** | `{ mode?: Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>; preventNavigation?: boolean; openNewTab?: boolean; size?: Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>; … }` | optional | Configuration for item click navigation (page, drawer, modal, etc.) | | **pagination** | `{ pageSize?: integer; pageSizeOptions?: integer[] }` | optional | Pagination configuration | -| **kanban** | `{ groupByField: string; summarizeField?: string; titleField?: string; columns: string[] }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | +| **kanban** | `{ groupByField: string; summarizeField?: string; titleField?: string; columns: string[]; … }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | | **calendar** | `{ startDateField: string; endDateField?: string; titleField?: string; colorField?: string; … }` | optional | Calendar configuration — applies when the view renders as a calendar layout | | **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … }` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | | **gallery** | `{ coverField?: string; coverFit?: Enum<'cover' \| 'contain'>; cardSize?: Enum<'small' \| 'medium' \| 'large'>; titleField?: string; … }` | optional | Gallery/card view configuration | @@ -1337,6 +1342,7 @@ View filter rule | **summarizeField** | `string` | optional | Field to sum at top of column (e.g. amount) | | **titleField** | `string` | optional | Field displayed as the card title. Omit to fall back to the record display name (ADR-0079 resolver chain) | | **columns** | `string[]` | ✅ | Fields to show on cards | +| **limit** | `integer` | optional (default: `100`) | Row ceiling — the most records the board fetches across all its lanes, 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. | ### Nested Shape: `ObjectListView.calendar` @@ -1391,6 +1397,7 @@ View filter rule | **cardSize** | `Enum<'small' \| 'medium' \| 'large'>` | optional (default: `"medium"`) | Card size in gallery view | | **titleField** | `string` | optional | Field to display as card title | | **visibleFields** | `string[]` | optional | Fields to display on card body | +| **limit** | `integer` | optional (default: `100`) | Row ceiling — the most cards the gallery fetches and draws, 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. | ### Nested Shape: `ObjectListView.timeline` @@ -1402,6 +1409,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. | ### Nested Shape: `ObjectListView.chart` @@ -1655,6 +1663,7 @@ Timeline view configuration | **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. | --- @@ -1808,7 +1817,7 @@ Tab configuration for multi-tab view interface | **selection** | `{ type?: Enum<'none' \| 'single' \| 'multiple'> }` | optional | Row selection configuration | | **navigation** | `{ mode?: Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>; preventNavigation?: boolean; openNewTab?: boolean; size?: Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>; … }` | optional | Configuration for item click navigation (page, drawer, modal, etc.) | | **pagination** | `{ pageSize?: integer; pageSizeOptions?: integer[] }` | optional | Pagination configuration | -| **kanban** | `{ groupByField: string; summarizeField?: string; titleField?: string; columns: string[] }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | +| **kanban** | `{ groupByField: string; summarizeField?: string; titleField?: string; columns: string[]; … }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | | **calendar** | `{ startDateField: string; endDateField?: string; titleField?: string; colorField?: string; … }` | optional | Calendar configuration — applies when the view renders as a calendar layout | | **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … }` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | | **gallery** | `{ coverField?: string; coverFit?: Enum<'cover' \| 'contain'>; cardSize?: Enum<'small' \| 'medium' \| 'large'>; titleField?: string; … }` | optional | Gallery/card view configuration | @@ -1893,7 +1902,7 @@ Tab configuration for multi-tab view interface | **selection** | `{ type?: Enum<'none' \| 'single' \| 'multiple'> }` | optional | Row selection configuration | | **navigation** | `{ mode?: Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>; preventNavigation?: boolean; openNewTab?: boolean; size?: Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>; … }` | optional | Configuration for item click navigation (page, drawer, modal, etc.) | | **pagination** | `{ pageSize?: integer; pageSizeOptions?: integer[] }` | optional | Pagination configuration | -| **kanban** | `{ groupByField: string; summarizeField?: string; titleField?: string; columns: string[] }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | +| **kanban** | `{ groupByField: string; summarizeField?: string; titleField?: string; columns: string[]; … }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | | **calendar** | `{ startDateField: string; endDateField?: string; titleField?: string; colorField?: string; … }` | optional | Calendar configuration — applies when the view renders as a calendar layout | | **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … }` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | | **gallery** | `{ coverField?: string; coverFit?: Enum<'cover' \| 'contain'>; cardSize?: Enum<'small' \| 'medium' \| 'large'>; titleField?: string; … }` | optional | Gallery/card view configuration | @@ -2134,7 +2143,7 @@ This schema accepts one of the following structures: | **selection** | `{ type?: Enum<'none' \| 'single' \| 'multiple'> }` | optional | Row selection configuration | | **navigation** | `{ mode?: Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>; preventNavigation?: boolean; openNewTab?: boolean; size?: Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>; … }` | optional | Configuration for item click navigation (page, drawer, modal, etc.) | | **pagination** | `{ pageSize?: integer; pageSizeOptions?: integer[] }` | optional | Pagination configuration | -| **kanban** | `{ groupByField: string; summarizeField?: string; titleField?: string; columns: string[] }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | +| **kanban** | `{ groupByField: string; summarizeField?: string; titleField?: string; columns: string[]; … }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | | **calendar** | `{ startDateField: string; endDateField?: string; titleField?: string; colorField?: string; … }` | optional | Calendar configuration — applies when the view renders as a calendar layout | | **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … }` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | | **gallery** | `{ coverField?: string; coverFit?: Enum<'cover' \| 'contain'>; cardSize?: Enum<'small' \| 'medium' \| 'large'>; titleField?: string; … }` | optional | Gallery/card view configuration | @@ -2310,7 +2319,7 @@ This schema accepts one of the following structures: | **selection** | `{ type?: Enum<'none' \| 'single' \| 'multiple'> }` | optional | Row selection configuration | | **navigation** | `{ mode?: Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>; preventNavigation?: boolean; openNewTab?: boolean; size?: Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>; … }` | optional | Configuration for item click navigation (page, drawer, modal, etc.) | | **pagination** | `{ pageSize?: integer; pageSizeOptions?: integer[] }` | optional | Pagination configuration | -| **kanban** | `{ groupByField: string; summarizeField?: string; titleField?: string; columns: string[] }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | +| **kanban** | `{ groupByField: string; summarizeField?: string; titleField?: string; columns: string[]; … }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | | **calendar** | `{ startDateField: string; endDateField?: string; titleField?: string; colorField?: string; … }` | optional | Calendar configuration — applies when the view renders as a calendar layout | | **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … }` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | | **gallery** | `{ coverField?: string; coverFit?: Enum<'cover' \| 'contain'>; cardSize?: Enum<'small' \| 'medium' \| 'large'>; titleField?: string; … }` | optional | Gallery/card view configuration | diff --git a/packages/spec/api-surface-declarations/api.txt b/packages/spec/api-surface-declarations/api.txt index a24ecbade4..9347bcb808 100644 --- a/packages/spec/api-surface-declarations/api.txt +++ b/packages/spec/api-surface-declarations/api.txt @@ -10080,6 +10080,7 @@ declare const GetUiViewResponseSchema: z.ZodObject<{ summarizeField: z.ZodOptional; titleField: z.ZodOptional; columns: z.ZodArray; + limit: z.ZodDefault; }, z.core.$strict>>; gantt: z.ZodOptional>; + limit: z.ZodDefault; }, z.core.$strict>>; gallery: z.ZodOptional; @@ -10174,6 +10176,7 @@ declare const GetUiViewResponseSchema: z.ZodObject<{ }>>; titleField: z.ZodOptional; visibleFields: z.ZodOptional>; + limit: z.ZodDefault; }, z.core.$strict>>; calendar: z.ZodOptional; titleField: z.ZodOptional; columns: z.ZodArray; + limit: z.ZodDefault; }, z.core.$strict>>; gantt: z.ZodOptional>; + limit: z.ZodDefault; }, z.core.$strict>>; gallery: z.ZodOptional; @@ -11412,6 +11417,7 @@ declare const GetUiViewResponseSchema: z.ZodObject<{ }>>; titleField: z.ZodOptional; visibleFields: z.ZodOptional>; + limit: z.ZodDefault; }, z.core.$strict>>; calendar: z.ZodOptional; titleField: z.ZodOptional; columns: z.ZodArray; + limit: z.ZodDefault; }, z.core.$strict>>; gantt: z.ZodOptional>; + limit: z.ZodDefault; }, z.core.$strict>>; gallery: z.ZodOptional; @@ -17991,6 +17999,7 @@ declare const ObjectDefinitionResponseSchema: z.ZodObject<{ }>>; titleField: z.ZodOptional; visibleFields: z.ZodOptional>; + limit: z.ZodDefault; }, z.core.$strict>>; calendar: z.ZodOptional; titleField: z.ZodOptional; columns: z.ZodArray; + limit: z.ZodDefault; }, z.core.$strict>>; gantt: z.ZodOptional>; + limit: z.ZodDefault; }, z.core.$strict>>; gallery: z.ZodOptional; @@ -25184,6 +25207,7 @@ declare const ObjectDefinitionResponseSchema: z.ZodObject<{ }>>; titleField: z.ZodOptional; visibleFields: z.ZodOptional>; + limit: z.ZodDefault; }, z.core.$strict>>; calendar: z.ZodOptional>; titleField: z.ZodOptional; visibleFields: z.ZodOptional>; + limit: z.ZodDefault; }, z.core.$strict>>; chart: z.ZodOptional; titleField: z.ZodOptional; columns: z.ZodArray; + limit: z.ZodDefault; }, z.core.$strict>>; gantt: z.ZodOptional>; + limit: z.ZodDefault; }, z.core.$strict>>; calendar: z.ZodOptional>; titleField: z.ZodOptional; visibleFields: z.ZodOptional>; + limit: z.ZodDefault; }, z.core.$strict>>; chart: z.ZodOptional; titleField: z.ZodOptional; columns: z.ZodArray; + limit: z.ZodDefault; }, z.core.$strict>>; gantt: z.ZodOptional>; + limit: z.ZodDefault; }, z.core.$strict>>; calendar: z.ZodOptional>; titleField: z.ZodOptional; visibleFields: z.ZodOptional>; + limit: z.ZodDefault; }, z.core.$strict>>; chart: z.ZodOptional; titleField: z.ZodOptional; columns: z.ZodArray; + limit: z.ZodDefault; }, z.core.$strict>>; gantt: z.ZodOptional>; + limit: z.ZodDefault; }, z.core.$strict>>; calendar: z.ZodOptional>; titleField: z.ZodOptional; visibleFields: z.ZodOptional>; + limit: z.ZodDefault; }, z.core.$strict>>; chart: z.ZodOptional; titleField: z.ZodOptional; columns: z.ZodArray; + limit: z.ZodDefault; }, z.core.$strict>>; gantt: z.ZodOptional>; + limit: z.ZodDefault; }, z.core.$strict>>; calendar: z.ZodOptional>; titleField: z.ZodOptional; visibleFields: z.ZodOptional>; + limit: z.ZodDefault; }, z.core.$strict>>; chart: z.ZodOptional; titleField: z.ZodOptional; columns: z.ZodArray; + limit: z.ZodDefault; }, z.core.$strict>>; gantt: z.ZodOptional>; + limit: z.ZodDefault; }, z.core.$strict>>; calendar: z.ZodOptional>; titleField: z.ZodOptional; visibleFields: z.ZodOptional>; + limit: z.ZodDefault; }, z.core.$strict>>; chart: z.ZodOptional; titleField: z.ZodOptional; columns: z.ZodArray; + limit: z.ZodDefault; }, z.core.$strict>>; gantt: z.ZodOptional>; + limit: z.ZodDefault; }, z.core.$strict>>; calendar: z.ZodOptional>; titleField: z.ZodOptional; visibleFields: z.ZodOptional>; + limit: z.ZodDefault; }, z.core.$strict>>; chart: z.ZodOptional; titleField: z.ZodOptional; columns: z.ZodArray; + limit: z.ZodDefault; }, z.core.$strict>>; gantt: z.ZodOptional>; + limit: z.ZodDefault; }, z.core.$strict>>; calendar: z.ZodOptional>; titleField: z.ZodOptional; visibleFields: z.ZodOptional>; + limit: z.ZodDefault; }, z.core.$strict>>; chart: z.ZodOptional; titleField: z.ZodOptional; columns: z.ZodArray; + limit: z.ZodDefault; }, z.core.$strict>>; gantt: z.ZodOptional>; + limit: z.ZodDefault; }, z.core.$strict>>; calendar: z.ZodOptional>; titleField: z.ZodOptional; visibleFields: z.ZodOptional>; + limit: z.ZodDefault; }, z.core.$strict>>; chart: z.ZodOptional; titleField: z.ZodOptional; columns: z.ZodArray; + limit: z.ZodDefault; }, z.core.$strict>>; gantt: z.ZodOptional>; + limit: z.ZodDefault; }, z.core.$strict>>; calendar: z.ZodOptional; titleField: z.ZodOptional; columns: z.ZodArray; + limit: z.ZodDefault; }, z.core.$strict>>; gantt: z.ZodOptional>; + limit: z.ZodDefault; }, z.core.$strict>>; gallery: z.ZodOptional; @@ -3170,6 +3172,7 @@ declare const ChangeSetSchema: z.ZodObject<{ }>>; titleField: z.ZodOptional; visibleFields: z.ZodOptional>; + limit: z.ZodDefault; }, z.core.$strict>>; chart: z.ZodOptional; titleField: z.ZodOptional; columns: z.ZodArray; + limit: z.ZodDefault; }, z.core.$strict>>; gantt: z.ZodOptional>; + limit: z.ZodDefault; }, z.core.$strict>>; gallery: z.ZodOptional; @@ -10363,6 +10380,7 @@ declare const ChangeSetSchema: z.ZodObject<{ }>>; titleField: z.ZodOptional; visibleFields: z.ZodOptional>; + limit: z.ZodDefault; }, z.core.$strict>>; chart: z.ZodOptional; titleField: z.ZodOptional; columns: z.ZodArray; + limit: z.ZodDefault; }, z.core.$strict>>; gantt: z.ZodOptional>; + limit: z.ZodDefault; }, z.core.$strict>>; gallery: z.ZodOptional; @@ -13362,6 +13382,7 @@ declare const ChangeSetSchema: z.ZodObject<{ }>>; titleField: z.ZodOptional; visibleFields: z.ZodOptional>; + limit: z.ZodDefault; }, z.core.$strict>>; chart: z.ZodOptional; titleField: z.ZodOptional; columns: z.ZodArray; + limit: z.ZodDefault; }, z.core.$strict>>; gantt: z.ZodOptional>; + limit: z.ZodDefault; }, z.core.$strict>>; gallery: z.ZodOptional; @@ -20555,6 +20590,7 @@ declare const ChangeSetSchema: z.ZodObject<{ }>>; titleField: z.ZodOptional; visibleFields: z.ZodOptional>; + limit: z.ZodDefault; }, z.core.$strict>>; chart: z.ZodOptional; titleField: z.ZodOptional; columns: z.ZodArray; + limit: z.ZodDefault; }, z.core.$strict>>; gantt: z.ZodOptional>; + limit: z.ZodDefault; }, z.core.$strict>>; gallery: z.ZodOptional; @@ -23517,6 +23555,7 @@ declare const CreateObjectOperation: z.ZodObject<{ }>>; titleField: z.ZodOptional; visibleFields: z.ZodOptional>; + limit: z.ZodDefault; }, z.core.$strict>>; chart: z.ZodOptional; titleField: z.ZodOptional; columns: z.ZodArray; + limit: z.ZodDefault; }, z.core.$strict>>; gantt: z.ZodOptional>; + limit: z.ZodDefault; }, z.core.$strict>>; gallery: z.ZodOptional; @@ -30710,6 +30763,7 @@ declare const CreateObjectOperation: z.ZodObject<{ }>>; titleField: z.ZodOptional; visibleFields: z.ZodOptional>; + limit: z.ZodDefault; }, z.core.$strict>>; chart: z.ZodOptional; titleField: z.ZodOptional; columns: z.ZodArray; + limit: z.ZodDefault; }, z.core.$strict>>; gantt: z.ZodOptional>; + limit: z.ZodDefault; }, z.core.$strict>>; gallery: z.ZodOptional; @@ -34456,6 +34512,7 @@ declare const EnvironmentArtifactSchema: z.ZodObject<{ }>>; titleField: z.ZodOptional; visibleFields: z.ZodOptional>; + limit: z.ZodDefault; }, z.core.$strict>>; chart: z.ZodOptional; titleField: z.ZodOptional; columns: z.ZodArray; + limit: z.ZodDefault; }, z.core.$strict>>; gantt: z.ZodOptional>; + limit: z.ZodDefault; }, z.core.$strict>>; gallery: z.ZodOptional; @@ -41649,6 +41720,7 @@ declare const EnvironmentArtifactSchema: z.ZodObject<{ }>>; titleField: z.ZodOptional; visibleFields: z.ZodOptional>; + limit: z.ZodDefault; }, z.core.$strict>>; chart: z.ZodOptional; titleField: z.ZodOptional; columns: z.ZodArray; + limit: z.ZodDefault; }, z.core.$strict>>; gantt: z.ZodOptional>; + limit: z.ZodDefault; }, z.core.$strict>>; gallery: z.ZodOptional; @@ -44069,6 +44143,7 @@ declare const EnvironmentArtifactSchema: z.ZodObject<{ }>>; titleField: z.ZodOptional; visibleFields: z.ZodOptional>; + limit: z.ZodDefault; }, z.core.$strict>>; chart: z.ZodOptional; titleField: z.ZodOptional; columns: z.ZodArray; + limit: z.ZodDefault; }, z.core.$strict>>; gantt: z.ZodOptional>; + limit: z.ZodDefault; }, z.core.$strict>>; gallery: z.ZodOptional; @@ -45307,6 +45384,7 @@ declare const EnvironmentArtifactSchema: z.ZodObject<{ }>>; titleField: z.ZodOptional; visibleFields: z.ZodOptional>; + limit: z.ZodDefault; }, z.core.$strict>>; chart: z.ZodOptional; titleField: z.ZodOptional; columns: z.ZodArray; + limit: z.ZodDefault; }, z.core.$strict>>; gantt: z.ZodOptional>; + limit: z.ZodDefault; }, z.core.$strict>>; gallery: z.ZodOptional; @@ -58724,6 +58804,7 @@ declare const MigrationOperationSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ }>>; titleField: z.ZodOptional; visibleFields: z.ZodOptional>; + limit: z.ZodDefault; }, z.core.$strict>>; chart: z.ZodOptional; titleField: z.ZodOptional; columns: z.ZodArray; + limit: z.ZodDefault; }, z.core.$strict>>; gantt: z.ZodOptional>; + limit: z.ZodDefault; }, z.core.$strict>>; gallery: z.ZodOptional; @@ -65917,6 +66012,7 @@ declare const MigrationOperationSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ }>>; titleField: z.ZodOptional; visibleFields: z.ZodOptional>; + limit: z.ZodDefault; }, z.core.$strict>>; chart: z.ZodOptional>; + limit: z.ZodDefault; }, z.core.$strict>>; filter: z.ZodOptional) => Dashboard; @@ -8744,6 +8748,7 @@ declare const GalleryConfigSchema: z.ZodObject<{ }>>; titleField: z.ZodOptional; visibleFields: z.ZodOptional>; + limit: z.ZodDefault; }, z.core.$strict>; // ── GanttConfig (type) ── @@ -9687,12 +9692,16 @@ declare const KNOWN_COMPONENT_TYPE_CANDIDATES: readonly string[]; // ── KanbanConfig (type) ── type KanbanConfig = z.input; +// ── KanbanConfigParsed (type) ── +type KanbanConfigParsed = z.infer; + // ── KanbanConfigSchema (const) ── declare const KanbanConfigSchema: z.ZodObject<{ groupByField: z.ZodString; summarizeField: z.ZodOptional; titleField: z.ZodOptional; columns: z.ZodArray; + limit: z.ZodDefault; }, z.core.$strict>; // ── LIST_VIEW_GROUP_COUNT_ALIAS (const) ── @@ -10153,6 +10162,7 @@ declare const ListViewSchema: z.ZodObject<{ summarizeField: z.ZodOptional; titleField: z.ZodOptional; columns: z.ZodArray; + limit: z.ZodDefault; }, z.core.$strict>>; calendar: z.ZodOptional>; titleField: z.ZodOptional; visibleFields: z.ZodOptional>; + limit: z.ZodDefault; }, z.core.$strict>>; timeline: z.ZodOptional>; + limit: z.ZodDefault; }, z.core.$strict>>; chart: z.ZodOptional>; titleField: z.ZodOptional; visibleFields: z.ZodOptional>; + limit: z.ZodDefault; }, z.core.$strict>>; chart: z.ZodOptional; titleField: z.ZodOptional; columns: z.ZodArray; + limit: z.ZodDefault; }, z.core.$strict>>; gantt: z.ZodOptional>; + limit: z.ZodDefault; }, z.core.$strict>>; calendar: z.ZodOptional>; + limit: z.ZodDefault; }, z.core.$strict>>; filter: z.ZodOptional>; + limit: z.ZodDefault; }, z.core.$strict>; // ── TreeConfig (type) ── @@ -20859,6 +20876,7 @@ declare const VIEW_METADATA_MEMBERS: { }>>; titleField: z.ZodOptional; visibleFields: z.ZodOptional>; + limit: z.ZodDefault; }, z.core.$strict>>; chart: z.ZodOptional; titleField: z.ZodOptional; columns: z.ZodArray; + limit: z.ZodDefault; }, z.core.$strict>>; gantt: z.ZodOptional>; + limit: z.ZodDefault; }, z.core.$strict>>; calendar: z.ZodOptional>; titleField: z.ZodOptional; visibleFields: z.ZodOptional>; + limit: z.ZodDefault; }, z.core.$strict>>; chart: z.ZodOptional; titleField: z.ZodOptional; columns: z.ZodArray; + limit: z.ZodDefault; }, z.core.$strict>>; gantt: z.ZodOptional>; + limit: z.ZodDefault; }, z.core.$strict>>; calendar: z.ZodOptional; titleField: z.ZodOptional; columns: z.ZodArray; + limit: z.ZodDefault; }, z.core.$strict>>; calendar: z.ZodOptional>; titleField: z.ZodOptional; visibleFields: z.ZodOptional>; + limit: z.ZodDefault; }, z.core.$strict>>; timeline: z.ZodOptional>; + limit: z.ZodDefault; }, z.core.$strict>>; chart: z.ZodOptional>; titleField: z.ZodOptional; visibleFields: z.ZodOptional>; + limit: z.ZodDefault; }, z.core.$strict>>; chart: z.ZodOptional; titleField: z.ZodOptional; columns: z.ZodArray; + limit: z.ZodDefault; }, z.core.$strict>>; gantt: z.ZodOptional>; + limit: z.ZodDefault; }, z.core.$strict>>; calendar: z.ZodOptional>; titleField: z.ZodOptional; visibleFields: z.ZodOptional>; + limit: z.ZodDefault; }, z.core.$strict>>; chart: z.ZodOptional; titleField: z.ZodOptional; columns: z.ZodArray; + limit: z.ZodDefault; }, z.core.$strict>>; gantt: z.ZodOptional>; + limit: z.ZodDefault; }, z.core.$strict>>; calendar: z.ZodOptional, z.infer< type export type Iso826 = Assert, z.infer< typeof M167.CalendarConfigSchema > >>; export type Iso827 = Assert, z.infer< typeof M167.GanttConfigSchema > >>; export type Iso828 = Assert, z.infer< typeof M167.GanttQuickFilterSchema > >>; -export type Iso829 = Assert, z.infer< typeof M167.KanbanConfigSchema > >>; +// (Iso829 `KanbanConfigSchema` left this list in #17393: the author-settable +// row ceiling `limit` APPLIES its default, which is exactly the "a nested field +// gains a `.default()`" event this file exists to catch — so the schema now has +// two shapes and `KanbanConfigParsed` is declared beside the bare alias, as +// ADR-0122 prescribes. Its two page-shaped siblings needed no line moved: both +// already carried defaults and therefore both halves of the pair.) export type Iso851 = Assert, z.infer< typeof M167.ListMapConfigSchema > >>; export type Iso830 = Assert, z.infer< typeof M167.NavigationModeSchema > >>; export type Iso831 = Assert, z.infer< typeof M167.TreeConfigSchema > >>; @@ -1678,7 +1683,7 @@ describe('ADR-0122 type-alias convention', () => { // this title and the section header above the pin list — are now asserted // against the recomputed count below, so neither can go stale without a red // test naming it. - it('still declares all 785 isomorphic pins', () => { + it('still declares all 784 isomorphic pins', () => { // The truth of each pin is proved by tsc, not here — an `Assert>` // that stops holding is a compile error with the alias named. What tsc // cannot notice is a pin that was DELETED: removing the assertion removes @@ -2252,7 +2257,18 @@ describe('ADR-0122 type-alias convention', () => { // Note the number is the same 783 -> 785 the DURATION block above records, // arrived at from the same 783 after #16059's retirement took it back down. // The two entries are different movements that share a pair of endpoints. - expect(pins).toHaveLength(785); + // 785 -> 784 is #17393's author-settable row ceiling on the page-shaped + // view configs (ui/view.zod.ts, module slot M167): `KanbanConfigSchema` + // gained a `limit` member whose default is APPLIED, which is the "a nested + // field gains a `.default()`" event at the top of this file, one level in. + // Author state and parsed state part company, so the pin left and + // `KanbanConfigParsed` is declared beside the bare alias. Its two siblings + // in that card moved no line: `GalleryConfig` and `TimelineConfig` already + // carried defaults (`coverFit` / `cardSize`, `scale`) and therefore already + // carried both halves of the pair — which is also why only ONE of the three + // was ever on this list. -1 converted to an `XParsed` pair; the Iso number + // stays vacant (ids are claims about pins, not positions). + expect(pins).toHaveLength(784); // The count is stated in PROSE twice as well — this case's title and the // section header above the pin list — and until #6605 nothing read either diff --git a/packages/spec/src/ui/view.test.ts b/packages/spec/src/ui/view.test.ts index 44f98706e4..191ec8e982 100644 --- a/packages/spec/src/ui/view.test.ts +++ b/packages/spec/src/ui/view.test.ts @@ -44,6 +44,8 @@ import { ViewItemSchema, ViewMetadataSchema, VIEW_METADATA_MEMBERS, + TreeConfigSchema, + DEFAULT_VIEW_ROW_LIMIT, } from './view.zod'; import { @@ -4660,3 +4662,118 @@ describe('ListViewSchema — `viewType` is not a spelling of `type` (#16577)', ( expect((r as { data: { type?: unknown } }).data.type).toBe('grid'); }); }); + + +// ============================================================================ +// [#17393] The author-settable row ceiling on the page-shaped view configs. +// +// A protocol-first card: objectui caps kanban and timeline by author choice off +// a key `@objectstack/spec` never declared (`$top: schema.limit ?? DEFAULT_*_LIMIT`), +// and the gallery — the third page-shaped view — caps not at all. These pins +// hold the new declaration to the three things a ceiling has to be: APPLIED +// (the default the prose states is the default the parse produces), BOUNDED +// (a value that could not cap a fetch is refused by name), and SCOPED (the +// non-grid four keep objectui#7210's platform ceiling and do not gain an +// authorable one). +// ============================================================================ + +describe('view row ceiling — `limit` on the page-shaped view configs (#17393)', () => { + /** + * One minimal, parse-clean block per page-shaped config, so every verdict + * below is about `limit` alone rather than about a missing sibling key. + */ + const PAGE_SHAPED = [ + ['gallery', GalleryConfigSchema as unknown as z.ZodTypeAny, {}], + ['kanban', KanbanConfigSchema as unknown as z.ZodTypeAny, { groupByField: 'status', columns: ['name'] }], + ['timeline', TimelineConfigSchema as unknown as z.ZodTypeAny, { startDateField: 'start_date', titleField: 'name' }], + ] as const; + + /** The `limit` member's own `.describe()` text, off the built shape. */ + const describeOf = (schema: z.ZodTypeAny): string => + (schema as unknown as { shape: Record }).shape.limit?.description ?? ''; + + it('applies the ceiling it declares when the author writes none', () => { + for (const [label, schema, minimal] of PAGE_SHAPED) { + const parsed = schema.parse({ ...minimal }) as { limit?: unknown }; + expect(parsed.limit, label).toBe(DEFAULT_VIEW_ROW_LIMIT); + } + }); + + it('accepts an authored ceiling as a MEMBER, with both controls firing on the same shape', () => { + for (const [label, schema, minimal] of PAGE_SHAPED) { + // CONTROL-1 — this surface CAN refuse a key, so acceptance below means something. + const control = schema.safeParse({ ...minimal, zzUnlikelyBogusKey__: 7 }); + expect(control.success, label).toBe(false); + expect(JSON.stringify((control as { error?: z.ZodError }).error?.issues), label) + .toContain('unrecognized_keys'); + + // CONTROL-2 — the refusal is about the NAME: the same block without it parses. + expect(schema.safeParse({ ...minimal }).success, label).toBe(true); + + // PROBE — the authored value SURVIVES the parse; it is not merely tolerated. + const probe = schema.safeParse({ ...minimal, limit: 25 }); + expect(probe.success, label).toBe(true); + expect(((probe as { data?: { limit?: unknown } }).data)?.limit, label).toBe(25); + } + }); + + it('refuses a value that could not bound a fetch — and refuses it BY NAME', () => { + for (const [label, schema, minimal] of PAGE_SHAPED) { + for (const bad of [0, -1, 2.5, '100', null] as const) { + const at = `${label} limit=${JSON.stringify(bad)}`; + const result = schema.safeParse({ ...minimal, limit: bad }); + expect(result.success, at).toBe(false); + const issues = (result as { error?: z.ZodError }).error?.issues ?? []; + expect(issues.some((issue) => issue.path[0] === 'limit'), `${at}: ${JSON.stringify(issues)}`) + .toBe(true); + } + } + }); + + it('states the default it ACTUALLY applies — prose and schema pinned to each other', () => { + for (const [label, schema, minimal] of PAGE_SHAPED) { + const description = describeOf(schema); + const stated = /default (\d+)/.exec(description); + expect(stated, `${label}: ${description}`).not.toBeNull(); + const applied = (schema.parse({ ...minimal }) as { limit: number }).limit; + expect(Number(stated?.[1]), `${label}: ${description}`).toBe(applied); + } + }); + + it('tells the author the renderer owes a VISIBLE truncation signal', () => { + // ⛔ The signal itself is the renderer's half and cannot be enforced from a + // schema. What the protocol can do — and what objectui#7390's ruling turns + // on — is say the cap is owed a signal, so that "bounded and silent" is + // never read as the finished job. + for (const [label, schema] of PAGE_SHAPED) { + expect(describeOf(schema), label).toContain('visible truncation signal'); + } + }); + + it('leaves the non-grid four WITHOUT an authorable ceiling (objectui#7210 keeps theirs)', () => { + // The card scopes those four out by name: their rows are capped by a + // platform constant the renderer owns, because a gantt range, a map camera + // fit and a tree parent chain are computed over the whole set. An + // authorable ceiling there would be surface no renderer reads. + const NON_GRID_FOUR = [ + ['gantt', GanttConfigSchema as unknown as z.ZodTypeAny], + ['calendar', CalendarConfigSchema as unknown as z.ZodTypeAny], + ['map', ListMapConfigSchema as unknown as z.ZodTypeAny], + ['tree', TreeConfigSchema as unknown as z.ZodTypeAny], + ] as const; + + for (const [label, schema] of NON_GRID_FOUR) { + const result = schema.safeParse({ limit: 10 }); + expect(result.success, label).toBe(false); + // Asserted on the REFUSED KEY LIST rather than on a stringified issue: + // when the key is accepted there is no issue to stringify, and the red + // then reads as an argument-type complaint instead of as a statement + // about this view type. Measured — it is how this case first reddened. + const refused = ((result as { error?: z.ZodError }).error?.issues ?? []) + .filter((issue) => issue.code === 'unrecognized_keys') + .flatMap((issue) => (issue as unknown as { keys?: string[] }).keys ?? []); + expect(refused, `${label} accepts an authorable row ceiling it should not declare`) + .toContain('limit'); + } + }); +}); diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 358aed5fc7..65853f52f3 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -1128,6 +1128,99 @@ export const GroupingConfigSchema = lazySchema(() => strictObject({ + 'AND-ed into the view filter. Compiled by `compileListViewGroupQuery` / `compileListViewGroupRowsQuery`', )); +/* + * --------------------------------------------------------------------------- + * `limit` — the author-settable row ceiling of the page-shaped views (#17393) + * --------------------------------------------------------------------------- + * + * Declared here because the PROTOCOL was the thing that was wrong: two + * renderers already cap by author choice, and the key they read was never a + * protocol key. Measured in objectui at `dda8f3815d`: + * + * - `ObjectKanban.tsx:573` fetches `$top: schema.limit ?? DEFAULT_KANBAN_LIMIT` + * (`= 100` at `:84`), and that `limit` is declared in `@object-ui/types` + * alone (`zod/objectql.zod.ts:1762`, `z.number().int().positive().optional()`); + * - `ObjectTimeline.tsx:328` fetches `$top: schema.limit ?? DEFAULT_TIMELINE_LIMIT` + * (`= 100` at `:29`), with `limit` on that component's own props interface + * (`:129`) and on no published schema at all; + * - `ObjectGallery.tsx` sends no `$top` and reads no ceiling at all — the + * unbounded fetch objectui#7390 is ruled to close by reading this key. + * + * Consumer-local author-settable keys the protocol never declared are the + * divergence the contract-first directive forbids, so the knob enters the + * protocol first and the three spellings come under one declaration (the + * director seat's amendment of 2026-09-10T11:0xZ on objectui#7390, on the + * maintainer's principle 「我们的项目以objectstack 协议为准,文档应该以实际实现 + * 为准。协议不正确的应该先修改协议。」). + * + * ## Why on the per-view config blocks, and not as a member of the list view + * + * The alternative shape — one row ceiling on {@link ListViewShapeSchema} + * itself — is rejected on three properties of this tree: + * + * 1. The base shape ALREADY carries the row-bounding knob every view type + * reaches: `pagination.pageSize` ({@link PaginationConfigSchema}, default + * 25). A second base-level row key would leave one view with two + * base-level row bounds and no declared precedence between them — and the + * `virtualScroll` tombstone at the bottom of this same shape prescribes + * `pagination` for exactly that question. + * 2. A base member is reachable from EVERY `type`, the non-grid four + * (gantt / calendar / map / tree) included. Their ceiling is a platform + * constant the renderer owns (objectui#7210) and this card does not touch + * them, so a base member would publish an authorable ceiling on four view + * kinds no renderer reads — declared-but-unenforced on the day it lands. + * 3. The per-kind block is what actually REACHES the renderer: objectui's + * `ListView` merges `schema.` into the generated node — its kanban + * branch spreads the rest of the block flat onto `object-kanban`, so + * `kanban.limit` lands exactly where `schema.limit` is read — while a + * base-level key is forwarded into no per-kind node at all. + * + * The NAME is `limit` for the same reason: it is the name the consumer already + * reads, so this declaration absorbs the two consumer-local keys instead of + * buying a second divergence spelled differently. + * + * ⚠️ NOT the kanban LANE's `limit`. objectui's node-level + * `ObjectKanbanLaneSchema.limit` is a WIP warning threshold that never reaches + * a query; no lane object exists on this face at all + * ({@link KanbanConfigSchema}'s `columns` is a list of card FIELD names), so + * the two cannot be confused here. + */ +export const DEFAULT_VIEW_ROW_LIMIT = 100; + +/** What a page-shaped view's ceiling bounds, per view type. */ +const ROW_LIMIT_SUBJECT = { + gallery: 'cards the gallery fetches and draws', + kanban: 'records the board fetches across all its lanes', + timeline: 'rows the timeline fetches onto its rail', +} as const; + +/** The three view configs that cap by AUTHOR choice (not by platform ceiling). */ +type RowLimitView = keyof typeof ROW_LIMIT_SUBJECT; + +/** + * The `limit` declaration for one page-shaped view config. + * + * The default is APPLIED, not merely described: a `.describe()` naming a + * default the schema does not apply is a second contract that nothing + * enforces, and the two drift the first time either is edited. The agreement + * is pinned from both sides in `view.test.ts` (#17393) — the parsed default is + * compared against the number the describe text states. + * + * ⛔ The truncation signal is the renderer's half and cannot be enforced from + * here; it is stated in the describe because a bounded-and-silent view reads + * as complete, which is worse than the unbounded-and-silent one this key + * replaces — the author needs to know the cap is visible, and the renderer + * author needs to know it is owed. + */ +const rowLimitKey = (view: RowLimitView) => + z.number().int().positive().default(DEFAULT_VIEW_ROW_LIMIT).describe( + `Row ceiling — the most ${ROW_LIMIT_SUBJECT[view]}, sent as the query \`$top\`; default ` + + `${DEFAULT_VIEW_ROW_LIMIT} 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.', + ); + /** * Gallery View Configuration (Airtable-style) * Configures card layout for gallery/card views. @@ -1141,6 +1234,7 @@ export const GalleryConfigSchema = lazySchema(() => strictObject({ cardSize: z.enum(['small', 'medium', 'large']).default('medium').describe('Card size in gallery view'), titleField: z.string().optional().describe('Field to display as card title'), visibleFields: z.array(z.string()).optional().describe('Fields to display on card body'), + limit: rowLimitKey('gallery'), }).describe('Gallery/card view configuration')); /** @@ -1163,6 +1257,7 @@ export const TimelineConfigSchema = lazySchema(() => strictObject({ ), colorField: z.string().optional().describe('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: z.enum(['hour', 'day', 'week', 'month', 'quarter', 'year']).default('week').describe('Default timeline scale'), + limit: rowLimitKey('timeline'), }).describe('Timeline view configuration')); /** @@ -1466,6 +1561,7 @@ export const KanbanConfigSchema = lazySchema(() => strictObject({ */ titleField: z.string().optional().describe('Field displayed as the card title. Omit to fall back to the record display name (ADR-0079 resolver chain)'), columns: z.array(z.string()).describe('Fields to show on cards'), + limit: rowLimitKey('kanban'), })); /** @@ -5837,6 +5933,8 @@ export type CalendarConfig = z.input; export type GanttConfig = z.input; export type GanttQuickFilter = z.input; export type KanbanConfig = z.input; +/** Post-parse shape of {@link KanbanConfig} — defaults applied, transforms run (ADR-0122). */ +export type KanbanConfigParsed = z.infer; export type ListMapConfig = z.input; export type NavigationMode = z.input; export type TreeConfig = z.input;