Skip to content

Commit a20fde1

Browse files
committed
feat(spec): declare the author-settable row ceiling on the page-shaped view configs
`GalleryConfigSchema`, `KanbanConfigSchema` and `TimelineConfigSchema` each gain a `limit` member: an int-positive row ceiling with the default applied (100), whose describe states that default and the visible truncation signal the renderer owes when the ceiling applies. `DEFAULT_VIEW_ROW_LIMIT` is exported so a consumer reads the number instead of re-declaring it. The name and the placement are the protocol absorbing keys the consumers already read: objectui caps kanban (`$top: schema.limit ?? DEFAULT_KANBAN_LIMIT`) and timeline off keys declared in `@object-ui/types` and on a component props interface, never in the protocol. The non-grid four keep their platform ceiling and gain nothing here. Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0046a41 commit a20fde1

2 files changed

Lines changed: 207 additions & 0 deletions

File tree

‎packages/spec/src/ui/view.test.ts‎

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ import {
4444
ViewItemSchema,
4545
ViewMetadataSchema,
4646
VIEW_METADATA_MEMBERS,
47+
TreeConfigSchema,
48+
DEFAULT_VIEW_ROW_LIMIT,
4749
} from './view.zod';
4850

4951
import {
@@ -4660,3 +4662,112 @@ describe('ListViewSchema — `viewType` is not a spelling of `type` (#16577)', (
46604662
expect((r as { data: { type?: unknown } }).data.type).toBe('grid');
46614663
});
46624664
});
4665+
4666+
4667+
// ============================================================================
4668+
// [#17393] The author-settable row ceiling on the page-shaped view configs.
4669+
//
4670+
// A protocol-first card: objectui caps kanban and timeline by author choice off
4671+
// a key `@objectstack/spec` never declared (`$top: schema.limit ?? DEFAULT_*_LIMIT`),
4672+
// and the gallery — the third page-shaped view — caps not at all. These pins
4673+
// hold the new declaration to the three things a ceiling has to be: APPLIED
4674+
// (the default the prose states is the default the parse produces), BOUNDED
4675+
// (a value that could not cap a fetch is refused by name), and SCOPED (the
4676+
// non-grid four keep objectui#7210's platform ceiling and do not gain an
4677+
// authorable one).
4678+
// ============================================================================
4679+
4680+
describe('view row ceiling — `limit` on the page-shaped view configs (#17393)', () => {
4681+
/**
4682+
* One minimal, parse-clean block per page-shaped config, so every verdict
4683+
* below is about `limit` alone rather than about a missing sibling key.
4684+
*/
4685+
const PAGE_SHAPED = [
4686+
['gallery', GalleryConfigSchema as unknown as z.ZodTypeAny, {}],
4687+
['kanban', KanbanConfigSchema as unknown as z.ZodTypeAny, { groupByField: 'status', columns: ['name'] }],
4688+
['timeline', TimelineConfigSchema as unknown as z.ZodTypeAny, { startDateField: 'start_date', titleField: 'name' }],
4689+
] as const;
4690+
4691+
/** The `limit` member's own `.describe()` text, off the built shape. */
4692+
const describeOf = (schema: z.ZodTypeAny): string =>
4693+
(schema as unknown as { shape: Record<string, { description?: string }> }).shape.limit?.description ?? '';
4694+
4695+
it('applies the ceiling it declares when the author writes none', () => {
4696+
for (const [label, schema, minimal] of PAGE_SHAPED) {
4697+
const parsed = schema.parse({ ...minimal }) as { limit?: unknown };
4698+
expect(parsed.limit, label).toBe(DEFAULT_VIEW_ROW_LIMIT);
4699+
}
4700+
});
4701+
4702+
it('accepts an authored ceiling as a MEMBER, with both controls firing on the same shape', () => {
4703+
for (const [label, schema, minimal] of PAGE_SHAPED) {
4704+
// CONTROL-1 — this surface CAN refuse a key, so acceptance below means something.
4705+
const control = schema.safeParse({ ...minimal, zzUnlikelyBogusKey__: 7 });
4706+
expect(control.success, label).toBe(false);
4707+
expect(JSON.stringify((control as { error?: z.ZodError }).error?.issues), label)
4708+
.toContain('unrecognized_keys');
4709+
4710+
// CONTROL-2 — the refusal is about the NAME: the same block without it parses.
4711+
expect(schema.safeParse({ ...minimal }).success, label).toBe(true);
4712+
4713+
// PROBE — the authored value SURVIVES the parse; it is not merely tolerated.
4714+
const probe = schema.safeParse({ ...minimal, limit: 25 });
4715+
expect(probe.success, label).toBe(true);
4716+
expect(((probe as { data?: { limit?: unknown } }).data)?.limit, label).toBe(25);
4717+
}
4718+
});
4719+
4720+
it('refuses a value that could not bound a fetch — and refuses it BY NAME', () => {
4721+
for (const [label, schema, minimal] of PAGE_SHAPED) {
4722+
for (const bad of [0, -1, 2.5, '100', null] as const) {
4723+
const at = `${label} limit=${JSON.stringify(bad)}`;
4724+
const result = schema.safeParse({ ...minimal, limit: bad });
4725+
expect(result.success, at).toBe(false);
4726+
const issues = (result as { error?: z.ZodError }).error?.issues ?? [];
4727+
expect(issues.some((issue) => issue.path[0] === 'limit'), `${at}: ${JSON.stringify(issues)}`)
4728+
.toBe(true);
4729+
}
4730+
}
4731+
});
4732+
4733+
it('states the default it ACTUALLY applies — prose and schema pinned to each other', () => {
4734+
for (const [label, schema, minimal] of PAGE_SHAPED) {
4735+
const description = describeOf(schema);
4736+
const stated = /default (\d+)/.exec(description);
4737+
expect(stated, `${label}: ${description}`).not.toBeNull();
4738+
const applied = (schema.parse({ ...minimal }) as { limit: number }).limit;
4739+
expect(Number(stated?.[1]), `${label}: ${description}`).toBe(applied);
4740+
}
4741+
});
4742+
4743+
it('tells the author the renderer owes a VISIBLE truncation signal', () => {
4744+
// ⛔ The signal itself is the renderer's half and cannot be enforced from a
4745+
// schema. What the protocol can do — and what objectui#7390's ruling turns
4746+
// on — is say the cap is owed a signal, so that "bounded and silent" is
4747+
// never read as the finished job.
4748+
for (const [label, schema] of PAGE_SHAPED) {
4749+
expect(describeOf(schema), label).toContain('visible truncation signal');
4750+
}
4751+
});
4752+
4753+
it('leaves the non-grid four WITHOUT an authorable ceiling (objectui#7210 keeps theirs)', () => {
4754+
// The card scopes those four out by name: their rows are capped by a
4755+
// platform constant the renderer owns, because a gantt range, a map camera
4756+
// fit and a tree parent chain are computed over the whole set. An
4757+
// authorable ceiling there would be surface no renderer reads.
4758+
const NON_GRID_FOUR = [
4759+
['gantt', GanttConfigSchema as unknown as z.ZodTypeAny],
4760+
['calendar', CalendarConfigSchema as unknown as z.ZodTypeAny],
4761+
['map', ListMapConfigSchema as unknown as z.ZodTypeAny],
4762+
['tree', TreeConfigSchema as unknown as z.ZodTypeAny],
4763+
] as const;
4764+
4765+
for (const [label, schema] of NON_GRID_FOUR) {
4766+
const result = schema.safeParse({ limit: 10 });
4767+
expect(result.success, label).toBe(false);
4768+
const unrecognized = ((result as { error?: z.ZodError }).error?.issues ?? [])
4769+
.find((issue) => issue.code === 'unrecognized_keys');
4770+
expect(JSON.stringify(unrecognized), label).toContain('limit');
4771+
}
4772+
});
4773+
});

