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
19 changes: 19 additions & 0 deletions .changeset/28211-kanban-declared-lanes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@object-ui/core': patch
'@object-ui/plugin-list': patch
'@object-ui/plugin-view': patch
'@objectstack/spec': patch
---

feat(console): a kanban view's `kanban.lanes` now decides which lanes the board draws, and in what order

Clause-②: yes (widening)
Metadata-protocol: none

`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.

- **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.
- **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.
- **Both routes agree.** A list view and an `object-view` element hand the lanes to the board the same way.
- **`@object-ui/core`**: new `kanbanDeclaredLanes(lanes, options)`, which turns a list view's `kanban.lanes` into the `object-kanban` node's `columns`.
- **`@objectstack/spec`**: the liveness ledger records `kanban.lanes` as live.
2 changes: 1 addition & 1 deletion packages/spec/liveness/view.json
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@
},
"kanban": {
"status": "live",
"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]`)."
"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]`)."
},
"calendar": {
"status": "live",
Expand Down
3 changes: 3 additions & 0 deletions ui/packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ export * from './utils/column-sortability.js';
// actions the installation switched off, stamped by the data layer and asked
// by every action gate beside the capability check.
export * from './utils/action-activation.js';
// [objectstack#28211] A list view's `kanban.lanes` as the `object-kanban`
// node's `columns`: the one mapping both producers of that node share.
export * from './utils/kanban-declared-lanes.js';
// Named, not `export *`: the module's `reset*` helper is test-only and stays off
// the public barrel (objectui#13453); in-repo tests import it from the module.
export {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { describe, it, expect } from 'vitest';
import { kanbanDeclaredLanes } from '../kanban-declared-lanes';

const STATUS_OPTIONS = [
{ value: 'not_started', label: 'Not Started' },
{ value: 'in_progress', label: 'In Progress' },
{ value: 'waiting', label: 'Waiting' },
{ value: 'completed', label: 'Completed' },
];

describe('kanbanDeclaredLanes (objectstack#28211)', () => {
it('draws only the listed lanes, in the listed order, titled by option label', () => {
expect(kanbanDeclaredLanes(['in_progress', 'not_started', 'waiting'], STATUS_OPTIONS)).toEqual([
{ id: 'in_progress', title: 'In Progress' },
{ id: 'not_started', title: 'Not Started' },
{ id: 'waiting', title: 'Waiting' },
]);
});

it('declares no lanes when the view declares none, so the board keeps one lane per option', () => {
expect(kanbanDeclaredLanes(undefined, STATUS_OPTIONS)).toBeUndefined();
expect(kanbanDeclaredLanes([], STATUS_OPTIONS)).toBeUndefined();
});

it('keeps the author order over the lifecycle order, and collapses a lost lane', () => {
const options = [
{ value: 'open', label: 'Open', kind: 'open' },
{ value: 'won', label: 'Won', kind: 'won' },
{ value: 'lost', label: 'Lost', kind: 'lost' },
];
expect(kanbanDeclaredLanes(['lost', 'open'], options)).toEqual([
{ id: 'lost', title: 'Lost', collapsed: true },
{ id: 'open', title: 'Open' },
]);
});

it('titles a lane with its value while the options are not loaded yet', () => {
expect(kanbanDeclaredLanes(['in_progress'], undefined)).toEqual([{ id: 'in_progress', title: 'in_progress' }]);
});
});
71 changes: 71 additions & 0 deletions ui/packages/core/src/utils/kanban-declared-lanes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/**
* 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.
*/

/**
* The lanes a list view's `kanban.lanes` declares, as the `object-kanban`
* node's `columns` (objectstack#28211).
*
* `kanban.lanes` (`KanbanConfigSchema.lanes` in `@objectstack/spec`) is an
* ordered list of the lane field's stored option values: the lanes the board
* draws, in that order. The `object-kanban` node declares no lane list of its
* own, but it does declare `columns`, its lanes, so the two producers of that
* node (`ListView` and `ObjectView`, the `kanban` branch of each) hand the
* declared lanes to the board through it. Both call this one function, so the
* two routes draw the same board.
*
* - The order is the author's, never the options' own and never the lifecycle
* order the board lays out on its own (open, won, lost): ordering is what
* the key is for.
* - Each lane is titled with its option's label. The board localizes an
* explicit lane's title against the same option, so a translated label still
* wins. A value the loaded options do not describe (the definition is still
* loading) is titled with the value itself until they do.
* - A lane whose option declares `kind: 'lost'` starts collapsed, as the board
* draws a lost lane it derives from the options (objectstack#26612).
* - A CHOICE of lanes, not a record filter: a record whose value is not listed
* lands in the board's `Uncategorized` lane, as a record whose value matches
* no lane always did. Nothing here decides that; the board does.
*
* Whether each value is an option of the lane field, and whether one is listed
* twice, is judged at authoring (`@objectstack/lint`, which `os validate` and
* the publish door run). This function takes the list as declared.
*
* Returns `undefined` when the view declares no lanes: the node then carries
* no `columns`, and the board draws one lane per option as before.
*/

/** One option of the lane field, as the loaded object definition carries it. */
export interface KanbanLaneOption {
value?: unknown;
label?: unknown;
kind?: unknown;
}

/** One lane of the `object-kanban` node's `columns`. */
export interface KanbanDeclaredLane {
id: string;
title: string;
collapsed?: true;
}

export function kanbanDeclaredLanes(
lanes: unknown,
options: readonly KanbanLaneOption[] | null | undefined,
): KanbanDeclaredLane[] | undefined {
if (!Array.isArray(lanes) || lanes.length === 0) return undefined;
const byValue = new Map<string, KanbanLaneOption>();
for (const option of options ?? []) {
if (option?.value !== undefined && option.value !== null) byValue.set(String(option.value), option);
}
return lanes.map((value) => {
const id = String(value);
const option = byValue.get(id);
const title = typeof option?.label === 'string' && option.label !== '' ? option.label : id;
return option?.kind === 'lost' ? { id, title, collapsed: true as const } : { id, title };
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/**
* 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.
*/

/**
* objectstack#28211 — a list view's `kanban.lanes`, handed to the board as its
* `columns` by `kanbanDeclaredLanes` (`@object-ui/core`), the mapping both
* producers of the `object-kanban` node make.
*
* Rows:
* (a) the board draws only the listed lanes, in the listed order, over the
* options' own order and over the lifecycle order it derives itself;
* (b) a record whose value is not listed lands in `Uncategorized`, never
* hidden;
* (c) a listed lost lane still starts collapsed.
*/

import React from 'react';
import { describe, it, expect, afterEach, vi } from 'vitest';
import { render, waitFor, cleanup, within } from '@testing-library/react';
import '@testing-library/jest-dom';
import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react';
import { kanbanDeclaredLanes } from '@object-ui/core';
// Registers `object-kanban`.
import './index';
// The board renders inside `KanbanRenderer`'s `React.lazy` boundary; importing
// the chunk at module scope bills the cold transform to the import phase
// (objectui#3010), same specifier as `index.tsx`'s factory.
import './KanbanImpl';

const STAGE_OPTIONS = [
{ value: 'new', label: 'New', kind: 'open' },
{ value: 'working', label: 'Working', kind: 'open' },
{ value: 'won', label: 'Won', kind: 'won' },
{ value: 'lost', label: 'Lost', kind: 'lost' },
];

const OBJECT_SCHEMA = {
name: 'lanes_deal',
fields: {
id: { type: 'text' },
name: { type: 'text', label: 'Name' },
stage: { name: 'stage', type: 'select', label: 'Stage', options: STAGE_OPTIONS },
},
};

const ROWS = [
{ id: 'd1', name: 'Deal in new', stage: 'new' },
{ id: 'd2', name: 'Deal in working', stage: 'working' },
{ id: 'd3', name: 'Deal won', stage: 'won' },
{ id: 'd4', name: 'Deal lost', stage: 'lost' },
];

function makeDataSource() {
return {
find: vi.fn().mockResolvedValue({ data: ROWS, total: ROWS.length }),
findOne: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
getObjectSchema: vi.fn().mockResolvedValue(OBJECT_SCHEMA),
} as any;
}

async function renderBoard(lanes: string[], waitForText: string) {
const result = render(
<SchemaRendererProvider dataSource={makeDataSource()}>
<SchemaRenderer
schema={{
type: 'object-kanban',
objectName: 'lanes_deal',
groupBy: 'stage',
columns: kanbanDeclaredLanes(lanes, STAGE_OPTIONS),
} as any}
/>
</SchemaRendererProvider>,
);
await waitFor(() => expect(result.container.textContent).toContain(waitForText));
return result;
}

/** Each lane's `role="group"` element, in DOM order. */
const lanesOf = (container: HTMLElement): HTMLElement[] =>
Array.from(container.querySelectorAll<HTMLElement>('[role="group"][aria-label]'));
const laneNamed = (container: HTMLElement, name: string) =>
lanesOf(container).find((l) => l.getAttribute('aria-label') === name);

afterEach(cleanup);

describe('the board draws the declared lanes (objectstack#28211)', () => {
it('(a) draws only the listed lanes, in the listed order', async () => {
const { container } = await renderBoard(['won', 'new'], 'Deal won');
const names = lanesOf(container).map((l) => l.getAttribute('aria-label'));
expect(names.filter((n) => n !== 'Uncategorized')).toEqual(['Won', 'New']);
expect(names).not.toContain('Working');
expect(names).not.toContain('Lost');
});

it('(b) lands a record whose value is not listed in Uncategorized, never hidden', async () => {
const { container } = await renderBoard(['won', 'new'], 'Deal won');
const uncategorized = laneNamed(container, 'Uncategorized');
expect(uncategorized).toBeDefined();
expect(uncategorized!.textContent).toContain('Deal in working');
expect(uncategorized!.textContent).toContain('Deal lost');
expect(laneNamed(container, 'Won')!.textContent).toContain('Deal won');
expect(laneNamed(container, 'New')!.textContent).toContain('Deal in new');
});

it('(c) starts a listed lost lane collapsed, and leaves the others open', async () => {
const { container } = await renderBoard(['lost', 'new'], 'Deal in new');
const lost = laneNamed(container, 'Lost')!;
expect(within(lost).getByRole('button', { expanded: false })).toBeInTheDocument();
expect(lost.textContent).not.toContain('Deal lost');
expect(within(laneNamed(container, 'New')!).queryByRole('button', { expanded: false })).toBeNull();
});
});
17 changes: 13 additions & 4 deletions ui/packages/plugin-list/src/ListView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import { useDensityMode, resolveInlineAriaProps } from '@object-ui/react';
import type { DependsOnInput, ListViewSchema, ObjectMapConfig } from '@object-ui/types';
import { detectStatusField, isSystemManagedField } from '@object-ui/types';
import { usePullToRefresh } from '@object-ui/mobile';
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';
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';
import { useObjectLabel, useSafeFieldLabel, createSafeTranslation, useDisplayLocale, pickLocalized, recordCurrencyField } from '@object-ui/i18n';
// Two resolvers, two vocabularies — the repo spells the distinction into the
// NAMES (objectui#4167). `resolveInlineI18nLabel` is the spec's own
Expand Down Expand Up @@ -4218,7 +4218,7 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
// re-derives lanes once it does.
const kanbanCfg = kanbanBlockOf(schema);
// ⭐ THE BLOCK'S DECLARED KEYS ONLY (objectui#6152 round 14). The spec's
// kanban block is `groupByField` / `columns` / `titleField` /
// kanban block is `groupByField` / `columns` / `lanes` / `titleField` /
// `summarizeField`, each read by name below. This branch used to read
// the pre-#2231 `groupField` / `cardFields` aliases as well and to spread
// the REST of the block onto the node after its own keys, so an undeclared
Expand All @@ -4229,9 +4229,17 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
//
// `columns` is the spec's list of fields shown on each card.
// ObjectKanban's own `columns` prop is its LANES, so it goes out as
// `cardFields` (mirrors plugin-view's adapter).
const { columns: kanbanCardColumns, titleField, summarizeField } = kanbanCfg as Record<string, any>;
// `cardFields` (mirrors plugin-view's adapter), and the block's `lanes`
// goes out as that `columns`.
const { columns: kanbanCardColumns, titleField, summarizeField, lanes } = kanbanCfg as Record<string, any>;
const laneField = kanbanLaneFieldOf(schema, objectDef);
// objectstack#28211 — the block's `lanes`, the lanes the board draws
// in the author's order, go out as the node's `columns` (its lanes).
// A record whose value is not listed lands in `Uncategorized`.
const declaredLanes = kanbanDeclaredLanes(
lanes,
laneField ? objectDef?.fields?.[laneField]?.options : undefined,
);
// objectui#12619 — the card fields: the block's own `columns` when
// the author named some (the spec's card-field key, every one of
// them), else the view's first columns past the title, the lane and
Expand Down Expand Up @@ -4265,6 +4273,7 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
type: 'object-kanban',
...baseProps,
groupBy: laneField,
...(declaredLanes ? { columns: declaredLanes } : {}),
...(titleField ? { titleField } : {}),
cardFields,
...(groupingConfig ? { grouping: groupingConfig } : {}),
Expand Down
Loading
Loading