Skip to content

Commit 3e63df4

Browse files
os-billclaude
andcommitted
spec(ui): constrain object-grid / object-calendar sort to the SortItem array
`ComponentPropsMap` declared `sort` as `z.unknown()` on both blocks, so an array, the legacy string clause and a bare number all returned `success: true` while `bogusProp` was refused by name on the same call. objectui#8221 decision batch #77 (2026-09-07, option B) rules one `sort` spelling platform-wide — the array — and its item 4 names these two doors as the spec-side half. Both now declare `z.array(SortItemSchema)`, the shared schema `ElementDataSourceSchema.sort`, `ListPageSchema.sort` and `element:record_picker` already import. `record:related_list`'s string arm is deliberately untouched: it is the `'field'` / `'-field'` dialect read by `RelatedList.normalizeSortSpec`, never reaching `convertSortToQueryParams`, and retiring it was not ruled. Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH Co-authored-by: Claude <noreply@anthropic.com>
1 parent d57611d commit 3e63df4

2 files changed

Lines changed: 160 additions & 2 deletions

File tree

packages/spec/src/ui/component.test.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2007,6 +2007,108 @@ describe('the four `object-*` `filter` doors — one filter orthography platform
20072007
});
20082008
});
20092009

2010+
describe('`object-grid` / `object-calendar` `sort` — one sort orthography, the array (objectui#8221, decision batch #77, option B)', () => {
2011+
const SORT_DOORS = ['object-grid', 'object-calendar'] as const;
2012+
const ARRAY_FORM = [{ field: 'created_at', order: 'desc' }];
2013+
/**
2014+
* The legacy OData-ish clause `convertSortToQueryParams` honours at the
2015+
* objectui pin `53ded82b` (`core/src/utils/sort-query.ts:66-70`) and that
2016+
* `ObjectGrid.tsx:1845-1846` puts on `$orderby` verbatim. Retired by the
2017+
* ruling; refused here.
2018+
*/
2019+
const STRING_FORM = 'created_at desc';
2020+
type ParseResult = { success: boolean; data?: { sort?: unknown }; error?: { issues: Array<{ path: PropertyKey[]; code: string }> } };
2021+
type Door = { shape?: Record<string, unknown>; safeParse: (v: unknown) => ParseResult };
2022+
const door = (type: string) => ComponentPropsMap[type as keyof typeof ComponentPropsMap] as unknown as Door;
2023+
const issuesAtPath = (r: ParseResult, path: string) =>
2024+
r.success ? [] : r.error!.issues.filter((i) => i.path.join('.') === path);
2025+
2026+
it.each(SORT_DOORS)('%s accepts a SortItem[] and echoes it — the acceptance criterion', (type) => {
2027+
const r = door(type).safeParse({ objectName: 'showcase_task', sort: ARRAY_FORM });
2028+
expect(r.success).toBe(true);
2029+
expect(r.data!.sort).toEqual(ARRAY_FORM);
2030+
});
2031+
2032+
it.each(SORT_DOORS)('%s carries the REAL SortItemSchema, not a lookalike: the direction enum and the required pair are checked', (type) => {
2033+
// `z.unknown()` echoed every one of these back with `success: true`.
2034+
const spelledOut = door(type).safeParse({ objectName: 'showcase_task', sort: [{ field: 'created_at', order: 'descending' }] });
2035+
expect(issuesAtPath(spelledOut, 'sort.0.order').map((i) => i.code)).toEqual(['invalid_value']);
2036+
const noDirection = door(type).safeParse({ objectName: 'showcase_task', sort: [{ field: 'created_at' }] });
2037+
expect(issuesAtPath(noDirection, 'sort.0.order').map((i) => i.code)).toEqual(['invalid_type']);
2038+
const noField = door(type).safeParse({ objectName: 'showcase_task', sort: [{ order: 'asc' }] });
2039+
expect(issuesAtPath(noField, 'sort.0.field').map((i) => i.code)).toEqual(['invalid_type']);
2040+
});
2041+
2042+
it.each(SORT_DOORS)('%s REFUSES the legacy string clause at the `sort` path — the shape the ruling retires', (type) => {
2043+
// Reverse verification on the issue envelope: located at `sort`, kind
2044+
// named. Before this change the same value parsed with zero issues on
2045+
// both doors (the card's measurement on `@objectstack/spec` 17.2.0, and
2046+
// the ablation in the landing PR re-runs it against this tree).
2047+
const r = door(type).safeParse({ objectName: 'showcase_task', sort: STRING_FORM });
2048+
expect(r.success).toBe(false);
2049+
const atSort = issuesAtPath(r, 'sort');
2050+
expect(atSort).toHaveLength(1);
2051+
expect(atSort[0].code).toBe('invalid_type');
2052+
expect(atSort[0]).toMatchObject({ expected: 'array' });
2053+
});
2054+
2055+
it.each(SORT_DOORS)('%s REFUSES a bare number at `sort` — the other value `z.unknown()` receipted', (type) => {
2056+
const r = door(type).safeParse({ objectName: 'showcase_task', sort: 3 });
2057+
expect(issuesAtPath(r, 'sort').map((i) => i.code)).toEqual(['invalid_type']);
2058+
});
2059+
2060+
it.each(SORT_DOORS)('%s still refuses an undeclared key BY NAME on the same call — the control the card keeps', (type) => {
2061+
// The control that makes the three readings above verdicts rather than a
2062+
// schema that reports nothing: key checking was never the thing that was
2063+
// missing on these doors, the VALUE was.
2064+
const r = door(type).safeParse({ objectName: 'showcase_task', sort: ARRAY_FORM, bogusProp: 1 });
2065+
expect(r.success).toBe(false);
2066+
expect(issuesAtPath(r, 'sort')).toEqual([]);
2067+
const unrecognized = r.error!.issues.filter((i) => i.code === 'unrecognized_keys') as Array<{ keys?: string[] }>;
2068+
expect(unrecognized.flatMap((i) => i.keys ?? [])).toContain('bogusProp');
2069+
});
2070+
2071+
it('`sort` agrees with `dataSource.sort` and with the picker shorthand — one shape, four doors', () => {
2072+
// The map's own copies are the same import (`SortItemSchema`), so this
2073+
// asks the question the copies could not: do the doors AGREE, value for
2074+
// value, with the binding every data-bound element already carries.
2075+
const viaBinding = ElementDataSourceSchema.parse({ object: 'showcase_task', sort: ARRAY_FORM });
2076+
for (const type of [...SORT_DOORS, 'element:record_picker']) {
2077+
const value = type === 'element:record_picker'
2078+
? { object: 'showcase_task', sort: ARRAY_FORM }
2079+
: { objectName: 'showcase_task', sort: ARRAY_FORM };
2080+
const r = door(type).safeParse(value);
2081+
expect([type, r.success]).toEqual([type, true]);
2082+
expect([type, r.data!.sort]).toEqual([type, viaBinding.sort]);
2083+
const refused = door(type).safeParse({ ...value, sort: STRING_FORM });
2084+
expect([type, issuesAtPath(refused, 'sort').map((i) => i.code)]).toEqual([type, ['invalid_type']]);
2085+
}
2086+
});
2087+
2088+
it('the census: no `sort` door in ComponentPropsMap takes a string except `record:related_list`, whose string is a DIFFERENT dialect and was not ruled', () => {
2089+
// Asked over the WHOLE map by shape rather than by the two names above, so
2090+
// a future entry declaring `sort` as `z.unknown()` is caught here by name.
2091+
// Guarded the same way as its `filter` twin: the doors pinned above must
2092+
// be found, or the shape read has gone wrong and the loop is vacuous.
2093+
const doors = (Object.entries(ComponentPropsMap) as Array<[string, unknown]>)
2094+
.filter(([, schema]) => {
2095+
const shape = (schema as Door).shape;
2096+
return !!shape && 'sort' in shape;
2097+
})
2098+
.map(([type]) => type);
2099+
expect(doors).toEqual(expect.arrayContaining([...SORT_DOORS, 'element:record_picker', 'record:related_list']));
2100+
const stringTakers = doors.filter((type) => issuesAtPath(door(type).safeParse({ sort: STRING_FORM }), 'sort').length === 0);
2101+
// ⚠️ `record:related_list` is the ONE deliberate exception and it is pinned
2102+
// as such, not tolerated: its string is the `'field'` / `'-field'` form
2103+
// read by `RelatedList.normalizeSortSpec`, a different dialect that never
2104+
// reaches `convertSortToQueryParams` — measured by objectui#8221's own
2105+
// implementing round, which narrowed it, established the dialect and then
2106+
// reverted the narrowing byte-identically. Retiring it was not ruled and
2107+
// would delete working, spec-legal behaviour.
2108+
expect(stringTakers).toEqual(['record:related_list']);
2109+
});
2110+
});
2111+
20102112
// ---------------------------------------------------------------------------
20112113
// Interactive Elements — element:text_input
20122114
// ---------------------------------------------------------------------------

