Skip to content

Commit 85a1b9d

Browse files
feat(ui): kanban board draws only the declared lanes, in their order (#28291)
<!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6JaoA3RUhHxoGDcojjSZo3nLDR)_ Fixes #28211 Refs #28018 Clause-②: yes (widening) Metadata-protocol: none ## 维护者速读 - 改了什么:看板视图现在按 `kanban.lanes` 只画作者列出的泳道、按作者的顺序;两条生成看板的路径(列表视图与 object-view 元素)都把它映射到看板已有的 `columns` 键上。 - 为什么改:键已在 spec 里声明,但 Console 不读它,HotCRM 任务看板仍画一条永远为空的「已完成」泳道,作者也排不了泳道顺序。 - 风险与回滚:没写 `lanes` 的看板完全不变;未列出取值的记录落进「未分类」泳道,不会被藏起来。回滚即撤销本 PR。 - 先看:`ui/packages/core/src/utils/kanban-declared-lanes.ts`(映射规则),`ui/packages/plugin-list/src/ListView.tsx` 的 `case 'kanban'`,`ui/packages/plugin-view/src/ObjectView.tsx` 的 `case 'kanban'`。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/plugin-kanban/src/ObjectKanban.declaredLanes-28211.test.tsx`,预期 3 个用例全绿(只画列出的泳道、顺序对、未列出的进「未分类」)。 - 需要你做的:无,席位落地。 ## What changes - **`@object-ui/core`**: new `kanbanDeclaredLanes(lanes, options)`. It turns a list view's `kanban.lanes` into the `object-kanban` node's `columns`, in the author's order. Each lane is titled with its option's label, or with the value itself while the definition is still loading. A lane whose option has `kind: 'lost'` gets `collapsed: true`, keeping the #26612 behaviour. No `lanes` returns `undefined`. - **`@object-ui/plugin-list`** (`ListView`, `case 'kanban'`) and **`@object-ui/plugin-view`** (`ObjectView#generateViewSchema`, `case 'kanban'`): both producers of the node read `kanban.lanes` by name and emit `columns` only when lanes are declared. `generateViewSchema` now also depends on `objectSchema`, so the titles follow the definition once it lands. - **`ObjectKanban.tsx` is unchanged.** Its explicit-columns branch already draws `columns` in order and localizes each title. A record whose value matches no lane already lands in `Uncategorized`, both when loaded rows are bucketed and when lanes page on their own: `pagedLaneIdsKey` reads declared columns first, and the uncolumned lane's query is "not in the listed ids". - **`packages/spec/liveness/view.json`**: only the `kanban` row changes. Its note moves `lanes` from planned to live and cites the readers by symbol. - Root changeset `.changeset/28211-kanban-declared-lanes.md`, with every package at `patch`. Why the producers and not `ObjectKanban`: the node's face, `ComponentPropsMap['object-kanban']`, declares no `lanes`, and both producers forward only named block keys. Mapping onto the existing `columns` key needs no spec edit. The seat's claim amendment records this surface. ## Tests - New `ui/packages/core/src/utils/__tests__/kanban-declared-lanes-28211.test.ts` (4 cases): order and labels, no lanes, author order over lifecycle order with lost collapsed, value title before options load. - New `ui/packages/plugin-kanban/src/ObjectKanban.declaredLanes-28211.test.tsx` (3 cases): the real board draws only the listed lanes in order, an unlisted value lands in `Uncategorized`, and a listed lost lane starts collapsed. - New `ui/packages/plugin-list/src/__tests__/ListView.kanbanDeclaredLanes-28211.test.tsx` and `ui/packages/plugin-view/src/__tests__/ObjectView.kanbanDeclaredLanes-28211.test.tsx` (2 cases each): the generated node carries the declared `columns`, and a control shows that no `lanes` means no `columns`. - Ablation: the `columns` spread was deleted with `scripts/ablation-replace.mjs` in each producer, and restore was proven by blob hash. Predicted and observed: the `columns` case of that producer's test goes red (`1 failed | 1 passed`) and its control stays green. ## Acceptance notes - S2 (the `checkKanbanLanes` docblock line) does not ride. That module is `packages/lint/src/validate-list-view-field-refs.ts`, outside the liveness file. The `KanbanConfigSchema.lanes` TSDoc in `packages/spec/src/ui/view.zod.ts` still says it "Lands ahead of its reader", which is now false. Both are spec-source edits that the seat files as a `domain:spec` follow-up after merge. - In `ListView.tsx`, one hunk sits outside `case 'kanban'`: the `@object-ui/core` import line gains `kanbanDeclaredLanes`. That matters for the serial overlap with seat 4's #25918 in the same file. When this branch was cut, `origin/main` had no change to that file. - The renderer does not dedupe or filter `lanes`. Membership and repeats are judged at authoring (`list-view-kanban-lane-unknown` / `-repeated` in `@objectstack/lint`). - ui `AGENTS.md` #2 (docs-driven) is not covered: no README or `content/docs/frontend/` page is touched, because both are outside the claimed surface. The spec key's own docs already describe the behaviour. ## Verification Head `8f4d9c61`, base `6285203d`. <details><summary>Package suites and type-checks</summary> - `pnpm --dir ui exec vitest run --maxWorkers=2 packages/core/ packages/plugin-kanban/`: 306 files, 4723 passed, 27 skipped. - `pnpm --dir ui exec vitest run --maxWorkers=2 packages/plugin-view/`: 78 files, 716 passed. - `pnpm --dir ui exec vitest run --maxWorkers=2 packages/plugin-list/` in two shards: 85 files / 794 passed, then 89 files / 908 passed. - `pnpm run type-check` in `ui/packages/core`, `plugin-kanban`, `plugin-list` and `plugin-view`: all exit 0, after a real dts build of their closure. - `eslint --no-inline-config` on the changed TS files: exit 0 (0 errors). </details> <details><summary>Derived gates</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 67 commands. Every one was run with stdin closed. Reconciliation with `--ran`: 67 accounted for, 66 run and 1 NOT MEASURED. - `pnpm check:dual-build-cjs-loads` exited 3 (PREREQUISITE NOT MET: needs a full workspace dist). NOT MEASURED locally; CI runs it. - `node scripts/check-plugin-teardown-shape.mjs --self-test` first exited 3 because the shallow clone lacked its pinned fixture commit. After `git fetch origin 621a487… --depth=1` it exited 0 (48 cases). - All other 65 derived commands exited 0, including `node scripts/check-sdui-manifest.mjs`, `pnpm --filter @objectstack/spec run check:liveness`, `pnpm check:doc-authoring`, `pnpm check:test-source-alias` and `pnpm --dir ui exec vitest run scripts/`. </details> 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01L9qibymteTr9Zsy1aSkJPt --- _Generated by [Claude Code](https://claude.ai/code/session_01L9qibymteTr9Zsy1aSkJPt)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 57f8ca2 commit 85a1b9d

