Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions .changeset/7322-object-kanban-component-props.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
---
'@object-ui/plugin-kanban': minor
---

`ObjectKanbanComponentProps.schema` names both node types the component is registered for
(objectui#7322 item ②, following the objectui#5903 / #5018 land shape).

`ObjectKanbanRenderer` is registered under two keys — `'object-kanban'` and `'kanban'` —
and the two keys have different declared node types: `ObjectKanbanSchema` (`type:
'object-kanban'`, `objectName` and `groupBy` required) and `KanbanSchema` (`type:
'kanban'`, both optional). The prop named `KanbanSchema` alone, so **no `object-kanban`
node was assignable to the component that renders it**, and the discriminants are disjoint
string literals, so no cast-free annotation existed for half the boards this component
serves. It is now the union of the two.

## What settled it: the read set

`ObjectKanban` reads thirteen keys off `schema`. Neither declaration covers them; the two
TOGETHER cover twelve, and each arm is load-bearing:

- `objectName`, `groupBy`, `limit`, `cardFields` — declared on both;
- `columns`, `cardTitle`, `swimlaneField`, `grouping` — `KanbanSchema` only;
- `titleField` — `ObjectKanbanSchema` only (which is why that read was spelled
`(schema as any).titleField`);
- `data`, `bind`, `className` — `BaseSchema`;
- `filter` — declared by **neither** face, still riding `BaseSchema`'s index signature.
Measured and reported, **not** changed here: this card moves the prop, not the two
published schema faces.

So naming `ObjectKanbanSchema` alone — the remedy the original card implied — would have
been wrong in the other direction: it drops four declared reads and the `'kanban'`
registration.

## Not affected

Widening a member of an exported prop type is additive: every caller that passed a
`KanbanSchema` still compiles, and the union claims exactly the accept set the registry
dispatches to this component — a third node type is still turned away. The runtime is
untouched; `ObjectKanbanRenderer` still takes `schema: any`, so no shape is turned away
there either (the objectui#5903 disposition, restated). The view-level `kanban.groupField`
alias, `BaseSchema`'s index signature, and `@object-ui/types` are all untouched.

## Casts this removes

Inside `ObjectKanban.tsx`, three schema-key reads drop their `as any`: `titleField` (two
sites, now honest because the `object-kanban` arm declares it) and `cardFields` /
`cardTitle` (already declared; the casts were redundant). `(schema as any).navigation`
**stays** — `navigation` is declared on neither face, so removing the cast would change
nothing but the spelling of an index-signature read.

Four of the six in-package fixtures that mount an `object-kanban` board drop their
`as never` escape for a real `satisfies ObjectKanbanSchema`. The other two are static
boards (`columns` + inline `data`, no fetch) that author no `objectName`, which
`ObjectKanbanSchema` declares required — objectui#7780's subject; their casts stay, now
carrying the reason and the card number.
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ import '@testing-library/jest-dom';
import { registerAllFields } from '@object-ui/fields';
import { toast } from '@object-ui/components';
import { normaliseClientError } from '@object-ui/data-objectstack';
import type { DataSource } from '@object-ui/types';
import type { DataSource, ObjectKanbanSchema } from '@object-ui/types';
import { ObjectKanban } from './ObjectKanban';

// Pay the board's lazy chunk at import time rather than racing it against a
Expand Down Expand Up @@ -112,7 +112,7 @@ const schema = {
{ id: 'backlog', title: 'Backlog' },
{ id: 'in_progress', title: 'In Progress' },
],
} as never;
} satisfies ObjectKanbanSchema;

const serverRecords = () => [{ id: 't1', title: CARD, status: 'backlog' }];

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
import React from 'react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react';
import type { ObjectKanbanSchema } from '@object-ui/types';
import { ObjectKanban } from './ObjectKanban';

