diff --git a/.changeset/8365-stray-kanban-groupby-refused.md b/.changeset/8365-stray-kanban-groupby-refused.md new file mode 100644 index 0000000000..692cb11cca --- /dev/null +++ b/.changeset/8365-stray-kanban-groupby-refused.md @@ -0,0 +1,60 @@ +--- +"@object-ui/types": minor +"@object-ui/plugin-list": minor +--- + +**A stray `groupBy` in a view's kanban config no longer overrides the lane, and is now refused by name.** + +`ListView`'s kanban branch destructured +`columns`/`groupByField`/`groupField`/`cardFields`/`titleField` out of the merged +kanban config and spread the **rest** *after* its own `groupBy: laneField`. A +`groupBy` surviving in that bag therefore **overrode the lane the branch had just +resolved**. Measured on a distinguishing fixture, not reasoned: with +`options.kanban = { groupBy: 'LANE_FROM_STRAY_GROUPBY' }` against +`kanban = { groupByField: 'LANE_FROM_CANONICAL' }`, the generated `object-kanban` +node carried `groupBy: 'LANE_FROM_STRAY_GROUPBY'`. It read as latent only because +the one producer that fed it wrote both spellings with the same value by +construction; that producer was retired separately, and this closes the override +itself. + +Two halves, per the maintainer's ruling (option B — a silent re-grouping was the +fallback and was **not** taken): + +1. **The canonical lane wins.** `groupBy` joins the destructure, so the stray key + can no longer reach the passthrough spread. A view that authored it now groups + by whatever `groupByField` / `groupField` / the declared lifecycle field + resolves. +2. **The stray key is refused loudly, at the read door of the view.** This repo's + `.passthrough()` `KanbanConfig` mirror (`@object-ui/types`) declares `groupBy` + as a named alias refusal pointing at `groupByField`, in the same sentence + shape `@objectstack/spec` already answers the sibling alias with — "Unrecognized + key(s) on this kanban configuration: `groupBy`. Did you mean `groupBy` → + `groupByField`?". The refusal lands wherever a view's metadata is validated: + the CLI's `os check` / `os validate`, the VS Code extension, and `tsc` at the + authoring site (the inferred authoring face now carries `groupBy?: never`). + The legacy `options.kanban` nesting — where the retired producer wrote, and so + where stored views carry the key — takes the identical message through a check + on that untyped bag. + +**Breaking, in the sense worth stating explicitly** (shipped `minor`: this repo +never declares `major`, and `.changeset/config.json` puts every package in one +`fixed` group, so levels cannot be split). Two behaviours change for **stored +data**, which is why this is not a patch: + +- a stored view authoring `kanban.groupBy` (either nesting) **re-points its lane** + — it used to group by the stray key and now groups by the canonical binding, so + its board may show different columns; +- the same document now **fails validation** where it used to pass: any pipeline + running `safeValidateSchema` over it (`os check`, `os validate`, the extension) + reports one issue naming the key and the replacement. + +Honouring `groupBy` as a declared alias was never an option here: +`@objectstack/spec`'s `KanbanConfigSchema` is a strict object of +`columns` / `groupByField` / `summarizeField` and refuses it by name — re-measured +on the pinned 17.4.0 with both controls firing — so legalising it would be a spec +change, not a renderer widening (AGENTS.md #0.1). + +`groupBy` on the generated `object-kanban` **node** is untouched: that is the live, +canonical lane key `ObjectKanban` reads. Only the **view-level** kanban config +spelling is refused. The `.passthrough()` itself is kept — an undeclared sibling +key still rides through, which is pinned as a control. diff --git a/packages/app-shell/src/views/ObjectView.kanbanGroupByRetired-8213.test.tsx b/packages/app-shell/src/views/ObjectView.kanbanGroupByRetired-8213.test.tsx index 6fa5143673..ec2176290b 100644 --- a/packages/app-shell/src/views/ObjectView.kanbanGroupByRetired-8213.test.tsx +++ b/packages/app-shell/src/views/ObjectView.kanbanGroupByRetired-8213.test.tsx @@ -44,16 +44,19 @@ * `groupBy`. * * ⚠️ NO ARM HERE ASSERTS THE `...restKanban` OVERRIDE, deliberately. `ListView` - * spreads the rest of the merged kanban config AFTER its own `groupBy: - * laneField`, so a surviving `groupBy` wins over the lane it just resolved. - * That override is real, but it cannot be pinned FROM THIS PRODUCER: every bag - * this producer can build has `groupBy` and `groupByField` holding the same + * spread the rest of the merged kanban config AFTER its own `groupBy: + * laneField`, so a surviving `groupBy` won over the lane it had just resolved. + * That override was real, but it could not be pinned FROM THIS PRODUCER: every + * bag this producer can build has `groupBy` and `groupByField` holding the same * value by construction, so an override row driven by it passes with or without - * the fix — a test that cannot fail. Distinguishing the two spellings needs a + * the fix — a test that cannot fail. Distinguishing the two spellings needed a * hand-built `plugin-list` fixture (`options.kanban.groupBy: 'a'` against - * `kanban.groupByField: 'b'`), which is a `plugin-list` change on its own card. - * What this deletion does is remove the only producer in this repo that fed the - * override; the override itself is untouched. + * `kanban.groupByField: 'b'`), which was a `plugin-list` change on its own card. + * ⭐ THAT FIXTURE NOW EXISTS and the override is closed — objectui#8365, + * `plugin-list/src/__tests__/ListView.strayGroupByRefused-8365.test.tsx`. What + * THIS deletion did remains what it always was: it removed the only producer in + * this repo that fed the override. The arms below are unchanged by that card, + * because they measure the producer, not the render branch. * * REVERSE VERIFICATION — direction predicted before running, then observed: * restore `{ groupBy: lane, groupByField: lane }` in `kanbanViewOptions` and diff --git a/packages/app-shell/src/views/ObjectView.tsx b/packages/app-shell/src/views/ObjectView.tsx index 5c7619010b..6e5d06f30d 100644 --- a/packages/app-shell/src/views/ObjectView.tsx +++ b/packages/app-shell/src/views/ObjectView.tsx @@ -412,14 +412,20 @@ export function galleryViewOptions(viewDef: any): Record { * nestings), and neither does the render branch (`groupByField || groupField || * detectStatusField(...)`). * - * ⚠️ WHAT THIS CLOSES AND WHAT IT DOES NOT. `ListView`'s kanban branch - * destructures `columns`/`groupByField`/`groupField`/`cardFields`/`titleField` - * out of the merged config and spreads the REST *after* its own - * `groupBy: laneField`, so a surviving `groupBy` overrides the lane it just - * resolved. This deletion removes the only producer in this repo that fed that - * override — it does NOT remove the override, which stays reachable from - * author-written `kanban.groupBy` riding this repo's `.passthrough()` mirror - * and is a `plugin-list` change on its own card. + * ⚠️ WHAT THIS CLOSED AND WHAT IT DID NOT — and what has since closed the rest. + * `ListView`'s kanban branch destructured + * `columns`/`groupByField`/`groupField`/`cardFields`/`titleField` out of the + * merged config and spread the REST *after* its own `groupBy: laneField`, so a + * surviving `groupBy` overrode the lane it had just resolved. This deletion + * removed the only producer in this repo that fed that override; the override + * itself stayed reachable from an author-written `kanban.groupBy` riding this + * repo's `.passthrough()` mirror, and was carried on its own card. + * ⭐ THAT CARD HAS LANDED (objectui#8365, maintainer ruling of 2026-09-12 — + * decision batch #117 item 5, option B): `groupBy` is now stripped in + * `ListView`'s destructure, so the canonical lane wins, AND the view-level + * `KanbanConfig` mirror (`@object-ui/types`, `zod/objectql.zod.ts`) declares it + * as an alias refusal naming `groupByField`, so the key is refused BY NAME at + * the read door instead of riding the passthrough. ⛔ Do not re-file it. * * ⚠️ `titleField` AND `cardFields` BELOW ARE ALSO OUTSIDE `KanbanConfigSchema`, * and are deliberately NOT swept up here. `cardFields` is a DECLARED deprecated diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx index 853fe7fbfd..dfbbd7a5ca 100644 --- a/packages/plugin-list/src/ListView.tsx +++ b/packages/plugin-list/src/ListView.tsx @@ -2566,7 +2566,25 @@ export const ListView = React.forwardRef(({ // own `columns` prop is its LANES, so passing this through verbatim built // lanes with undefined id/title. Map it to `cardFields` and strip the // vocabulary keys from the passthrough (mirrors plugin-view's adapter). - const { columns: kanbanCardColumns, groupByField, groupField, cardFields, titleField, ...restKanban } = kanbanCfg as Record; + // ⭐ `groupBy` IS STRIPPED HERE (objectui#8365, maintainer ruling of + // 2026-09-12 — decision batch #117 item 5, option B). It is a THIRD + // spelling of the lane, and because it was NOT in this destructure it + // survived into `restKanban`, which the return below spreads AFTER its + // own `groupBy: laneField` — so an authored `kanban.groupBy` OVERRODE + // the lane this branch had just resolved. Measured on the card's + // distinguishing fixture (`options.kanban = { groupBy: + // 'LANE_FROM_STRAY_GROUPBY' }` against `kanban = { groupByField: + // 'LANE_FROM_CANONICAL' }`): the generated node carried + // `groupBy: 'LANE_FROM_STRAY_GROUPBY'`. Stripping it is the half that + // makes the CANONICAL lane win; the loud half is the read door, where + // the view-level `KanbanConfig` mirror (`@object-ui/types`, + // `zod/objectql.zod.ts`) now declares `groupBy` as an alias refusal and + // names `groupByField`, so the key is no longer silently accepted by + // that object's `.passthrough()`. + // ⛔ Deliberately NOT folded onto `laneField`: this branch's own read is + // already canonical-first (`groupByField || groupField || detect…`), so + // a fold would re-create the override it just closed. + const { columns: kanbanCardColumns, groupByField, groupField, cardFields, titleField, groupBy: _strayGroupBy, ...restKanban } = kanbanCfg as Record; const laneField = groupByField || groupField || detectStatusField(objectDef) || undefined; // `groupBy` is the lane key and the ONLY one written here. This node // used to carry `groupField: laneField` alongside it — a duplicate the diff --git a/packages/plugin-list/src/__tests__/ListView.strayGroupByRefused-8365.test.tsx b/packages/plugin-list/src/__tests__/ListView.strayGroupByRefused-8365.test.tsx new file mode 100644 index 0000000000..3144025415 --- /dev/null +++ b/packages/plugin-list/src/__tests__/ListView.strayGroupByRefused-8365.test.tsx @@ -0,0 +1,298 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#8365 — a surviving `groupBy` in the kanban config OVERRODE the lane + * `ListView` had just resolved. + * + * `ListView`'s kanban branch destructured + * `columns`/`groupByField`/`groupField`/`cardFields`/`titleField` out of the + * merged config and spread the REST *after* its own `groupBy: laneField`. So an + * authored `kanban.groupBy` rode the `.passthrough()` `KanbanConfig` mirror into + * `restKanban` and won over the canonical `groupByField` the branch had already + * resolved — the board grouped by the stray key, and nothing said so. + * + * ## THE DISTINGUISHING FIXTURE, and why it had to be hand-built + * + * The override was measured as ACTIVE, not latent. It read as latent only + * because the one producer that fed it (`app-shell`'s `kanbanViewOptions`, + * retired by objectui#8213) wrote both spellings with the SAME value by + * construction — so no fixture driven by that producer could tell them apart, + * and an override row driven by it was a test that cannot fail. The two + * spellings therefore carry two DIFFERENT lane names here, which is the whole + * reason this file exists: + * + * options.kanban = { groupBy: 'LANE_FROM_STRAY_GROUPBY' } + * kanban = { groupByField: 'LANE_FROM_CANONICAL' } + * + * ## THE RULING, and the two halves it asks for + * + * Maintainer ruling of 2026-09-12 (decision batch #117 item 5, verbatim + * 「8365 同意」) — option B. Option A (strip the key and re-group in silence) + * was the fallback for a measured zero of stored views and was NOT taken. + * Honouring `groupBy` as a declared alias was never on the table: the protocol + * refuses it BY NAME (see the control below), and legalising it would be a spec + * change on its own `objectstack` card, never a renderer-side widening + * (AGENTS.md #0.1). + * + * 1. THE CANONICAL LANE WINS — `ListView` adds `groupBy` to the destructure, + * so the stray key can no longer reach `restKanban`. Asserted on the + * GENERATED `object-kanban` node, through a registry spy, because that node + * is the only place the override was ever observable. + * 2. THE STRAY KEY IS REFUSED LOUDLY, at the READ DOOR of the view and in the + * sentence shape the platform contract already uses for the sibling alias. + * That door is the view-level `KanbanConfig` mirror in `@object-ui/types` + * (`zod/objectql.zod.ts`), reached here through the published + * `safeValidateSchema` — the same entry point the CLI's `os check` / + * `os validate` and the VS Code extension run, i.e. where the AUTHOR of the + * view is standing. ⛔ Deliberately NOT a `console.warn`: the ruling names + * that outcome and refuses it. + * + * ⚠️ TWO NESTINGS, and the second one is load-bearing rather than thorough. + * `ListView` merges `{ ...options.kanban, ...kanban }`, and the producer + * objectui#8213 retired wrote into `options.kanban` — so that is where the + * stored views this ruling is ABOUT carry the key. `options` is + * `z.record(z.string(), z.any())` and can declare no member, so it takes the + * declared arm's guidance as a CHECK (`custom`) while the declared `kanban` + * slot reports the arm itself (`invalid_type`). Two codes, ONE message — + * asserted below, because a refusal that only reached the declared nesting + * would leave exactly the affected population silently re-grouped, i.e. + * option A wearing option B's name. + * + * ## THE CONTROLS, and what each one would catch + * + * - DARK CONTROL (`groupByField` alone) — parses GREEN through the same door. + * Without it, "the door refuses the fixture" is satisfied by a door that + * refuses everything. + * - PASSTHROUGH CONTROL (an undeclared `zzzBogusKey`) — still parses GREEN. + * `KanbanConfig` stays `.passthrough()` for renderer-ahead knobs + * (`swimlaneField` is the live one); this card declared exactly ONE named + * refusal arm and did not close the object. Without this arm a later + * `.strict()` would satisfy every other assertion here. + * - PROTOCOL CONTROL — `@objectstack/spec`'s own `KanbanConfigSchema` refuses + * `groupBy` by name, with a lit control (`zzzBogusKey`) firing and a dark + * control (`groupByField` alone) drawing no `unrecognized_keys`. This is what + * makes "the mirror stopped being more permissive than the spec" a reading + * rather than a claim, and it reddens first if a later spec bump ever DECLARES + * `groupBy`. + * - LANE CONTROL (`groupBy` alone, no canonical key) — the board falls back to + * the detector / no lane, and does NOT silently keep grouping by the stray + * key. This is the arm that tells option B apart from "strip it and hope". + * + * REVERSE VERIFICATION — direction predicted before running, then observed: + * remove `groupBy` from the destructure in `ListView.tsx` and the lane arms go + * RED naming `LANE_FROM_STRAY_GROUPBY`, while every refusal arm stays GREEN; + * remove the `groupBy` arm from `KanbanConfig` and the refusal arms go RED while + * the lane arms stay GREEN. Two independent halves, two independent ablations — + * the asymmetry is what proves neither arm is carrying the other. + */ + +import React from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, waitFor } from '@testing-library/react'; +import { ComponentRegistry } from '@object-ui/core'; +import { SchemaRendererProvider } from '@object-ui/react'; +import { safeValidateSchema } from '@object-ui/types/zod'; +import { KanbanConfigSchema } from '@objectstack/spec/ui'; +import { ListView } from '../ListView'; + +const OBJECT = 'deal'; + +/** The two lane names the fixture holds apart. */ +const STRAY = 'LANE_FROM_STRAY_GROUPBY'; +const CANONICAL = 'LANE_FROM_CANONICAL'; + +const objectDef = { + name: OBJECT, + label: 'Deal', + fields: { + id: { name: 'id', type: 'text', label: 'Id' }, + name: { name: 'name', type: 'text', label: 'Name' }, + [CANONICAL]: { name: CANONICAL, type: 'text', label: 'Canonical lane' }, + [STRAY]: { name: STRAY, type: 'text', label: 'Stray lane' }, + }, +}; + +/** Every `object-kanban` node the renderer generated, in order. */ +let kanbanNodes: Array> = []; + +ComponentRegistry.register( + 'object-kanban', + (props: Record) => { + kanbanNodes.push(props.schema); + return
; + }, + { namespace: 'test', label: 'Kanban spy', category: 'view' }, +); + +const makeDataSource = () => + ({ + find: vi.fn(async () => []), + findOne: vi.fn(async () => null), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + count: vi.fn(async () => 0), + getObjectSchema: vi.fn(async () => objectDef), + getObjects: vi.fn(async () => []), + onMutation: () => () => {}, + }) as any; + +/** Mount `ListView` on a view and return the last generated `object-kanban` node. */ +async function generatedKanbanNode(view: Record) { + const dataSource = makeDataSource(); + render( + + + , + ); + await waitFor(() => expect(kanbanNodes.length).toBeGreaterThan(0)); + return kanbanNodes[kanbanNodes.length - 1]; +} + +/** + * THE FIXTURE, built once and used by BOTH halves — the lane assertions read it + * through the renderer, the refusal assertions read it through the read door, so + * neither half can drift onto a different document than the other. + */ +const DISTINGUISHING_VIEW = { + type: 'list-view', + objectName: OBJECT, + viewType: 'kanban', + options: { kanban: { groupBy: STRAY } }, + kanban: { groupByField: CANONICAL }, +} as const; + +/** Issues the read door raises at `kanban.groupBy`, if any. */ +const refusalIssuesFor = (doc: unknown) => { + const result = safeValidateSchema(doc); + if (result.success) return []; + return result.error.issues.filter((i) => i.path.join('.').endsWith('kanban.groupBy')); +}; + +beforeEach(() => { + kanbanNodes = []; +}); + +describe('objectui#8365 · half 1 — the CANONICAL lane wins on the generated node', () => { + it('the distinguishing fixture resolves the lane from `groupByField`, not the stray `groupBy`', async () => { + const node = await generatedKanbanNode(DISTINGUISHING_VIEW); + // Before the fix this read `LANE_FROM_STRAY_GROUPBY`: the `...restKanban` + // spread landed after the branch's own `groupBy: laneField`. + expect(node.groupBy).toBe(CANONICAL); + expect(node.groupBy).not.toBe(STRAY); + }); + + it('the stray key does not reach the generated node under ANY spelling', async () => { + const node = await generatedKanbanNode(DISTINGUISHING_VIEW); + // The node's `groupBy` IS the live lane key `ObjectKanban` reads, so the + // assertion is about its VALUE above. This one is about the stray value: + // it must appear nowhere on the node, including under a key the merge might + // have carried it through. + expect(Object.values(node)).not.toContain(STRAY); + }); + + it('CONTROL: the declared `kanban.groupByField` still resolves the lane on its own', async () => { + const node = await generatedKanbanNode({ kanban: { groupByField: CANONICAL } }); + expect(node.groupBy).toBe(CANONICAL); + }); + + it('CONTROL: the legacy `kanban.groupField` alias still resolves the lane', async () => { + // The VIEW-LEVEL legacy alias is LIVE and untouched by this card — only the + // third spelling is refused. Without this arm the fix could have narrowed + // the alias read as well and nothing here would notice. + const node = await generatedKanbanNode({ kanban: { groupField: CANONICAL } }); + expect(node.groupBy).toBe(CANONICAL); + }); + + it('LANE CONTROL: `groupBy` alone no longer sets the lane — option B, not a silent re-grouping', async () => { + const node = await generatedKanbanNode({ options: { kanban: { groupBy: STRAY } } }); + expect(node.groupBy).not.toBe(STRAY); + }); +}); + +describe('objectui#8365 · half 2 — the stray key is REFUSED at the read door', () => { + it('the distinguishing fixture is refused BY NAME, pointing at `groupByField`', () => { + const issues = refusalIssuesFor(DISTINGUISHING_VIEW); + expect(issues).toHaveLength(1); + // ⭐ THE LEGACY NESTING. `options` is `z.record(z.string(), z.any())` and can + // declare no MEMBER, so the refusal there is a CHECK — `custom`, not + // `invalid_type`. Two codes, ONE message (the check reads the declared arm's + // own `.description`), which is why the message assertions below are shared + // between the two nestings while the code assertions are not. + expect(issues[0].code).toBe('custom'); + expect(issues[0].path.join('.')).toBe('options.kanban.groupBy'); + // The lead sentence is the one the protocol's own `strictObject({ aliases })` + // answers with, so an author meets ONE remedy on both faces. + expect(issues[0].message).toContain('Unrecognized key(s) on this kanban configuration: `groupBy`.'); + expect(issues[0].message).toContain('Did you mean `groupBy` → `groupByField`?'); + }); + + it('the DECLARED nesting is refused with the SAME message, as a declared member', () => { + const declared = refusalIssuesFor({ + type: 'list-view', + objectName: OBJECT, + kanban: { groupByField: CANONICAL, groupBy: STRAY }, + }); + expect(declared).toHaveLength(1); + // `aliasKeyRefusal` is a `z.never()` ARM here, so the envelope is + // `invalid_type` at the key's own path. + expect(declared[0].code).toBe('invalid_type'); + expect(declared[0].path.join('.')).toBe('kanban.groupBy'); + // ⭐ ONE STRING, BOTH NESTINGS — the assertion that keeps the check and the + // arm from drifting into two dialects of one remedy. + const legacy = refusalIssuesFor(DISTINGUISHING_VIEW); + expect(declared[0].message).toBe(legacy[0].message); + }); + + it('DARK CONTROL: the canonical config alone parses GREEN through the same door', () => { + const result = safeValidateSchema({ + type: 'list-view', + objectName: OBJECT, + kanban: { groupByField: CANONICAL }, + }); + expect(result.success).toBe(true); + }); + + it('PASSTHROUGH CONTROL: an undeclared sibling key still rides through GREEN', () => { + // `KanbanConfig` keeps `.passthrough()` for renderer-ahead knobs. This card + // declared ONE named refusal arm; it did not close the object. + const result = safeValidateSchema({ + type: 'list-view', + objectName: OBJECT, + kanban: { groupByField: CANONICAL, zzzBogusKey: 'still accepted' }, + }); + expect(result.success).toBe(true); + }); +}); + +describe('objectui#8365 · the protocol is what this mirror is aligning to', () => { + /** Keys `@objectstack/spec`'s strict `KanbanConfigSchema` names as refused. */ + const specRefusedKeys = (cfg: Record) => { + const r = KanbanConfigSchema.safeParse(cfg); + if (r.success) return []; + return r.error.issues.flatMap((i) => ((i as { keys?: string[] }).keys ?? [])); + }; + + it('PROTOCOL CONTROL: the spec refuses `groupBy` BY NAME, with both controls firing', () => { + // LIT: a bogus key is named. DARK: the canonical config draws no + // `unrecognized_keys` at all. Between them, the middle row is a reading. + expect(specRefusedKeys({ groupByField: 'stage', columns: ['name'], zzzBogusKey: 1 })).toContain('zzzBogusKey'); + expect(specRefusedKeys({ groupByField: 'stage', columns: ['name'] })).toEqual([]); + expect(specRefusedKeys({ groupByField: 'stage', columns: ['name'], groupBy: 'stage' })).toContain('groupBy'); + }); + + it('PROTOCOL CONTROL: `groupBy` is still not a key the spec DECLARES', () => { + // If a later spec bump ever declares it, this reddens first and the refusal + // arm above becomes the thing to re-decide — deliberately, on a new card. + expect(Object.keys((KanbanConfigSchema as any).shape)).not.toContain('groupBy'); + }); +}); diff --git a/packages/types/src/__tests__/kanban-stray-group-by-refusal-8365.test.ts b/packages/types/src/__tests__/kanban-stray-group-by-refusal-8365.test.ts new file mode 100644 index 0000000000..bc585b17ce --- /dev/null +++ b/packages/types/src/__tests__/kanban-stray-group-by-refusal-8365.test.ts @@ -0,0 +1,92 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#8365 — `kanban.groupBy` is a DECLARED REFUSAL on this mirror, on both + * faces. + * + * The runtime half and the whole reading live in `plugin-list`'s + * `ListView.strayGroupByRefused-8365.test.tsx`, beside the render branch the + * refusal protects. THIS file exists for the half that file cannot carry: the + * TYPE face. + * + * ⭐ WHY IT IS A SEPARATE FILE, and why that is not duplication. `z.never()` at a + * member position makes `z.input` of that member `undefined`, so the inferred + * authoring face carries `groupBy?: never` and `tsc` refuses the key at the + * AUTHORING SITE — before anything runs. A pin on that face is a compile-time + * assertion: vitest erases it, and a green vitest run says nothing whatsoever + * about it. The gate that enforces it is `tsc`, and in this package that is + * `pnpm --filter @object-ui/types type-check`, whose `tsconfig.test.json` + * compiles exactly this directory. ⛔ Do not "verify" the arms below by running + * vitest over them. + * + * ⚠️ `@ts-expect-error` is the assertion, not a suppression: if a later edit + * makes `kanban: { groupBy }` compile again, tsc reports the directive itself as + * unused and this file goes RED. That is the failure mode the pin is for. + * + * REVERSE VERIFICATION — direction predicted before running, then observed: + * remove the `groupBy` arm from `KanbanConfig` and `tsc -p tsconfig.test.json` + * goes RED here with TS2578 ("Unused '@ts-expect-error' directive"), while the + * two positive controls below stay silent in both worlds. + */ + +import { describe, it, expect } from 'vitest'; +import { ListViewSchema } from '../zod/objectql.zod'; +import type { ListViewInferred } from '../zod/objectql.zod'; + +/** COMPILE-TIME PIN. Erased before vitest runs — `tsc` is the only gate here. */ +const strayGroupByIsRefusedByTsc: ListViewInferred = { + type: 'list-view', + objectName: 'deal', + kanban: { + groupByField: 'stage', + // @ts-expect-error objectui#8365 — `groupBy` is a declared refusal on + // `KanbanConfig`; write `groupByField` (above) or the deprecated + // `groupField`. Removing the arm makes this directive unused → TS2578. + groupBy: 'stage', + }, +}; + +/** POSITIVE CONTROL: the canonical key compiles, so the pin is not "kanban is unwritable". */ +const canonicalCompiles: ListViewInferred = { + type: 'list-view', + objectName: 'deal', + kanban: { groupByField: 'stage' }, +}; + +/** POSITIVE CONTROL: the live legacy alias still compiles — this card narrowed ONE key. */ +const legacyAliasCompiles: ListViewInferred = { + type: 'list-view', + objectName: 'deal', + kanban: { groupField: 'stage' }, +}; + +describe('objectui#8365 · the type face refuses `kanban.groupBy`', () => { + it('the compile-time fixtures above are real program inputs', () => { + // The arms that matter are the `@ts-expect-error` and the two controls, and + // they are checked by `tsc`, not here. This runtime arm exists so the file + // is not an empty suite and so the fixtures cannot be dropped as unused. + expect(canonicalCompiles.kanban).toEqual({ groupByField: 'stage' }); + expect(legacyAliasCompiles.kanban).toEqual({ groupField: 'stage' }); + expect((strayGroupByIsRefusedByTsc.kanban as Record).groupBy).toBe('stage'); + }); + + it('and the runtime door refuses the same key, so the two faces agree', () => { + const result = ListViewSchema.safeParse({ + type: 'list-view', + objectName: 'deal', + kanban: { groupByField: 'stage', groupBy: 'stage' }, + }); + expect(result.success).toBe(false); + const issue = result.success + ? undefined + : result.error.issues.find((i) => i.path.join('.') === 'kanban.groupBy'); + expect(issue?.code).toBe('invalid_type'); + expect(issue?.message).toContain('Did you mean `groupBy` → `groupByField`?'); + }); +}); diff --git a/packages/types/src/__tests__/list-view-spec-parity.test.ts b/packages/types/src/__tests__/list-view-spec-parity.test.ts index 6f48fcbeef..21df5fbb67 100644 --- a/packages/types/src/__tests__/list-view-spec-parity.test.ts +++ b/packages/types/src/__tests__/list-view-spec-parity.test.ts @@ -177,7 +177,12 @@ describe('ListView spec parity (#2231 drift guard)', () => { */ describe('per-view-type configs derive from the spec', () => { const CONFIGS = { - kanban: { spec: SpecKanbanConfigSchema, local: ['groupField', 'cardFields'] }, + // `groupBy` is local and DECLARED, but it is not a writable member: it is + // the objectui#8365 alias-refusal arm (`aliasKeyRefusal`), declared exactly so + // the key is refused BY NAME instead of riding this mirror's `.passthrough()`. + // It belongs on this list because the list asks which keys the mirror + // declares beyond the spec — declaring a refusal is still declaring. + kanban: { spec: SpecKanbanConfigSchema, local: ['groupField', 'cardFields', 'groupBy'] }, calendar: { spec: SpecCalendarConfigSchema, local: ['defaultView'] }, gantt: { spec: SpecGanttConfigSchema, local: [] }, gallery: { spec: SpecGalleryConfigSchema, local: ['imageField'] }, diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index e92e66eb84..54fedd4183 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -40,7 +40,7 @@ import { DashboardWidgetSchema as SpecDashboardWidgetSchema, } from '@objectstack/spec/ui'; import { BaseSchema, specFieldsExcept } from './base.zod.js'; -import { handlerKeyRefusal, retirementTombstone } from './tombstone.zod.js'; +import { aliasKeyRefusal, handlerKeyRefusal, retirementTombstone } from './tombstone.zod.js'; import { DrillDownConfigSchema } from './data-display.zod.js'; // The kanban CARD vocabulary has one authority (`./complex.zod.ts`); the // `object-kanban` lane below reads it rather than restating it (objectui#8913). @@ -569,11 +569,99 @@ const LIST_VIEW_LOCAL_OVERRIDES = [ // absent from the spec's `KanbanConfigSchema` / `TimelineConfigSchema`, so these // `.passthrough()`s still carry real authored values — // `core/src/utils/__tests__/normalize-list-view.test.ts` pins exactly those two. +// ALIAS REFUSAL — THE READ DOOR FOR A STORED VIEW'S KANBAN CONFIG +// (objectui#8365, maintainer ruling of 2026-09-12, decision batch #117 item 5: +// option B, 「8365 同意」). +// +// `groupBy` is a THIRD spelling of the lane the spec names `groupByField` and +// this mirror's own `groupField` aliases. It was never declared here — and +// because this object ends `.passthrough()`, an undeclared key is not +// dropped, it is KEPT. That is the whole defect: the surviving key rode the +// bag into `ListView`'s kanban branch, whose `...restKanban` spread lands +// AFTER its own `groupBy: laneField`, so an authored `kanban.groupBy` +// OVERRODE the lane the branch had just resolved from `groupByField`. +// Measured on the card's distinguishing fixture, not reasoned: +// `options.kanban = { groupBy: 'LANE_FROM_STRAY_GROUPBY' }` against +// `kanban = { groupByField: 'LANE_FROM_CANONICAL' }` produced +// `node.groupBy === 'LANE_FROM_STRAY_GROUPBY'`. +// +// ⛔ HONOURING IT AS A DECLARED ALIAS IS NOT AVAILABLE. `@objectstack/spec`'s +// `KanbanConfigSchema` is a `strictObject` of exactly +// `columns` / `groupByField` / `summarizeField` and refuses `groupBy` BY NAME. +// Re-measured at implementation time on the version this tree pins +// (17.4.0 — the card measured 17.3.0), with BOTH controls firing: a lit +// control (`zzzBogusKey` alongside a valid `groupByField`) draws +// `unrecognized_keys` naming the bogus key, and a dark control +// (`groupByField` alone) draws none. Upstream knows the SIBLING alias by name +// — probing `groupField` answers "Did you mean `groupField` → `groupByField`?" +// — and knows nothing at all about `groupBy`, which it refuses as a plain +// unrecognized key. Legalising it is a spec change on its own objectstack +// card, ⛔ never a renderer-side widening (AGENTS.md #0.1). +// +// ⇒ the key is DECLARED and unwritable, so it is refused BY NAME instead of +// riding the passthrough in silence, and this mirror stops being more +// permissive than the protocol it mirrors. The lead sentence is the one the +// spec's own `strictObject({ aliases })` answers with (surface noun quoted +// verbatim from the measurement above), so an author meets ONE remedy on both +// faces. `z.input` is `undefined`, so the inferred TypeScript face carries +// `groupBy?: never` and `tsc` refuses it at the authoring site too. +// +// ⛔ NOT A FOLD onto `groupByField`. A fold is only honest where the canonical +// key is the READER's first limb; here the reader's first limb IS canonical +// (`groupByField || groupField || detectStatusField(...)`), so folding the +// alias in would re-create the very override this refusal closes. +// ⛔ NOT the `groupField` / `cardFields` treatment above either: those two are +// deprecated aliases the SPEC also models under its canonical names and +// `normalize-list-view.ts` folds forward; `groupBy` is modelled nowhere and +// folds nowhere. +// +// ⚠️ NODE-LOCAL vs VIEW-LEVEL, the distinction this file has to keep straight: +// this arm is the VIEW-LEVEL `kanban` config. `groupBy` on the generated +// `object-kanban` NODE is the live, canonical lane key that `ObjectKanban` +// reads — untouched, and deliberately so. +const KanbanStrayGroupByRefusal = aliasKeyRefusal( + 'groupBy', + 'groupByField', + 'this kanban configuration', + '`groupBy` is the lane key of the generated `object-kanban` NODE, not of the view-level ' + + 'kanban configuration (objectui#8365). `@objectstack/spec`\'s `KanbanConfigSchema` is a ' + + 'strict object of `columns` / `groupByField` / `summarizeField` and refuses `groupBy` by ' + + 'name, so a view carrying it never came through the validated path. Write `groupByField` ' + + '(or the deprecated `groupField`, which folds onto it). Until this refusal the key rode ' + + 'this object\'s `.passthrough()` into `ListView`\'s kanban branch and OVERRODE the lane ' + + 'that branch had already resolved from `groupByField` — the board grouped by the stray ' + + 'key, and nothing said so.', +); + +/** + * WHERE THIS ARM IS INSTALLED — TWO NESTINGS, ONE STRING. + * + * `ListView` merges `{ ...schema.options?.kanban, ...schema.kanban }` before it + * reads anything, so a stored view can carry the stray key under EITHER. The + * declared `kanban` slot takes this arm as a DECLARED MEMBER (`invalid_type` at + * `kanban.groupBy`, and `groupBy?: never` on the inferred TypeScript face). + * The legacy `options` bag is `z.record(z.string(), z.any())` and can declare no + * member at all, so it takes the SAME guidance as a check (`custom` at + * `options.kanban.groupBy`) — see `ListViewSchema.options` below. + * + * ⚠️ Covering the legacy nesting is not optional politeness: the retired + * producer (`app-shell`'s `kanbanViewOptions`, objectui#8213) wrote into + * `options.kanban`, so that is where the stored views this ruling is ABOUT carry + * the key. Refusing only the declared nesting would leave exactly that + * population re-grouped in silence — option A, which the ruling did not take. + * + * ⛔ The two channels take ONE string, read off this arm's own `.description`, + * so the message an author meets cannot depend on which nesting they wrote. + */ + const KanbanConfig = stripImportedDefaults(SpecKanbanConfigSchema).partial().extend({ /** @deprecated legacy alias for the spec's `groupByField` */ groupField: z.string().optional().describe('Deprecated alias for groupByField'), /** @deprecated legacy alias for the spec's `columns` (fields shown on each card) */ cardFields: z.array(z.string()).optional().describe('Deprecated alias for columns'), + // ⭐ The named alias-refusal arm — objectui#8365. Declared above with the + // whole reading; ⛔ do not re-spell the message here, it has ONE source. + groupBy: KanbanStrayGroupByRefusal, }).passthrough(); const CalendarConfig = stripImportedDefaults(SpecCalendarConfigSchema).partial().extend({ @@ -708,7 +796,38 @@ export const ListViewSchema = BaseSchema addRecordViaForm: z.boolean().optional().describe('Add records via form dialog'), addDeleteRecordsInline: z.boolean().optional().describe('Enable inline add/delete'), collapseAllByDefault: z.boolean().optional().describe('Collapse all groups by default'), - options: z.record(z.string(), z.any()).optional().describe('Component overrides (legacy)'), + // THE LEGACY BAG, and the ONE named refusal that reaches into it + // (objectui#8365). Everything in here is `z.any()` and stays that way: this + // is the pre-#2231 "component overrides" escape hatch, not an authoring + // surface the protocol models, and typing it is a much larger question than + // this card. ⚠️ But `ListView` merges `{ ...options.kanban, ...kanban }` + // before it reads anything, and the retired producer objectui#8213 removed + // wrote the stray `groupBy` into THIS nesting — so the stored views the + // objectui#8365 ruling is about carry it here. A refusal that covered only + // the declared `kanban` slot would leave exactly that population silently + // re-grouped, which is option A; the ruling took option B. + // + // A record can declare no MEMBER, so this is a check rather than an arm: + // same guidance string, read off {@link KanbanStrayGroupByRefusal}'s own + // `.description` so the two channels cannot drift, reported as `custom` at + // `options.kanban.groupBy` (the declared slot reports `invalid_type` at + // `kanban.groupBy` — two codes, one message, and the pin asserts both). + // ⛔ Scoped to the ONE key: no other member of `options.kanban`, and nothing + // else under `options`, is judged here. + options: z.record(z.string(), z.any()) + .check((ctx) => { + const bag = ctx.value as Record | undefined; + const kanban = bag?.kanban; + if (!kanban || typeof kanban !== 'object' || Array.isArray(kanban)) return; + if ((kanban as Record).groupBy === undefined) return; + ctx.issues.push({ + code: 'custom', + message: KanbanStrayGroupByRefusal.description as string, + input: (kanban as Record).groupBy, + path: ['kanban', 'groupBy'], + }); + }) + .optional().describe('Component overrides (legacy)'), operations: z.object({ create: z.boolean().optional(), read: z.boolean().optional(),