diff --git a/.changeset/28211-kanban-declared-lanes.md b/.changeset/28211-kanban-declared-lanes.md new file mode 100644 index 00000000000..29555dc34e0 --- /dev/null +++ b/.changeset/28211-kanban-declared-lanes.md @@ -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. diff --git a/packages/spec/liveness/view.json b/packages/spec/liveness/view.json index dcf6ac1240b..d11e76100bf 100644 --- a/packages/spec/liveness/view.json +++ b/packages/spec/liveness/view.json @@ -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", diff --git a/ui/packages/core/src/index.ts b/ui/packages/core/src/index.ts index 5197f065756..73a0776e029 100644 --- a/ui/packages/core/src/index.ts +++ b/ui/packages/core/src/index.ts @@ -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 { diff --git a/ui/packages/core/src/utils/__tests__/kanban-declared-lanes-28211.test.ts b/ui/packages/core/src/utils/__tests__/kanban-declared-lanes-28211.test.ts new file mode 100644 index 00000000000..d0d08b9a454 --- /dev/null +++ b/ui/packages/core/src/utils/__tests__/kanban-declared-lanes-28211.test.ts @@ -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' }]); + }); +}); diff --git a/ui/packages/core/src/utils/kanban-declared-lanes.ts b/ui/packages/core/src/utils/kanban-declared-lanes.ts new file mode 100644 index 00000000000..d8e55e8d163 --- /dev/null +++ b/ui/packages/core/src/utils/kanban-declared-lanes.ts @@ -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(); + 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 }; + }); +} diff --git a/ui/packages/plugin-kanban/src/ObjectKanban.declaredLanes-28211.test.tsx b/ui/packages/plugin-kanban/src/ObjectKanban.declaredLanes-28211.test.tsx new file mode 100644 index 00000000000..f59211c83d5 --- /dev/null +++ b/ui/packages/plugin-kanban/src/ObjectKanban.declaredLanes-28211.test.tsx @@ -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( + + + , + ); + 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('[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(); + }); +}); diff --git a/ui/packages/plugin-list/src/ListView.tsx b/ui/packages/plugin-list/src/ListView.tsx index 13049501a64..7bedec9a053 100644 --- a/ui/packages/plugin-list/src/ListView.tsx +++ b/ui/packages/plugin-list/src/ListView.tsx @@ -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 @@ -4218,7 +4218,7 @@ export const ListView = React.forwardRef(({ // 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 @@ -4229,9 +4229,17 @@ export const ListView = React.forwardRef(({ // // `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; + // `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; 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 @@ -4265,6 +4273,7 @@ export const ListView = React.forwardRef(({ type: 'object-kanban', ...baseProps, groupBy: laneField, + ...(declaredLanes ? { columns: declaredLanes } : {}), ...(titleField ? { titleField } : {}), cardFields, ...(groupingConfig ? { grouping: groupingConfig } : {}), diff --git a/ui/packages/plugin-list/src/__tests__/ListView.kanbanDeclaredLanes-28211.test.tsx b/ui/packages/plugin-list/src/__tests__/ListView.kanbanDeclaredLanes-28211.test.tsx new file mode 100644 index 00000000000..6fec705c42a --- /dev/null +++ b/ui/packages/plugin-list/src/__tests__/ListView.kanbanDeclaredLanes-28211.test.tsx @@ -0,0 +1,116 @@ +/** + * 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 — `ListView`'s kanban branch hands the block's `lanes` to + * the board as the `object-kanban` node's `columns`, in the author's order and + * titled by the lane field's option labels, through `kanbanDeclaredLanes` + * (`@object-ui/core`), the mapping `ObjectView`'s twin makes. A block without + * `lanes` emits no `columns`, so the board keeps one lane per option. + */ + +import React from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, waitFor, cleanup } from '@testing-library/react'; +import { ComponentRegistry } from '@object-ui/core'; +import { SchemaRendererProvider } from '@object-ui/react'; +import { ListView } from '../ListView'; + +const OBJECT = 'lanes_task'; + +const objectDef = { + name: OBJECT, + label: 'Task', + fields: { + id: { name: 'id', type: 'text', label: 'Id' }, + name: { name: 'name', type: 'text', label: 'Name' }, + status: { + name: 'status', + type: 'select', + label: 'Status', + options: [ + { label: 'Not Started', value: 'not_started' }, + { label: 'In Progress', value: 'in_progress' }, + { label: 'Completed', value: 'completed' }, + { label: 'Cancelled', value: 'cancelled', kind: 'lost' }, + ], + }, + }, +}; + +const rows = [{ id: 'r1', name: 'Alpha', status: 'in_progress' }]; + +let nodes: Array> = []; + +ComponentRegistry.register( + 'object-kanban', + (props: Record) => { + nodes.push(props.schema); + return
; + }, + { namespace: 'test', label: 'object-kanban spy', category: 'view' }, +); + +const makeDataSource = () => + ({ + find: vi.fn(async () => rows), + findOne: vi.fn(async () => null), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + count: vi.fn(async () => rows.length), + getObjectSchema: vi.fn(async () => objectDef), + getObjects: vi.fn(async () => []), + onMutation: () => () => {}, + }) as any; + +/** Mount `ListView` on a kanban view and return the last board node, once `accept` holds. */ +async function kanbanNode(kanban: Record, accept: (node: Record) => boolean) { + cleanup(); + nodes = []; + render( + + + , + ); + await waitFor(() => { + expect(nodes.length).toBeGreaterThan(0); + expect(accept(nodes[nodes.length - 1])).toBe(true); + }); + return nodes[nodes.length - 1]; +} + +beforeEach(() => { + cleanup(); + nodes = []; +}); + +describe('objectstack#28211 — the list view board draws the declared lanes', () => { + it('emits `kanban.lanes` as the node\'s `columns`, in order, titled by option label', async () => { + const node = await kanbanNode( + { groupByField: 'status', lanes: ['in_progress', 'not_started', 'cancelled'] }, + (n) => n.columns?.[0]?.title === 'In Progress', + ); + expect(node.groupBy).toBe('status'); + expect(node.columns).toEqual([ + { id: 'in_progress', title: 'In Progress' }, + { id: 'not_started', title: 'Not Started' }, + { id: 'cancelled', title: 'Cancelled', collapsed: true }, + ]); + // `kanban.columns` stays the card fields; the lanes never land there. + expect(node.cardFields).not.toContain('in_progress'); + }); + + it('CONTROL: a block without `lanes` emits no `columns`', async () => { + const node = await kanbanNode({ groupByField: 'status' }, (n) => n.groupBy === 'status'); + expect(Object.prototype.hasOwnProperty.call(node, 'columns')).toBe(false); + }); +}); diff --git a/ui/packages/plugin-view/src/ObjectView.tsx b/ui/packages/plugin-view/src/ObjectView.tsx index e6b0bd354b5..26e28273c0c 100644 --- a/ui/packages/plugin-view/src/ObjectView.tsx +++ b/ui/packages/plugin-view/src/ObjectView.tsx @@ -88,6 +88,7 @@ import { resolveFilterPlaceholders, toFilterNodeSafely, GANTT_CONFIG_SPELLING, + kanbanDeclaredLanes, type FilterTokenScope, type SchemaLike, } from '@object-ui/core'; @@ -2168,7 +2169,8 @@ export const ObjectView: React.FC = ({ // `groupByField` is the spec's lane key and the only spelling read here: // the pre-#2231 `groupField` alias retired in objectui#6152 round 14. // `kanban.columns` (when provided) lists the FIELDS to render on each card — - // these are NOT lanes; lanes are derived from the groupBy field's options. + // these are NOT lanes; lanes are derived from the groupBy field's options, + // or are the block's `lanes` when it declares them (below). const kanbanCfg = viewOptions.kanban || {}; const groupBy = kanbanCfg.groupByField || @@ -2285,10 +2287,20 @@ export const ObjectView: React.FC = ({ // // ⚠️ NODE-LOCAL: the VIEW-LEVEL `kanbanCfg.groupField` alias read here // was a separate key, and objectui#6152 round 14 retired it. + // 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), the + // mapping `ListView`'s twin makes. A record whose value is not listed + // lands in `Uncategorized`. + const fieldDefs = (objectSchema as { fields?: Record } | null)?.fields; + const declaredLanes = kanbanDeclaredLanes( + kanbanCfg.lanes, + fieldDefs?.[groupBy]?.options as Parameters[1], + ); return { type: 'object-kanban', ...baseProps, groupBy, + ...(declaredLanes ? { columns: declaredLanes } : {}), titleField: kanbanCfg.titleField || 'name', cardFields, ...(kanbanCfg.summarizeField ? { summarizeField: kanbanCfg.summarizeField } : {}), @@ -2571,7 +2583,7 @@ export const ObjectView: React.FC = ({ // `schema.table?.columns` joins the list with the read added for // objectui#5269: a memo that reads a key but does not depend on it keeps // serving the field list the author has already replaced. - }, [schema.objectName, schema.table?.columns, schema.table?.fields, currentNamedViewConfig, activeView]); + }, [schema.objectName, schema.table?.columns, schema.table?.fields, currentNamedViewConfig, activeView, objectSchema]); // Build grid schema (default content renderer) // diff --git a/ui/packages/plugin-view/src/__tests__/ObjectView.kanbanDeclaredLanes-28211.test.tsx b/ui/packages/plugin-view/src/__tests__/ObjectView.kanbanDeclaredLanes-28211.test.tsx new file mode 100644 index 00000000000..ae3dd01856b --- /dev/null +++ b/ui/packages/plugin-view/src/__tests__/ObjectView.kanbanDeclaredLanes-28211.test.tsx @@ -0,0 +1,113 @@ +/** + * 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 — `generateViewSchema`'s kanban branch hands the block's + * `lanes` to the board as the `object-kanban` node's `columns`, in the + * author's order and titled by the lane field's option labels, through + * `kanbanDeclaredLanes` (`@object-ui/core`), the mapping `ListView`'s twin + * makes. A block without `lanes` emits no `columns`, so the board keeps one + * lane per option. + */ + +import React from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, waitFor, cleanup } from '@testing-library/react'; +import { ObjectView } from '../ObjectView'; +import type { ObjectViewSchema } from '@object-ui/types'; + +/** Every schema the view hands to SchemaRenderer, in order. */ +const rendered: any[] = []; + +vi.mock('@object-ui/react', async (importOriginal) => { + const ReactMod = await import('react'); + return { + ...(await importOriginal>()), + SchemaRenderer: ({ schema }: any) => { + rendered.push(schema); + return
{schema?.type}
; + }, + SchemaRendererContext: ReactMod.createContext(null), + subscribeDataChanges: () => () => {}, + notifyDataChanged: () => {}, + }; +}); +vi.mock('@object-ui/plugin-grid', async (importOriginal) => ({ + ...(await importOriginal>()), + ObjectGrid: () =>
, +})); +vi.mock('@object-ui/plugin-form', async (importOriginal) => ({ + ...(await importOriginal>()), + ObjectForm: () =>
, +})); + +const FIELDS = { + status: { + name: 'status', + type: 'select', + label: 'Status', + options: [ + { value: 'not_started', label: 'Not Started' }, + { value: 'in_progress', label: 'In Progress' }, + { value: 'completed', label: 'Completed' }, + { value: 'cancelled', label: 'Cancelled', kind: 'lost' }, + ], + }, +}; + +const dataSource = (): any => ({ + find: vi.fn().mockResolvedValue({ data: [], total: 0 }), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn().mockResolvedValue({ name: 'task', fields: FIELDS }), +}); + +/** The last `object-kanban` node, once `accept` holds for it. */ +async function kanbanNode(kanban: Record, accept: (node: any) => boolean): Promise { + cleanup(); + rendered.length = 0; + render( + , + ); + await waitFor(() => { + const node = rendered[rendered.length - 1]; + expect(node?.type).toBe('object-kanban'); + expect(accept(node)).toBe(true); + }); + return rendered[rendered.length - 1]; +} + +beforeEach(() => { + rendered.length = 0; +}); + +describe('objectstack#28211 — the generated board draws the declared lanes', () => { + it('emits `kanban.lanes` as the node\'s `columns`, in order, titled by option label', async () => { + const node = await kanbanNode( + { groupByField: 'status', lanes: ['in_progress', 'not_started', 'cancelled'] }, + (n) => n.columns?.[0]?.title === 'In Progress', + ); + expect(node.groupBy).toBe('status'); + expect(node.columns).toEqual([ + { id: 'in_progress', title: 'In Progress' }, + { id: 'not_started', title: 'Not Started' }, + { id: 'cancelled', title: 'Cancelled', collapsed: true }, + ]); + }); + + it('CONTROL: a block without `lanes` emits no `columns`', async () => { + const node = await kanbanNode({ groupByField: 'status' }, () => true); + expect(Object.prototype.hasOwnProperty.call(node, 'columns')).toBe(false); + }); +});