// Pay the board's lazy chunk at import time, not inside a `findBy` budget
Expand Down Expand Up @@ -84,7 +85,7 @@ async function openDrawer(navigation?: Record<string, unknown>) {
columns: [{ id: 'todo', title: 'To Do' }],
data: cards,
...(navigation ? { navigation } : {}),
} as never}
} satisfies ObjectKanbanSchema}
/>,
);
const card = await screen.findByText('On the board');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,15 @@ function renderKanbanIn(language: string, schemaExtra: Record<string, unknown>)
columns: [{ id: 'todo', title: 'To Do' }],
data: cards,
...schemaExtra,
// ⚠️ Still escaped, and NOT by oversight (objectui#7322 item ②). This
// board is STATIC — `columns` plus inline `data`, no fetch — so it
// authors no `objectName`, which `ObjectKanbanSchema` declares REQUIRED.
// That requiredness is objectui#7780's subject (the renderer reads inline
// `data` ahead of the fetch and never needs the object name for a board
// like this one); until it is answered, no declared type accepts this
// node. The other four in-package fixtures that mount an `object-kanban`
// board dropped their `as never` for a real `satisfies ObjectKanbanSchema`
// in objectui#7322 — when #7780 lands, this one follows.
} as never}
/>
</I18nProvider>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,15 @@ function renderKanban(schemaExtra: Record<string, unknown>) {
columns: [{ id: 'todo', title: 'To Do' }],
data: cards,
...schemaExtra,
// ⚠️ Still escaped, and NOT by oversight (objectui#7322 item ②). This
// board is STATIC — `columns` plus inline `data`, no fetch — so it
// authors no `objectName`, which `ObjectKanbanSchema` declares REQUIRED.
// That requiredness is objectui#7780's subject (the renderer reads inline
// `data` ahead of the fetch and never needs the object name for a board
// like this one); until it is answered, no declared type accepts this
// node. The other four in-package fixtures that mount an `object-kanban`
// board dropped their `as never` for a real `satisfies ObjectKanbanSchema`
// in objectui#7322 — when #7780 lands, this one follows.
} as never}
/>,
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ import { render, screen, within, act, cleanup, waitFor } from '@testing-library/
import '@testing-library/jest-dom';
import { registerAllFields } from '@object-ui/fields';
import { toast } from '@object-ui/components';
import type { DataSource } from '@object-ui/types';
import type { DataSource, ObjectKanbanSchema } from '@object-ui/types';
import { ObjectKanban } from './ObjectKanban';

// Pay the board's lazy chunk at import time rather than racing it against a
Expand Down Expand Up @@ -105,7 +105,7 @@ const schema = {
{ id: 'backlog', title: 'Backlog' },
{ id: 'in_progress', title: 'In Progress' },
],
} as never;
} satisfies ObjectKanbanSchema;

/** Server truth: the card is in `backlog` and the server never moves it. */
const serverRecords = () => [{ id: 't1', title: CARD, status: 'backlog' }];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ import { render, screen, within, act, cleanup, waitFor, fireEvent } from '@testi
import '@testing-library/jest-dom';
import { registerAllFields } from '@object-ui/fields';
import { toast } from '@object-ui/components';
import type { DataSource } from '@object-ui/types';
import type { DataSource, ObjectKanbanSchema } from '@object-ui/types';
import { ObjectKanban } from './ObjectKanban';

// Pay the board's lazy chunk at import time rather than racing it against a
Expand Down Expand Up @@ -127,7 +127,7 @@ const schema = {
{ id: 'negotiation', title: 'Negotiation' },
{ id: 'closed_won', title: 'Closed Won' },
],
} as never;
} satisfies ObjectKanbanSchema;

