Skip to content
This repository was archived by the owner on Oct 26, 2026. It is now read-only.

Commit 7a564e0

Browse files
fix(plugin-kanban): match kanban lanes by id only, retire title bucketing (objectui#10069) (#10312)
Closes #10069 Clause-②: no (it narrows, per the ruling) Executes ruling 5816956837 on objectui#10069 (batch #222 item 3, letter A; the maintainer said 「同意」): **a kanban lane matches a record by its `id`, the option value, only.** Implemented by the dispatched dev of session `https://claude.ai/code/session_01877XiBYSaRCk2CU7cMSg3S`. ## What changed - `packages/plugin-kanban/src/index.tsx`, `bucketCardsIntoColumns`: the lookup no longer maps `col.title`. It is renamed from `labelToColumnId` to `laneIdByFoldedId`, because it no longer holds labels. The source comment that said data values ("labels like 'In Progress'") match column IDs is replaced by the id-only rule and a ⛔ against re-adding the title key. - **Case folding on the id: kept.** It was already there (`String(col.id).toLowerCase()`), and the ruling retires the title key, not the folding of the id key. `IN_PROGRESS` still lands in lane `in_progress`. A pinned control asserts this. - Both doors are closed by this one site. Authored `columns` and lanes derived from the groupBy picklist (`title: opt.label` in `ObjectKanban`'s `effectiveColumns`) both reach the board only through `KanbanRenderer` and then `bucketCardsIntoColumns`. Because translated titles no longer decide membership, the locale-dependent misbucketing is gone too. - **Loud, not silent.** A record matching no lane still goes to the trailing "Uncategorized" lane (#2792). It is now also named by a `console.warn` that gives the `groupBy` field, the raw value, how many records carry it, and the available lane ids. - **Dedup choice: one warn per distinct raw value per bucketing pass.** It is never per record, and never deduplicated module-wide. - Per value, because the offending record count is unbounded (a whole imported table can store labels) while the distinct-value count is bounded by the vocabulary. - Per pass, because one pass is one render or data load of one board (`KanbanRenderer`'s memo bounds it). Module-level dedup would stay silent for the next board, or for this board after its picklist or data changed, which is when an author looks. - The empty value is not warned about: a record with no group value is the ordinary "not yet categorised" state, not a value/label mismatch. - Docs: the `columns` row in `content/docs/api/schema-reference.md` and the `KanbanColumn` block in the plugin README now state the id-only rule. - Changeset `.changeset/10069-kanban-lanes-match-by-id-only.md`: **minor** on `@object-ui/plugin-kanban`, with the migration note. ## Pins: flipped, not deleted - `objectKanbanColumnMembers-8071.test.tsx`, the title row (formerly "AS A BUCKETING ALIAS"): **flipped**. It now asserts a title-valued record is an orphan in the trailing lane, the id-valued record in the same render still lands, and the warn names `"in progress"` plus `"open", "won"`. The "value matching neither the id nor the title IS an orphan" control is **kept unchanged**. The file header no longer says `title` is read twice. - Three more pins asserted the title alias and are flipped the same way (none deleted): - `index.bucket.test.ts` "matches by column title/label as well as id" now reads "id only (case-insensitively), never by title". - `laneIdCoercion-8993.test.ts` row 4 now reaches the raw numeric id through the folded-id lookup, and asserts the title-valued record is an orphan. - `prototypeGroupKeys-9043.test.ts` row 4c reaches lane `toString` via `TOSTRING`, and asserts `Done` (its title) is an orphan. Row 1c's name now says it holds through case folding. - New `laneIdOnly-10069.test.tsx` has four parts: - ⭐ **A translated title does not change membership.** The same ids with English versus German titles produce the identical board. - A case-folding control. - Three warn rows: content, once per distinct value per pass, and silence for matched or empty values. - The **picklist door** driven through the real `SchemaRenderer`: with no `columns`, lanes come from `field.options`, and a record storing the label `In Progress` is swept while the one storing `in_progress` lands. - `apps/console/.../registry-inputs-spec-parity.test.ts`: only the `pins` prose for the swimlane element, which described the alias, is updated. ## Reverse verification (committed first, then mutated) The title mapping was restored on top of commit `d6d4d0253` through `ablation-replace.mjs`. - Anchor `laneIdByFoldedId[String(col.id).toLowerCase()] = col.id;` went from 1 to 0, and the marker `ABLATION-10069` went from 0 to 1. The blob changed from `a830d5731ee2` to `2e93ce45f176`. - The five touched test files turned red: **6 failed / 49 passed**. The red rows include the flipped 8071 pin ("NOT A BUCKETING KEY"), the translated-title row, the picklist-door row, and the flipped rows in bucket, 8993 and 9043. - The restore was checked against HEAD, not taken from the exit code: blob after restore = blob at HEAD = `a830d5731ee2`, `git diff HEAD` is empty, and the marker count is 0. - My first attempt was a no-op and is not counted: its replacement text contained the anchor, so the tool refused it (the anchor stayed at 1) before any test ran. ## Tests and gates (at `d6d4d0253`) - `pnpm --filter "@object-ui/plugin-kanban..." build`: exit 0, all 13 packages in the closure. - `pnpm --filter @object-ui/plugin-kanban type-check` (`tsc --noEmit && tsc -p tsconfig.test.json`): exit 0. `--listFiles` confirms the test config includes the touched test files. - `pnpm exec vitest run packages/plugin-kanban/`, plus the console parity file and the `packages/types` / `scripts` tests that read `schema-reference.md`: **69 files, 1067 tests passed**. - eslint on the 7 changed TS/TSX files: 0 errors (the warnings are existing `no-explicit-any` / `react-refresh` warnings in those files). - `check-changeset-presence` 0 · `check-changeset-no-major` 0 · `check:new-line-citations` `0 new citation(s)` · `check:control-bytes` 0 · `check:changeset-claims` 0 · `check:pending-changeset-literals` 0 · `check:doc-fences` 0 · `check:doc-types` 0 · `check:test-path-roots` 0. - NOT MEASURED: `check:doc-snippets` / `check:doc-examples` stopped with PRECONDITION NOT MET (exit 2) because they need the 34-package docs closure built. `check:readme-exports` had exit 1, but only on unbuilt *other* packages; it has 0 findings for `plugin-kanban/README.md`. This PR's doc edits are prose inside an existing table row and a comment inside an existing code block. CI builds the closure. ## Migration: finding records that store a label instead of the value Records whose group field stores an option LABEL (`'In Progress'`) instead of its VALUE (`'in_progress'`) now show under "Uncategorized", and the console warning names each such value. A one-off query for a select field, shown for object `deal` grouped by `status`, uses the ObjectQL `FilterNode` array AST that `dataSource.find` takes as `$filter`: ```js // 1. The option VALUES the lanes match on, read from the field definition. const def = await dataSource.getObjectSchema('deal'); const options = def.fields.status.options; const values = options.map((o) => o.value); // 2. Records whose stored value is set but is none of them. const { data } = await dataSource.find('deal', { $filter: ['and', ['status', 'nin', values], ['status', '!=', null], ['status', '!=', ''], ], $select: ['_id', 'status'], }); // 3. Per row, the repair target. The board compares ids case-insensitively, // so a row matching a VALUE in another case still lands and is skipped here; // a row matching a LABEL is the migration case. const byValue = new Map(options.map((o) => [String(o.value).toLowerCase(), o.value])); const byLabel = new Map(options.map((o) => [String(o.label).toLowerCase(), o.value])); const toRepair = data .filter((r) => !byValue.has(String(r.status).toLowerCase())) .map((r) => ({ id: r._id ?? r.id, stored: r.status, repairTo: byLabel.get(String(r.status).toLowerCase()) ?? null })); ``` A `repairTo` of `null` means the value is neither a value nor a label: a retired option or free text, for a person to decide. Repair by updating each record to `repairTo`. Dragging a card from "Uncategorized" into its lane writes the lane id and repairs one record. ⛔ Do not add the label as a lane `id` to make records reappear: that re-teaches the label as a second value for the same state. ## Census: other kanban paths that bucket by title or label - **Same shape, covered here:** `bucketCardsIntoColumns` is the only place records are bucketed into kanban lanes. Every host reaches it through `ObjectKanban`, then `KanbanRenderer`: the `object-kanban` node, `plugin-list` `ListView`'s kanban view, app-shell `ObjectView`, and the metadata-admin preview. - Measured by `git grep` for `labelToColumnId` / `bucketCardsIntoColumns`, and by reading every `plugin-kanban` source that lowercases or reads `.label` / `.title`. - `plugin-view` has no kanban bucketing, only view-config utils. - **Different shapes, reported and not changed:** 1. `KanbanImpl` swimlane rows group by the raw `card[swimlaneField]` value. No title or label is involved. 2. The `ObjectKanban` card badge finds a select option by case-insensitive VALUE, then shows its translated label. That is display only and never decides membership. 3. `plugin-gantt` `groupByAccessor` matches a task's value to an option and then uses the option's display **label** as the group key. It is value-matched and label-keyed, so two options sharing a label would merge into one group. It is not the kanban shape (a label never admits a record), and I did not reproduce it. 4. `ObjectKanban` lanes derived from data when there are no options use `id = title = String(value)`, so there is no alias. ## Acceptance notes - `prototypeGroupKeys-9043.test.ts`'s header still names `labelToColumnId` where it describes the code measured before that repair. It is historical and was left as is. - The gantt label-keyed grouping (census item 3) is an observation, not filed: it has no reproduction. --- _Generated by [Claude Code](https://claude.ai/code/session_01877XiBYSaRCk2CU7cMSg3S)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 879ecac commit 7a564e0

10 files changed

Lines changed: 383 additions & 49 deletions
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
---
2+
'@object-ui/plugin-kanban': minor
3+
---
4+
5+
fix(plugin-kanban): a kanban lane matches records by its `id` only, never by its `title`
6+
7+
⚠️ Behaviour change (narrowing), declared `minor` under this repo's fixed-group
8+
versioning. Executes maintainer ruling A on objectui#10069.
9+
10+
A kanban board decides which lane a record belongs to by comparing the record's
11+
stored `groupBy` value with each lane's `id` — the picklist option value. Until
12+
now `bucketCardsIntoColumns` also lowercased every lane `title` into that lookup,
13+
so the title was a second, undeclared bucketing key. That had three
14+
user-visible effects, and all three are gone:
15+
16+
- renaming a lane in authored `columns` moved records between lanes;
17+
- relabelling a picklist option moved records too, because lanes derived from
18+
the `groupBy` field's options take `title: opt.label`;
19+
- membership depended on the viewer's locale, because lane titles are
20+
translated: a record storing the English label bucketed into its lane in one
21+
locale and into "Uncategorized" in another.
22+
23+
The lane `title` is now presentation only. The id comparison keeps its existing
24+
case folding (`IN_PROGRESS` still matches a lane `in_progress`).
25+
26+
A record whose value matches no lane id still surfaces in the trailing
27+
"Uncategorized" lane (#2792), and the board now also names it on the console:
28+
one `console.warn` per distinct raw value per bucketing pass, giving the
29+
`groupBy` field, the raw value, the number of records carrying it and the
30+
available lane ids. An empty group value is not warned about.
31+
32+
**Migration.** Records whose group field stores an option's LABEL (for example
33+
`'In Progress'`) instead of its VALUE (`'in_progress'`) used to reach their lane
34+
through the retired title match; they now appear under "Uncategorized", and the
35+
console warning names each such value. Fix the data, not the board: update each
36+
record to store the option value. A drag from "Uncategorized" into the right lane
37+
writes the lane id and so repairs one record at a time; for a bulk repair, find
38+
the affected records with a filter that selects values outside the field's
39+
option values, e.g. `['and', ['status', 'nin', ['in_progress', 'done']],
40+
['status', '!=', null], ['status', '!=', '']]`, and update each to the value
41+
whose option label it stores. ⛔ Do not add the label as a lane `id` to make the
42+
records reappear: that re-teaches the label as a second value for the same state.

‎apps/console/src/__tests__/registry-inputs-spec-parity.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2424,7 +2424,7 @@ const MEMBER_PINS: Record<string, MemberPin> = {
24242424
},
24252425
'object-kanban.columns': {
24262426
file: 'packages/plugin-kanban/src/__tests__/objectKanbanColumnMembers-8071.test.tsx',
2427-
pins: 'The SWIMLANE element\'s six members, each at its OWN sink and none assumed to behave like its neighbour, driven through the real renderer on a real adapter. `id` decides which records land in the lane (`groups[col.id]`) and names the heading, with an unmatched record swept into the trailing lane rather than dropped (objectui#2792) as the control. ⭐ `title` is read TWICE with two unrelated meanings — as the lane\'s accessible name, and as a BUCKETING ALIAS (`labelToColumnId[String(col.title).toLowerCase()] = col.id`), so a record whose stored group value is the lane\'s TITLE lands in that lane as surely as one carrying its id: nothing on the authoring surface says so, and renaming a lane therefore MOVES RECORDS. `cards` is a UNION and not a replacement — static lane cards survive the fetch, come first, and are not de-duplicated against it. `limit` is displayed beside the count and flags the lane, and ⛔ never truncates: the over-limit row asserts every card still renders, so a pin watching only the badge could not stay green under a renderer that dropped the overflow. `className` reaches that lane\'s container and no other. `collapsed` withholds that lane\'s cards while its neighbour keeps them — the member-set row only, because `columnCollapsedHonoured-9628.test.tsx` owns that member whole. The set itself is asserted as a WHITELIST: an undeclared lane member reaches no sink, with a `className` marker on the same lane in the same render as the lit control. ⚠️ The DECLARATION half is a different file and deliberately not duplicated here: `packages/types/src/__tests__/object-kanban-columns-declared-8913.test.ts` (objectui#8913/#8989) parses lane bags against both published faces, and every assertion in it is a `safeParse` — it cannot say what the board DOES with a member it admitted, which is objectui#8068\'s criterion. Read end to end before being cited. New file (objectui#8071 slice 16).',
2427+
pins: 'The SWIMLANE element\'s six members, each at its OWN sink and none assumed to behave like its neighbour, driven through the real renderer on a real adapter. `id` decides which records land in the lane (`groups[col.id]`) and names the heading, with an unmatched record swept into the trailing lane rather than dropped (objectui#2792) as the control. ⭐ `title` is the lane\'s accessible name and ⛔ NOT a bucketing key: it used to be a second, undeclared BUCKETING ALIAS, so renaming a lane moved records; objectui#10069 (ruling A) retired it and FLIPPED that row, so a record whose stored group value is the lane\'s TITLE is now swept into the trailing lane with a console warn. `cards` is a UNION and not a replacement — static lane cards survive the fetch, come first, and are not de-duplicated against it. `limit` is displayed beside the count and flags the lane, and ⛔ never truncates: the over-limit row asserts every card still renders, so a pin watching only the badge could not stay green under a renderer that dropped the overflow. `className` reaches that lane\'s container and no other. `collapsed` withholds that lane\'s cards while its neighbour keeps them — the member-set row only, because `columnCollapsedHonoured-9628.test.tsx` owns that member whole. The set itself is asserted as a WHITELIST: an undeclared lane member reaches no sink, with a `className` marker on the same lane in the same render as the lit control. ⚠️ The DECLARATION half is a different file and deliberately not duplicated here: `packages/types/src/__tests__/object-kanban-columns-declared-8913.test.ts` (objectui#8913/#8989) parses lane bags against both published faces, and every assertion in it is a `safeParse` — it cannot say what the board DOES with a member it admitted, which is objectui#8068\'s criterion. Read end to end before being cited. New file (objectui#8071 slice 16).',
24282428
},
24292429
'object-kanban.conditionalFormatting': {
24302430
file: 'packages/plugin-kanban/src/__tests__/ObjectKanban.structuredMembersReachTheirSinks-8313.test.tsx',

‎content/docs/api/schema-reference.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -935,7 +935,7 @@ A drag-and-drop Kanban board. The `object-kanban` type key validates the shape t
935935
|----------|------|-------------|
936936
| `objectName` | `string` | Object to fetch records from. |
937937
| `groupBy` | `string` | Field whose values become the lanes (maps to column ids). **Optional** since objectui#8990, matching `@objectstack/spec`. A board that omits it draws whatever lanes `columns` declares — and holds **no cards**, because records are only distributed once a lane key exists. |
938-
| `columns` | `string[] \| KanbanLane[]` | Swimlane definitions — an array of `{ id, title }` lanes (one per `groupBy` value), **or** an array of bare value strings; never a mix. **Not** a field projection (that is `cardFields`). A lane's `cards` is optional: an object-bound board buckets records into the lane by `groupBy`, and only a static board writes a lane's cards itself. |
938+
| `columns` | `string[] \| KanbanLane[]` | Swimlane definitions — an array of `{ id, title }` lanes (one per `groupBy` value), **or** an array of bare value strings; never a mix. **Not** a field projection (that is `cardFields`). A lane's `cards` is optional: an object-bound board buckets records into the lane by `groupBy`, and only a static board writes a lane's cards itself. A record lands in a lane when its stored `groupBy` value equals the lane **`id`** (the option value, compared case-insensitively); the lane `title` is display only and never decides membership (objectui#10069). A record matching no lane id is shown in a trailing *Uncategorized* lane and named in a console warning. |
939939
| `titleField` | `string` | Field used as the card title. |
940940
| `cardFields` | `string[]` | Fields rendered on each card. |
941941
| `filter` | `any[]` | Query filter, forwarded verbatim as `$filter`. |

‎packages/plugin-kanban/README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -160,8 +160,8 @@ const onCardMove = (cardId: string, fromColumnId: string, toColumnId: string, ne
160160

161161
// Column structure
162162
interface KanbanColumn {
163-
id: string;
164-
title: string;
163+
id: string; // The option VALUE — the ONLY key a record is matched on
164+
title: string; // Display only; never decides lane membership (objectui#10069)
165165
cards: KanbanCard[];
166166
limit?: number; // WIP limit — the count at which the lane warns
167167
className?: string;

‎packages/plugin-kanban/src/__tests__/laneIdCoercion-8993.test.ts‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@
5151
* control could not catch it.
5252
*/
5353

54-
import { describe, it, expect } from 'vitest';
54+
import { describe, it, expect, vi } from 'vitest';
5555
import { bucketCardsIntoColumns, KANBAN_UNCOLUMNED_ID } from '../index';
5656

5757
/** `id:card+card` per lane, in lane order — the whole board in one string. */
@@ -114,10 +114,18 @@ describe('bucketCardsIntoColumns — lane id key coercion (objectui#8993)', () =
114114
}
115115
});
116116

117-
it('row 4 — the label→id path is keyed the same way (it stores the RAW id)', () => {
118-
// 'One' matches the lane's TITLE, so the group key comes out of
119-
// `labelToColumnId`, whose stored VALUE is the raw numeric id.
120-
expect(bucket([{ id: 1, title: 'One' }], [{ id: 'r1', status: 'One' }])).toBe('1:r1');
117+
it('row 4 — the folded-id lookup is keyed the same way (it stores the RAW id)', () => {
118+
// The string '1' reaches the lane through `laneIdByFoldedId`, whose stored
119+
// VALUE is the raw numeric id. This row used to reach that lookup through
120+
// the lane's TITLE ('One'); objectui#10069 retired title matching, so the
121+
// title-valued record is now an orphan — asserted here so this row cannot
122+
// silently go back to exercising the retired key.
123+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
124+
expect(bucket([{ id: 1, title: 'One' }], [{ id: 'r1', status: '1' }])).toBe('1:r1');
125+
expect(bucket([{ id: 1, title: 'One' }], [{ id: 'r1', status: 'One' }])).toBe(
126+
`1:, ${KANBAN_UNCOLUMNED_ID}:r1`,
127+
);
128+
warn.mockRestore();
121129
});
122130

123131
it('row 5 — the sweep still sweeps: an unmatched record still surfaces (#2792)', () => {
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* objectui#10069 (ruling A) — a kanban lane matches a record by lane `id`, the
11+
* stored option value, ONLY. The lane `title` is presentation.
12+
*
13+
* Before the ruling `bucketCardsIntoColumns` also lowercased every lane
14+
* `title` into its lookup, so the title was an undeclared second bucketing
15+
* key. Three things moved records as a result, and each is a row here:
16+
*
17+
* 1. LOCALE. Lane titles are translated (`localizeColumn` and
18+
* `translateOptions` in `ObjectKanban.tsx`), so a record storing the
19+
* English label bucketed into its lane under one locale and into
20+
* "Uncategorized" under another. Membership must not depend on titles.
21+
* 2. THE PICKLIST DOOR. With no authored `columns`, lanes are derived from
22+
* the groupBy field's options as `{ id: opt.value, title: opt.label }`,
23+
* so relabelling an option moved records too. Driven through the real
24+
* renderer, because that door lives in `ObjectKanban`, not in the pure
25+
* bucketer. (The authored-`columns` door is the flipped row in
26+
* `objectKanbanColumnMembers-8071.test.tsx`.)
27+
* 3. LOUDNESS. A record that no longer matches is a DATA defect (it stored a
28+
* label instead of the value), so the move into "Uncategorized" is
29+
* named on the console — once per distinct raw value per bucketing pass.
30+
*/
31+
32+
import React from 'react';
33+
import { describe, it, expect, vi, afterEach } from 'vitest';
34+
import { render, waitFor, cleanup } from '@testing-library/react';
35+
import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react';
36+
// Registers `object-kanban`; module scope because the import IS the
37+
// registration (AGENTS.md's test-discipline section).
38+
import { bucketCardsIntoColumns, KANBAN_UNCOLUMNED_ID } from '../index';
39+
// Same specifier as `index.tsx`'s `React.lazy` factory, imported at module
40+
// scope so the cold transform is billed to the import phase (objectui#3010).
41+
import '../KanbanImpl';
42+
43+
afterEach(() => {
44+
cleanup();
45+
vi.restoreAllMocks();
46+
});
47+
48+
/** `id:card+card` per lane, in lane order. */
49+
const summarise = (lanes: Array<any>): string =>
50+
lanes.map((l: any) => `${String(l.id)}:${(l.cards || []).map((c: any) => c.id).join('+')}`).join(', ');
51+
52+
const bucket = (columns: Array<any>, data: Array<any>) =>
53+
bucketCardsIntoColumns(columns, data, 'status', undefined, 'Uncategorized');
54+
55+
const RECORDS = [
56+
{ id: 'r1', status: 'in_progress' }, // the option VALUE
57+
{ id: 'r2', status: 'In Progress' }, // the English LABEL
58+
{ id: 'r3', status: 'done' },
59+
];
60+
61+
describe('objectui#10069 — lane membership is decided by lane id only', () => {
62+
it('⭐ a translated title does not change membership', () => {
63+
vi.spyOn(console, 'warn').mockImplementation(() => {});
64+
const english = [
65+
{ id: 'in_progress', title: 'In Progress' },
66+
{ id: 'done', title: 'Done' },
67+
];
68+
const german = [
69+
{ id: 'in_progress', title: 'In Bearbeitung' },
70+
{ id: 'done', title: 'Erledigt' },
71+
];
72+
73+
const underEnglish = summarise(bucket(english, RECORDS));
74+
const underGerman = summarise(bucket(german, RECORDS));
75+
76+
// Same ids, different titles ⇒ the same board. Before the ruling the
77+
// English run put r2 in `in_progress` and the German run swept it.
78+
expect(underEnglish).toBe(underGerman);
79+
expect(underEnglish).toBe(`in_progress:r1, done:r3, ${KANBAN_UNCOLUMNED_ID}:r2`);
80+
});
81+
82+
it('CONTROL — the id comparison keeps its case folding', () => {
83+
// The ruling retires the TITLE key only; an id spelled in another case
84+
// still lands, so the row above is about titles and not about folding.
85+
expect(summarise(bucket([{ id: 'in_progress', title: 'In Progress' }], [{ id: 'r1', status: 'IN_PROGRESS' }]))).toBe(
86+
'in_progress:r1',
87+
);
88+
});
89+
90+
describe('the console warn', () => {
91+
const LANES = [
92+
{ id: 'open', title: 'Open' },
93+
{ id: 'won', title: 'Won' },
94+
];
95+
96+
it('names the raw value, the grouping field and every available lane id', () => {
97+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
98+
bucket(LANES, [{ id: 'r1', status: 'Opened' }]);
99+
100+
expect(warn).toHaveBeenCalledTimes(1);
101+
const message = String(warn.mock.calls[0][0]);
102+
expect(message).toContain('"Opened"');
103+
expect(message).toContain('"status"');
104+
expect(message).toContain('["open", "won"]');
105+
});
106+
107+
it('fires ONCE per distinct raw value per pass, not once per record', () => {
108+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
109+
bucket(LANES, [
110+
{ id: 'r1', status: 'Opened' },
111+
{ id: 'r2', status: 'Opened' },
112+
{ id: 'r3', status: 'Opened' },
113+
{ id: 'r4', status: 'Closed Won' },
114+
{ id: 'r5', status: 'OPEN' }, // matched (case-folded id): no warn
115+
]);
116+
117+
expect(warn).toHaveBeenCalledTimes(2);
118+
const messages = warn.mock.calls.map((c) => String(c[0]));
119+
expect(messages.find((m) => m.includes('"Opened"'))).toContain('3 record(s)');
120+
expect(messages.find((m) => m.includes('"Closed Won"'))).toContain('1 record(s)');
121+
122+
// A second pass (the next render or data load) warns again: deduplication
123+
// is per pass, never module-wide, so a later board is not silenced.
124+
bucket(LANES, [{ id: 'r1', status: 'Opened' }]);
125+
expect(warn).toHaveBeenCalledTimes(3);
126+
});
127+
128+
it('stays silent when every record matches, and for an EMPTY group value', () => {
129+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
130+
const out = bucket(LANES, [
131+
{ id: 'r1', status: 'open' },
132+
{ id: 'r2', status: '' },
133+
{ id: 'r3' },
134+
]);
135+
136+
// The empty-valued records still surface (#2792) — only the warn is withheld.
137+
expect(summarise(out)).toBe(`open:r1, won:, ${KANBAN_UNCOLUMNED_ID}:r2+r3`);
138+
expect(warn).not.toHaveBeenCalled();
139+
});
140+
});
141+
});
142+
143+
/* -------------------------------------------------------------------------- */
144+
/* The picklist door — lanes derived from `field.options`, no `columns` */
145+
/* -------------------------------------------------------------------------- */
146+
147+
const OBJECT = 'deal';
148+
149+
const DEAL_SCHEMA = {
150+
name: OBJECT,
151+
label: 'Deal',
152+
fields: {
153+
name: { type: 'text', label: 'Name' },
154+
status: {
155+
type: 'select',
156+
label: 'Status',
157+
options: [
158+
{ value: 'in_progress', label: 'In Progress' },
159+
{ value: 'done', label: 'Done' },
160+
],
161+
},
162+
},
163+
};
164+
165+
function makeAdapter(rows: Array<Record<string, unknown>>): Record<string, any> {
166+
return {
167+
find: vi.fn(async () => ({ data: rows })),
168+
findOne: vi.fn(),
169+
create: vi.fn(),
170+
update: vi.fn(),
171+
delete: vi.fn(),
172+
getObjectSchema: vi.fn(async () => DEAL_SCHEMA),
173+
};
174+
}
175+
176+
const cardsInList = (container: HTMLElement, label: string): string[] =>
177+
[...(container.querySelector(`[role="list"][aria-label="${label}"]`)?.children ?? [])]
178+
.map((node) => node.getAttribute('aria-label') ?? '')
179+
.filter((name) => name !== '');
180+
181+
const laneNames = (container: HTMLElement): string[] =>
182+
[...container.querySelectorAll('[role="group"]')].map((n) => n.getAttribute('aria-label') ?? '');
183+
184+
describe('objectui#10069 — the picklist door (`title: opt.label`) no longer buckets by label', () => {
185+
it('⭐ a record storing an option LABEL is swept; the one storing its VALUE lands', async () => {
186+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
187+
const adapter = makeAdapter([
188+
{ id: 'a', name: 'By value', status: 'in_progress' },
189+
{ id: 'b', name: 'By label', status: 'In Progress' },
190+
]);
191+
const { container } = render(
192+
<SchemaRendererProvider dataSource={adapter as any}>
193+
<SchemaRenderer schema={{ type: 'object-kanban', objectName: OBJECT, groupBy: 'status' } as never} />
194+
</SchemaRendererProvider>,
195+
);
196+
197+
// Wait on the CARD, not the lane: lanes commit before the query settles.
198+
await waitFor(() => expect(cardsInList(container, 'In Progress cards')).toEqual(['By value']));
199+
// The lanes really were derived from the picklist (titles are the labels),
200+
// plus the trailing sweep holding the label-valued record.
201+
expect(laneNames(container).slice(0, 2)).toEqual(['In Progress', 'Done']);
202+
expect(laneNames(container)).toHaveLength(3);
203+
expect(cardsInList(container, `${laneNames(container)[2]} cards`)).toEqual(['By label']);
204+
expect(
205+
warn.mock.calls.some((c) => String(c[0]).includes('"In Progress"') && String(c[0]).includes('"in_progress", "done"')),
206+
).toBe(true);
207+
});
208+
});

0 commit comments

Comments
 (0)