packages/spec/src/ui/component.zod.ts

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2485,7 +2485,45 @@ export const ObjectGridPropsSchema = lazySchema(() => strictObject({
24852485
.describe('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 key, singular — not the plural misspelling. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array`'),
24862486
defaultFilters: z.unknown().optional()
24872487
.describe('Legacy base-filter fallback, read only when `filter` is absent. Prefer `filter`'),
2488-
sort: z.unknown().optional().describe('Initial sort (array of { field, order })'),
2488+
/**
2489+
* Initial row order — the `SortItem` ARRAY form, `[{ field, order }, ...]`,
2490+
* the one sort orthography every DECLARED `sort` door on this platform
2491+
* carries: `ElementDataSourceSchema.sort` and `ListPageSchema.sort`
2492+
* (page.zod.ts) and `element:record_picker`'s flat shorthand above. One
2493+
* shared schema rather than a third copy — all of them are
2494+
* `SortItemSchema`, already imported at the top of this file for the picker.
2495+
*
2496+
* objectui#8221, decision batch #77, 2026-09-07, maintainer verbatim
2497+
* 「其他同意」, option B: one `sort` spelling, the array; the legacy string
2498+
* clause is retired from `@object-ui/core`. Item 4 of that ruling is this
2499+
* declaration and `object-calendar`'s below — 「`ComponentPropsMap` for
2500+
* `object-calendar` and `object-grid` constrains the `sort` value to the
2501+
* array shape (today it accepts anything), so the spec, the registrations
2502+
* and the helper agree; that is a pull-back to the declared contract,
2503+
* ordinary tier」.
2504+
*
2505+
* The `z.unknown()` this door carried was a read-point record (#7751), the
2506+
* same vintage as its `filter` neighbour above and not an exception to the
2507+
* ruling: it receipted an array, a string and a bare NUMBER alike with
2508+
* `success: true`, while `plugin-grid/src/index.tsx:222` has published
2509+
* `type: 'array'` all along — so the html tier answered `type-mismatch` on a
2510+
* value this schema had just accepted.
2511+
*
2512+
* Sequenced measurement-first, as this family has to be. Measured at the
2513+
* objectui pin `53ded82b`: `ObjectGrid.tsx:1457` reads `schema.sort` and the
2514+
* fetch path at `:1844-1851` carries an explicit `typeof === 'string'` arm
2515+
* putting the clause on `$orderby` verbatim, beside the array arm that folds
2516+
* `[{ field, order }]` onto the same parameter. ⚠️ At THIS pin the string is
2517+
* therefore still lowered, and this door refuses a spelling the pinned
2518+
* renderer honours — the ruled sequence, not an oversight: objectui#8221's
2519+
* PR #8758 (merged 2026-09-09, after this pin) drops the string arm from
2520+
* `convertSortToQueryParams`, and the next pin bump carries it in. The array
2521+
* is the spelling both ends already agree on today; the header-arrow read at
2522+
* `:3998` hands `schemaSort` to `parseSchemaSort` as `TableSortItem[]`, the
2523+
* array shape and not the string.
2524+
*/
2525+
sort: z.array(SortItemSchema).optional()
2526+
.describe('Initial row order — 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 — objectui#8221 decision batch #77, option B, retired it'),
24892527
/**
24902528
* REMOVED (#11805, maintainer ruling 2026-08-25, decision-inbox batch 4:
24912529
* 「#11805 退役 defaultSort,不需要major」 — the ADR-0049 enforce-or-remove
@@ -2836,7 +2874,25 @@ export const ObjectCalendarPropsSchema = lazySchema(() => strictObject({
28362874
*/
28372875
filter: z.array(ViewFilterRuleSchema).optional()
28382876
.describe('Base query 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`'),
2839-
sort: z.unknown().optional().describe('Sort for the fetched events'),
2877+
/**
2878+
* Row order for the fetched events — the same `SortItem` ARRAY form
2879+
* `object-grid` declares above, and for the same ruling (objectui#8221,
2880+
* decision batch #77, option B; the `object-grid` entry carries the verbatim
2881+
* text). One sort orthography, one shared `SortItemSchema`.
2882+
*
2883+
* Measured at the objectui pin `53ded82b`: `ObjectCalendar.tsx:431` hands
2884+
* `schema.sort` to the shared sink `convertSortToQueryParams`
2885+
* (`core/src/utils/sort-query.ts`) as the fetch's `$orderby`. ⚠️ That sink
2886+
* still honours the legacy string clause at this pin — `sort-query.ts:66-70`
2887+
* — so, exactly as on `object-grid`, this declaration lands ahead of the
2888+
* consumer-side retirement (objectui#8221's PR #8758, merged 2026-09-09) and
2889+
* refuses a spelling the pinned helper still lowers. The array arm is
2890+
* unaffected: the sink folds `[{ field, order }]` into the field-direction
2891+
* map either way. Unlike the grid, `plugin-calendar/src/index.tsx` declares
2892+
* no `sort` input at all, so nothing on the registry side moves.
2893+
*/
2894+
sort: z.array(SortItemSchema).optional()
2895+
.describe('Row order for the fetched events — 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 — objectui#8221 decision batch #77, option B, retired it'),
28402896
data: z.array(z.unknown()).optional().describe('Pre-fetched records — skips the internal fetch'),
28412897
staticData: z.array(z.unknown()).optional().describe('Static inline records'),
28422898
locale: z.string().optional().describe('Locale override for the calendar chrome'),

0 commit comments

Comments
 (0)