const records = (winReason: unknown = null) => [
{ id: 'o1', name: DEAL, stage: 'negotiation', win_reason: winReason },
Expand Down
63 changes: 58 additions & 5 deletions packages/plugin-kanban/src/ObjectKanban.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
*/

import React, { useEffect, useState, useMemo } from 'react';
import type { DataSource } from '@object-ui/types';
import type { DataSource, ObjectKanbanSchema } from '@object-ui/types';
import {
useDataScope,
useNavigationOverlay,
Expand Down Expand Up @@ -141,7 +141,60 @@ export function resolveKanbanCardFields(
* no importer breaks. Tripwire: `__tests__/spec-symbol-4650.test.ts`.
*/
export interface ObjectKanbanComponentProps {
schema: KanbanSchema;
/**
* The board node. A UNION of the two declared node types this component is
* registered for (objectui#7322 item ②) — `KanbanSchema` (`type: 'kanban'`)
* and `ObjectKanbanSchema` (`type: 'object-kanban'`).
*
* ## Why a union and not either type alone
*
* `index.tsx` registers ONE component under TWO keys —
* `ComponentRegistry.register('object-kanban', ObjectKanbanRenderer, …)` and
* `ComponentRegistry.register('kanban', ObjectKanbanRenderer, …)`, and
* `kanban-plugin-dialect-authoritative-7664.test.ts` pins that they resolve to
* the same renderer. The two keys have DIFFERENT declared node types, and the
* discriminants are disjoint literals, so naming one of them makes the prop
* lie about the other half of the nodes this component serves. That is the
* defect this member carried: it named `KanbanSchema` alone, so no
* `object-kanban` node was assignable to it, and every in-package test that
* mounts one had to escape the prop with `as never`.
*
* ## The read set that settled it (measured on `origin/main` `21d7989fb`)
*
* `ObjectKanban` reads thirteen keys off `schema`. Neither declaration covers
* them; the two TOGETHER cover twelve:
*
* | key | `BaseSchema` | `KanbanSchema` | `ObjectKanbanSchema` |
* |---|---|---|---|
* | `objectName`, `groupBy`, `limit`, `cardFields` | — | yes | yes |
* | `columns`, `cardTitle`, `swimlaneField`, `grouping` | — | yes | — |
* | `titleField` | — | — | yes |
* | `data`, `bind`, `className` | yes | — / yes | — |
* | `filter` | — | — | — |
*
* So each arm is load-bearing: dropping `ObjectKanbanSchema` loses the
* `titleField` read at `:350` / `:1024` (which is why that read was spelled
* `(schema as any).titleField` before this card), and dropping `KanbanSchema`
* loses four reads AND the `'kanban'` registration. `filter` (`:310`,
* `$filter` on the fetch) is declared by NEITHER face and still rides
* {@link BaseSchema}'s `[key: string]: any` — measured, filed, and NOT fixed
* here: this card moves the prop, not the two published schema faces.
*
* ## What the union does and does not claim
*
* It claims exactly the accept set the registry dispatches to this component,
* no wider: a node of some third type is still turned away. Every key above
* that only one arm declares reads as `any` on the union (through the other
* arm's index signature) — the same resolution it had before, so no read
* changes meaning. Widening a member of an exported prop type is additive:
* every caller that passed a `KanbanSchema` still compiles.
*
* ⛔ Do not narrow this back to one arm without first removing a
* registration. `__tests__/object-kanban-component-props-7322.test.ts`
* derives the registered key set from `index.tsx` off disk and goes red if
* the two ever stop agreeing.
*/
schema: KanbanSchema | ObjectKanbanSchema;
dataSource?: DataSource;
className?: string; // Allow override
/** Pre-fetched records passed by a parent (e.g. ListView). When provided, skips internal data fetching. */
Expand Down Expand Up @@ -347,7 +400,7 @@ export const ObjectKanban: React.FC<ObjectKanbanComponentProps> = ({
// Support cardTitle property from schema (passed by ObjectView)
// Fallback to legacy titleField for backwards compatibility
const explicitTitleField: string | undefined =
schema.cardTitle || (schema as any).titleField;
schema.cardTitle || schema.titleField;

// Title is resolved per-item below via:
// 1. explicit titleField (schema.cardTitle / schema.titleField), if it
Expand Down Expand Up @@ -481,7 +534,7 @@ export const ObjectKanban: React.FC<ObjectKanbanComponentProps> = ({
// semantic role (ADR-0085); `[]` drops to the legacy heuristic below.
// (See `resolveKanbanCardFields` for the full priority contract.)
const explicitCardFields: string[] = resolveKanbanCardFields(
(schema as any).cardFields,
schema.cardFields,
objectDef,
);
// The field used as the card title is implicit (resolved above). Don't
Expand Down Expand Up @@ -1021,7 +1074,7 @@ export const ObjectKanban: React.FC<ObjectKanbanComponentProps> = ({
const rec = navigation.selectedRecord as Record<string, any>;
const recordId = rec.id ?? rec._id;
if (!objectName || recordId == null) return null;
const titleField = (schema as any).cardTitle ?? (schema as any).titleField;
const titleField = schema.cardTitle ?? schema.titleField;
const titleText = titleField && rec[titleField]
? String(rec[titleField])
: detailTitle;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,25 @@ type _RegistryEntryIsThisFace = Assert<Equal<SchemaRegistry['kanban'], KanbanSch

// 3. The renderers' props type-check against the declared schema:
// - `ObjectKanban` (behind `ObjectKanbanRenderer`, registered for `'kanban'`
// AND `'object-kanban'`) takes exactly the declared face as its `schema`;
type _ObjectKanbanTakesTheDeclaredFace = Assert<Equal<ObjectKanbanComponentProps['schema'], KanbanSchema>>;
// AND `'object-kanban'`) ACCEPTS the declared face as its `schema`.
//
// ⚠️ This leg read `Equal<…, KanbanSchema>` until objectui#7322 item ②.
// Identity was never what the ruling claimed — the ruling's words are that
// "the four registered renderers' props still type-check against the
// declared schema", i.e. assignability, which is the same form the
// `kanban-ui` leg below already uses and for the same reason. Identity was
// merely the shape the prop happened to have while it named ONE arm, and
// that was itself the defect objectui#7322 item ② filed: the same renderer
// is registered for `'object-kanban'` too, whose declared node type is
// `ObjectKanbanSchema`, and no such node was assignable to the prop. The
// prop is now the union of the two registered keys' declared types, so the
// ruling's claim holds on this arm and now holds on the other one as well.
// The union's own honesty is pinned in
// `object-kanban-component-props-7322.test.ts`, which derives the
// registered key set from `../index` off disk; this leg keeps guarding
// what objectui#7664 asserted — that THIS declaration is the one the
// renderer consumes.
type _ObjectKanbanTakesTheDeclaredFace = Assert<KanbanSchema extends ObjectKanbanComponentProps['schema'] ? true : false>;
// - `KanbanRenderer` (`'kanban-ui'`) accepts a declared board — its inline
// prop schema is a looser projection (`columns?: Array<any>`), so the
// claim is assignability, not identity;
Expand Down
Loading
Loading