‎packages/spec/src/ui/view.zod.ts‎

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1128,6 +1128,99 @@ export const GroupingConfigSchema = lazySchema(() => strictObject({
11281128
+ 'AND-ed into the view filter. Compiled by `compileListViewGroupQuery` / `compileListViewGroupRowsQuery`',
11291129
));
11301130

1131+
/*
1132+
* ---------------------------------------------------------------------------
1133+
* `limit` — the author-settable row ceiling of the page-shaped views (#17393)
1134+
* ---------------------------------------------------------------------------
1135+
*
1136+
* Declared here because the PROTOCOL was the thing that was wrong: two
1137+
* renderers already cap by author choice, and the key they read was never a
1138+
* protocol key. Measured in objectui at `dda8f3815d`:
1139+
*
1140+
* - `ObjectKanban.tsx:573` fetches `$top: schema.limit ?? DEFAULT_KANBAN_LIMIT`
1141+
* (`= 100` at `:84`), and that `limit` is declared in `@object-ui/types`
1142+
* alone (`zod/objectql.zod.ts:1762`, `z.number().int().positive().optional()`);
1143+
* - `ObjectTimeline.tsx:328` fetches `$top: schema.limit ?? DEFAULT_TIMELINE_LIMIT`
1144+
* (`= 100` at `:29`), with `limit` on that component's own props interface
1145+
* (`:129`) and on no published schema at all;
1146+
* - `ObjectGallery.tsx` sends no `$top` and reads no ceiling at all — the
1147+
* unbounded fetch objectui#7390 is ruled to close by reading this key.
1148+
*
1149+
* Consumer-local author-settable keys the protocol never declared are the
1150+
* divergence the contract-first directive forbids, so the knob enters the
1151+
* protocol first and the three spellings come under one declaration (the
1152+
* director seat's amendment of 2026-09-10T11:0xZ on objectui#7390, on the
1153+
* maintainer's principle 「我们的项目以objectstack 协议为准,文档应该以实际实现
1154+
* 为准。协议不正确的应该先修改协议。」).
1155+
*
1156+
* ## Why on the per-view config blocks, and not as a member of the list view
1157+
*
1158+
* The alternative shape — one row ceiling on {@link ListViewShapeSchema}
1159+
* itself — is rejected on three properties of this tree:
1160+
*
1161+
* 1. The base shape ALREADY carries the row-bounding knob every view type
1162+
* reaches: `pagination.pageSize` ({@link PaginationConfigSchema}, default
1163+
* 25). A second base-level row key would leave one view with two
1164+
* base-level row bounds and no declared precedence between them — and the
1165+
* `virtualScroll` tombstone at the bottom of this same shape prescribes
1166+
* `pagination` for exactly that question.
1167+
* 2. A base member is reachable from EVERY `type`, the non-grid four
1168+
* (gantt / calendar / map / tree) included. Their ceiling is a platform
1169+
* constant the renderer owns (objectui#7210) and this card does not touch
1170+
* them, so a base member would publish an authorable ceiling on four view
1171+
* kinds no renderer reads — declared-but-unenforced on the day it lands.
1172+
* 3. The per-kind block is what actually REACHES the renderer: objectui's
1173+
* `ListView` merges `schema.<kind>` into the generated node — its kanban
1174+
* branch spreads the rest of the block flat onto `object-kanban`, so
1175+
* `kanban.limit` lands exactly where `schema.limit` is read — while a
1176+
* base-level key is forwarded into no per-kind node at all.
1177+
*
1178+
* The NAME is `limit` for the same reason: it is the name the consumer already
1179+
* reads, so this declaration absorbs the two consumer-local keys instead of
1180+
* buying a second divergence spelled differently.
1181+
*
1182+
* ⚠️ NOT the kanban LANE's `limit`. objectui's node-level
1183+
* `ObjectKanbanLaneSchema.limit` is a WIP warning threshold that never reaches
1184+
* a query; no lane object exists on this face at all
1185+
* ({@link KanbanConfigSchema}'s `columns` is a list of card FIELD names), so
1186+
* the two cannot be confused here.
1187+
*/
1188+
export const DEFAULT_VIEW_ROW_LIMIT = 100;
1189+
1190+
/** What a page-shaped view's ceiling bounds, per view type. */
1191+
const ROW_LIMIT_SUBJECT = {
1192+
gallery: 'cards the gallery fetches and draws',
1193+
kanban: 'records the board fetches across all its lanes',
1194+
timeline: 'rows the timeline fetches onto its rail',
1195+
} as const;
1196+
1197+
/** The three view configs that cap by AUTHOR choice (not by platform ceiling). */
1198+
type RowLimitView = keyof typeof ROW_LIMIT_SUBJECT;
1199+
1200+
/**
1201+
* The `limit` declaration for one page-shaped view config.
1202+
*
1203+
* The default is APPLIED, not merely described: a `.describe()` naming a
1204+
* default the schema does not apply is a second contract that nothing
1205+
* enforces, and the two drift the first time either is edited. The agreement
1206+
* is pinned from both sides in `view.test.ts` (#17393) — the parsed default is
1207+
* compared against the number the describe text states.
1208+
*
1209+
* ⛔ The truncation signal is the renderer's half and cannot be enforced from
1210+
* here; it is stated in the describe because a bounded-and-silent view reads
1211+
* as complete, which is worse than the unbounded-and-silent one this key
1212+
* replaces — the author needs to know the cap is visible, and the renderer
1213+
* author needs to know it is owed.
1214+
*/
1215+
const rowLimitKey = (view: RowLimitView) =>
1216+
z.number().int().positive().default(DEFAULT_VIEW_ROW_LIMIT).describe(
1217+
`Row ceiling — the most ${ROW_LIMIT_SUBJECT[view]}, sent as the query \`$top\`; default `
1218+
+ `${DEFAULT_VIEW_ROW_LIMIT} when the key is absent. When the ceiling APPLIES (the filtered `
1219+
+ 'set is larger than it), the renderer must show a visible truncation signal saying what is '
1220+
+ 'on screen is not the whole set — a bounded view that looks complete is worse than an '
1221+
+ 'unbounded one.',
1222+
);
1223+
11311224
/**
11321225
* Gallery View Configuration (Airtable-style)
11331226
* Configures card layout for gallery/card views.
@@ -1141,6 +1234,7 @@ export const GalleryConfigSchema = lazySchema(() => strictObject({
11411234
cardSize: z.enum(['small', 'medium', 'large']).default('medium').describe('Card size in gallery view'),
11421235
titleField: z.string().optional().describe('Field to display as card title'),
11431236
visibleFields: z.array(z.string()).optional().describe('Fields to display on card body'),
1237+
limit: rowLimitKey('gallery'),
11441238
}).describe('Gallery/card view configuration'));
11451239

11461240
/**
@@ -1163,6 +1257,7 @@ export const TimelineConfigSchema = lazySchema(() => strictObject({
11631257
),
11641258
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'),
11651259
scale: z.enum(['hour', 'day', 'week', 'month', 'quarter', 'year']).default('week').describe('Default timeline scale'),
1260+
limit: rowLimitKey('timeline'),
11661261
}).describe('Timeline view configuration'));
11671262

11681263
/**
@@ -1466,6 +1561,7 @@ export const KanbanConfigSchema = lazySchema(() => strictObject({
14661561
*/
14671562
titleField: z.string().optional().describe('Field displayed as the card title. Omit to fall back to the record display name (ADR-0079 resolver chain)'),
14681563
columns: z.array(z.string()).describe('Fields to show on cards'),
1564+
limit: rowLimitKey('kanban'),
14691565
}));
14701566

14711567
/**

0 commit comments

Comments
 (0)