10 files changed

Lines changed: 510 additions & 7 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@object-ui/core': patch
3+
'@object-ui/plugin-list': patch
4+
'@object-ui/plugin-view': patch
5+
'@objectstack/spec': patch
6+
---
7+
8+
feat(console): a kanban view's `kanban.lanes` now decides which lanes the board draws, and in what order
9+
10+
Clause-②: yes (widening)
11+
Metadata-protocol: none
12+
13+
`kanban.lanes` (an ordered list of the group field's stored option values) was declared in the spec but the Console did not read it yet, so a board still drew one lane per option. A task board that filters out completed tasks kept an always-empty `Completed` lane, and an author could not reorder lanes.
14+
15+
- **The board draws the listed lanes, in the listed order.** Each lane is titled with its option's label, and a translated label still wins. A lane whose option is a `lost` lifecycle kind still starts collapsed. Without `lanes` the board is unchanged: one lane per option.
16+
- **Nothing is hidden.** A record whose value is not listed lands in the board's `Uncategorized` lane, both when the board groups loaded records and when it pages each lane on its own. To keep records off the board, filter the view.
17+
- **Both routes agree.** A list view and an `object-view` element hand the lanes to the board the same way.
18+
- **`@object-ui/core`**: new `kanbanDeclaredLanes(lanes, options)`, which turns a list view's `kanban.lanes` into the `object-kanban` node's `columns`.
19+
- **`@objectstack/spec`**: the liveness ledger records `kanban.lanes` as live.

‎packages/spec/liveness/view.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@
101101
},
102102
"kanban": {
103103
"status": "live",
104-
"note": "objectui: KanbanImpl.tsx:682 — field-by-field wired (audit L16). One key rides this blanket verdict at PLANNED strength, as `viewMode` does on the gantt row: `lanes` (the lanes the board draws, in order, as stored option values of `groupByField`; absent meaning one lane per option) is declared ahead of its reader — at this repo's `ui/` tree on 9f13ee8154, ui/packages/plugin-kanban/src/ObjectKanban.tsx#effectiveColumns still draws one lane per declared option of the group field and reads no lane list. Amend this note to plain live, citing that read, when the console half lands. What `groupByField` names, on kanban, gantt and timeline alike (packages/spec/src/ui/view.zod.ts, the padded-spelling refusal; moved out of spec source, #23434): a lookup BY THAT NAME on data the server answers under the unpadded name, at objectui @dda8f3815 packages/plugin-list/src/ListView.tsx#laneField (`groupByField || groupField || detectStatusField(objectDef)`, inside #viewComponentSchema; cards bucketed by `card[laneField]`), objectui @dda8f3815 packages/plugin-gantt/src/ObjectGantt.tsx#groupByAccessor (splits on `.` and walks the record) and objectui @dda8f3815 packages/plugin-timeline/src/ObjectTimeline.tsx#effectiveItems (`item[groupByField]`)."
104+
"note": "objectui: KanbanImpl.tsx:682 — field-by-field wired (audit L16). `lanes` (the lanes the board draws, in order, as stored option values of `groupByField`; absent meaning one lane per option) is live: both producers of the `object-kanban` node, ui/packages/plugin-list/src/ListView.tsx (its `case 'kanban'` branch) and ui/packages/plugin-view/src/ObjectView.tsx#generateViewSchema, hand it to the board as the node's `columns` through ui/packages/core/src/utils/kanban-declared-lanes.ts#kanbanDeclaredLanes (the author's order, option labels as titles, a `lost` lane collapsed), and ui/packages/plugin-kanban/src/ObjectKanban.tsx#effectiveColumns draws explicit columns in that order, a record whose value is not listed landing in `Uncategorized` (pinned in ui/packages/plugin-kanban/src/ObjectKanban.declaredLanes-28211.test.tsx). What `groupByField` names, on kanban, gantt and timeline alike (packages/spec/src/ui/view.zod.ts, the padded-spelling refusal; moved out of spec source, #23434): a lookup BY THAT NAME on data the server answers under the unpadded name, at objectui @dda8f3815 packages/plugin-list/src/ListView.tsx#laneField (`groupByField || groupField || detectStatusField(objectDef)`, inside #viewComponentSchema; cards bucketed by `card[laneField]`), objectui @dda8f3815 packages/plugin-gantt/src/ObjectGantt.tsx#groupByAccessor (splits on `.` and walks the record) and objectui @dda8f3815 packages/plugin-timeline/src/ObjectTimeline.tsx#effectiveItems (`item[groupByField]`)."
105105
},
106106
"calendar": {
107107
"status": "live",

‎ui/packages/core/src/index.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@ export * from './utils/column-sortability.js';
6060
// actions the installation switched off, stamped by the data layer and asked
6161
// by every action gate beside the capability check.
6262
export * from './utils/action-activation.js';
63+
// [objectstack#28211] A list view's `kanban.lanes` as the `object-kanban`
64+
// node's `columns`: the one mapping both producers of that node share.
65+
export * from './utils/kanban-declared-lanes.js';
6366
// Named, not `export *`: the module's `reset*` helper is test-only and stays off
6467
// the public barrel (objectui#13453); in-repo tests import it from the module.
6568
export {
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { kanbanDeclaredLanes } from '../kanban-declared-lanes';
3+
4+
const STATUS_OPTIONS = [
5+
{ value: 'not_started', label: 'Not Started' },
6+
{ value: 'in_progress', label: 'In Progress' },
7+
{ value: 'waiting', label: 'Waiting' },
8+
{ value: 'completed', label: 'Completed' },
9+
];
10+
11+
describe('kanbanDeclaredLanes (objectstack#28211)', () => {
12+
it('draws only the listed lanes, in the listed order, titled by option label', () => {
13+
expect(kanbanDeclaredLanes(['in_progress', 'not_started', 'waiting'], STATUS_OPTIONS)).toEqual([
14+
{ id: 'in_progress', title: 'In Progress' },
15+
{ id: 'not_started', title: 'Not Started' },
16+
{ id: 'waiting', title: 'Waiting' },
17+
]);
18+
});
19+
20+
it('declares no lanes when the view declares none, so the board keeps one lane per option', () => {
21+
expect(kanbanDeclaredLanes(undefined, STATUS_OPTIONS)).toBeUndefined();
22+
expect(kanbanDeclaredLanes([], STATUS_OPTIONS)).toBeUndefined();
23+
});
24+
25+
it('keeps the author order over the lifecycle order, and collapses a lost lane', () => {
26+
const options = [
27+
{ value: 'open', label: 'Open', kind: 'open' },
28+
{ value: 'won', label: 'Won', kind: 'won' },
29+
{ value: 'lost', label: 'Lost', kind: 'lost' },
30+
];
31+
expect(kanbanDeclaredLanes(['lost', 'open'], options)).toEqual([
32+
{ id: 'lost', title: 'Lost', collapsed: true },
33+
{ id: 'open', title: 'Open' },
34+
]);
35+
});
36+
37+
it('titles a lane with its value while the options are not loaded yet', () => {
38+
expect(kanbanDeclaredLanes(['in_progress'], undefined)).toEqual([{ id: 'in_progress', title: 'in_progress' }]);
39+
});
40+
});
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
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+
* The lanes a list view's `kanban.lanes` declares, as the `object-kanban`
11+
* node's `columns` (objectstack#28211).
12+
*
13+
* `kanban.lanes` (`KanbanConfigSchema.lanes` in `@objectstack/spec`) is an
14+
* ordered list of the lane field's stored option values: the lanes the board
15+
* draws, in that order. The `object-kanban` node declares no lane list of its
16+
* own, but it does declare `columns`, its lanes, so the two producers of that
17+
* node (`ListView` and `ObjectView`, the `kanban` branch of each) hand the
18+
* declared lanes to the board through it. Both call this one function, so the
19+
* two routes draw the same board.
20+
*
21+
* - The order is the author's, never the options' own and never the lifecycle
22+
* order the board lays out on its own (open, won, lost): ordering is what
23+
* the key is for.
24+
* - Each lane is titled with its option's label. The board localizes an
25+
* explicit lane's title against the same option, so a translated label still
26+
* wins. A value the loaded options do not describe (the definition is still
27+
* loading) is titled with the value itself until they do.
28+
* - A lane whose option declares `kind: 'lost'` starts collapsed, as the board
29+
* draws a lost lane it derives from the options (objectstack#26612).
30+
* - A CHOICE of lanes, not a record filter: a record whose value is not listed
31+
* lands in the board's `Uncategorized` lane, as a record whose value matches
32+
* no lane always did. Nothing here decides that; the board does.
33+
*
34+
* Whether each value is an option of the lane field, and whether one is listed
35+
* twice, is judged at authoring (`@objectstack/lint`, which `os validate` and
36+
* the publish door run). This function takes the list as declared.
37+
*
38+
* Returns `undefined` when the view declares no lanes: the node then carries
39+
* no `columns`, and the board draws one lane per option as before.
40+
*/
41+
42+
/** One option of the lane field, as the loaded object definition carries it. */
43+
export interface KanbanLaneOption {
44+
value?: unknown;
45+
label?: unknown;
46+
kind?: unknown;
47+
}
48+
49+
/** One lane of the `object-kanban` node's `columns`. */
50+
export interface KanbanDeclaredLane {
51+
id: string;
52+
title: string;
53+
collapsed?: true;
54+
}
55+
56+
export function kanbanDeclaredLanes(
57+
lanes: unknown,
58+
options: readonly KanbanLaneOption[] | null | undefined,
59+
): KanbanDeclaredLane[] | undefined {
60+
if (!Array.isArray(lanes) || lanes.length === 0) return undefined;
61+
const byValue = new Map<string, KanbanLaneOption>();
62+
for (const option of options ?? []) {
63+
if (option?.value !== undefined && option.value !== null) byValue.set(String(option.value), option);
64+
}
65+
return lanes.map((value) => {
66+
const id = String(value);
67+
const option = byValue.get(id);
68+
const title = typeof option?.label === 'string' && option.label !== '' ? option.label : id;
69+
return option?.kind === 'lost' ? { id, title, collapsed: true as const } : { id, title };
70+
});
71+
}
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
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+
* objectstack#28211 — a list view's `kanban.lanes`, handed to the board as its
11+
* `columns` by `kanbanDeclaredLanes` (`@object-ui/core`), the mapping both
12+
* producers of the `object-kanban` node make.
13+
*
14+
* Rows:
15+
* (a) the board draws only the listed lanes, in the listed order, over the
16+
* options' own order and over the lifecycle order it derives itself;
17+
* (b) a record whose value is not listed lands in `Uncategorized`, never
18+
* hidden;
19+
* (c) a listed lost lane still starts collapsed.
20+
*/
21+
22+
import React from 'react';
23+
import { describe, it, expect, afterEach, vi } from 'vitest';
24+
import { render, waitFor, cleanup, within } from '@testing-library/react';
25+
import '@testing-library/jest-dom';
26+
import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react';
27+
import { kanbanDeclaredLanes } from '@object-ui/core';
28+
// Registers `object-kanban`.
29+
import './index';
30+
// The board renders inside `KanbanRenderer`'s `React.lazy` boundary; importing
31+
// the chunk at module scope bills the cold transform to the import phase
32+
// (objectui#3010), same specifier as `index.tsx`'s factory.
33+
import './KanbanImpl';
34+
35+
const STAGE_OPTIONS = [
36+
{ value: 'new', label: 'New', kind: 'open' },
37+
{ value: 'working', label: 'Working', kind: 'open' },
38+
{ value: 'won', label: 'Won', kind: 'won' },
39+
{ value: 'lost', label: 'Lost', kind: 'lost' },
40+
];
41+
42+
const OBJECT_SCHEMA = {
43+
name: 'lanes_deal',
44+
fields: {
45+
id: { type: 'text' },
46+
name: { type: 'text', label: 'Name' },
47+
stage: { name: 'stage', type: 'select', label: 'Stage', options: STAGE_OPTIONS },
48+
},
49+
};
50+
51+
const ROWS = [
52+
{ id: 'd1', name: 'Deal in new', stage: 'new' },
53+
{ id: 'd2', name: 'Deal in working', stage: 'working' },
54+
{ id: 'd3', name: 'Deal won', stage: 'won' },
55+
{ id: 'd4', name: 'Deal lost', stage: 'lost' },
56+
];
57+
58+
function makeDataSource() {
59+
return {
60+
find: vi.fn().mockResolvedValue({ data: ROWS, total: ROWS.length }),
61+
findOne: vi.fn(),
62+
create: vi.fn(),
63+
update: vi.fn(),
64+
delete: vi.fn(),
65+
getObjectSchema: vi.fn().mockResolvedValue(OBJECT_SCHEMA),
66+
} as any;
67+
}
68+
69+
async function renderBoard(lanes: string[], waitForText: string) {
70+
const result = render(
71+
<SchemaRendererProvider dataSource={makeDataSource()}>
72+
<SchemaRenderer
73+
schema={{
74+
type: 'object-kanban',
75+
objectName: 'lanes_deal',
76+
groupBy: 'stage',
77+
columns: kanbanDeclaredLanes(lanes, STAGE_OPTIONS),
78+
} as any}
79+
/>
80+
</SchemaRendererProvider>,
81+
);
82+
await waitFor(() => expect(result.container.textContent).toContain(waitForText));
83+
return result;
84+
}
85+
86+
/** Each lane's `role="group"` element, in DOM order. */
87+
const lanesOf = (container: HTMLElement): HTMLElement[] =>
88+
Array.from(container.querySelectorAll<HTMLElement>('[role="group"][aria-label]'));
89+
const laneNamed = (container: HTMLElement, name: string) =>
90+
lanesOf(container).find((l) => l.getAttribute('aria-label') === name);
91+
92+
afterEach(cleanup);
93+
94+
describe('the board draws the declared lanes (objectstack#28211)', () => {
95+
it('(a) draws only the listed lanes, in the listed order', async () => {
96+
const { container } = await renderBoard(['won', 'new'], 'Deal won');
97+
const names = lanesOf(container).map((l) => l.getAttribute('aria-label'));
98+
expect(names.filter((n) => n !== 'Uncategorized')).toEqual(['Won', 'New']);
99+
expect(names).not.toContain('Working');
100+
expect(names).not.toContain('Lost');
101+
});
102+
103+
it('(b) lands a record whose value is not listed in Uncategorized, never hidden', async () => {
104+
const { container } = await renderBoard(['won', 'new'], 'Deal won');
105+
const uncategorized = laneNamed(container, 'Uncategorized');
106+
expect(uncategorized).toBeDefined();
107+
expect(uncategorized!.textContent).toContain('Deal in working');
108+
expect(uncategorized!.textContent).toContain('Deal lost');
109+
expect(laneNamed(container, 'Won')!.textContent).toContain('Deal won');
110+
expect(laneNamed(container, 'New')!.textContent).toContain('Deal in new');
111+
});
112+
113+
it('(c) starts a listed lost lane collapsed, and leaves the others open', async () => {
114+
const { container } = await renderBoard(['lost', 'new'], 'Deal in new');
115+
const lost = laneNamed(container, 'Lost')!;
116+
expect(within(lost).getByRole('button', { expanded: false })).toBeInTheDocument();
117+
expect(lost.textContent).not.toContain('Deal lost');
118+
expect(within(laneNamed(container, 'New')!).queryByRole('button', { expanded: false })).toBeNull();
119+
});
120+
});

‎ui/packages/plugin-list/src/ListView.tsx‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ import { useDensityMode, resolveInlineAriaProps } from '@object-ui/react';
3232
import type { DependsOnInput, ListViewSchema, ObjectMapConfig } from '@object-ui/types';
3333
import { detectStatusField, isSystemManagedField } from '@object-ui/types';
3434
import { usePullToRefresh } from '@object-ui/mobile';
35-
import { type ListViewVisualization, resolveConditionalFormatting, buildExpandFields, buildExportFileName, resolveAffordance, type SchemaLike, partitionRowsByPredicate, normalizeListViewSchema, isListViewVisualization, rowHeightToDensityMode, mergeFilterNodes, FilterOperatorError, columnIdentity, collectPredicateFieldRefs, collectGroupingFieldRefs, listViewPredicates, PLATFORM_RECORD_COLUMNS, EXPANDABLE_FIELD_TYPES, UNMATERIALIZED_FIELD_TYPES, readObjectSortability, isPlatformSortableField, leadWithNameField, filterPlatformSortableSort, applyNonGridRowCeiling, type NonGridCeilingResult, resolveNameField, declaredNameField, resolveDependsOnFields, GANTT_CONFIG_SPELLING } from '@object-ui/core';
35+
import { type ListViewVisualization, resolveConditionalFormatting, buildExpandFields, buildExportFileName, resolveAffordance, type SchemaLike, partitionRowsByPredicate, normalizeListViewSchema, isListViewVisualization, rowHeightToDensityMode, mergeFilterNodes, FilterOperatorError, columnIdentity, collectPredicateFieldRefs, collectGroupingFieldRefs, listViewPredicates, PLATFORM_RECORD_COLUMNS, EXPANDABLE_FIELD_TYPES, UNMATERIALIZED_FIELD_TYPES, readObjectSortability, isPlatformSortableField, leadWithNameField, filterPlatformSortableSort, applyNonGridRowCeiling, type NonGridCeilingResult, resolveNameField, declaredNameField, resolveDependsOnFields, GANTT_CONFIG_SPELLING, kanbanDeclaredLanes } from '@object-ui/core';
3636
import { useObjectLabel, useSafeFieldLabel, createSafeTranslation, useDisplayLocale, pickLocalized, recordCurrencyField } from '@object-ui/i18n';
3737
// Two resolvers, two vocabularies — the repo spells the distinction into the
3838
// NAMES (objectui#4167). `resolveInlineI18nLabel` is the spec's own
@@ -4218,7 +4218,7 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
42184218
// re-derives lanes once it does.
42194219
const kanbanCfg = kanbanBlockOf(schema);
42204220
// ⭐ THE BLOCK'S DECLARED KEYS ONLY (objectui#6152 round 14). The spec's
4221-
// kanban block is `groupByField` / `columns` / `titleField` /
4221+
// kanban block is `groupByField` / `columns` / `lanes` / `titleField` /
42224222
// `summarizeField`, each read by name below. This branch used to read
42234223
// the pre-#2231 `groupField` / `cardFields` aliases as well and to spread
42244224
// the REST of the block onto the node after its own keys, so an undeclared
@@ -4229,9 +4229,17 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
42294229
//
42304230
// `columns` is the spec's list of fields shown on each card.
42314231
// ObjectKanban's own `columns` prop is its LANES, so it goes out as
4232-
// `cardFields` (mirrors plugin-view's adapter).
4233-
const { columns: kanbanCardColumns, titleField, summarizeField } = kanbanCfg as Record<string, any>;
4232+
// `cardFields` (mirrors plugin-view's adapter), and the block's `lanes`
4233+
// goes out as that `columns`.
4234+
const { columns: kanbanCardColumns, titleField, summarizeField, lanes } = kanbanCfg as Record<string, any>;
42344235
const laneField = kanbanLaneFieldOf(schema, objectDef);
4236+
// objectstack#28211 — the block's `lanes`, the lanes the board draws
4237+
// in the author's order, go out as the node's `columns` (its lanes).
4238+
// A record whose value is not listed lands in `Uncategorized`.
4239+
const declaredLanes = kanbanDeclaredLanes(
4240+
lanes,
4241+
laneField ? objectDef?.fields?.[laneField]?.options : undefined,
4242+
);
42354243
// objectui#12619 — the card fields: the block's own `columns` when
42364244
// the author named some (the spec's card-field key, every one of
42374245
// them), else the view's first columns past the title, the lane and
@@ -4265,6 +4273,7 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
42654273
type: 'object-kanban',
42664274
...baseProps,
42674275
groupBy: laneField,
4276+
...(declaredLanes ? { columns: declaredLanes } : {}),
42684277
...(titleField ? { titleField } : {}),
42694278
cardFields,
42704279
...(groupingConfig ? { grouping: groupingConfig } : {}),

0 commit comments

Comments
 (0)