Skip to content
Merged
18 changes: 18 additions & 0 deletions .changeset/12619-kanban-every-record.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@object-ui/plugin-kanban': minor
'@object-ui/plugin-list': minor
'@object-ui/i18n': patch
---

A kanban under a list view puts every record of the view in its column, not the list's first page, and its cards carry the view's first columns (objectui#12619).

Measured on HotCRM's 45 opportunities at Display › Layout › Kanban: the board drew 25 cards (the grid's Rows per page), the closed columns read 0, the footer read "Showing first 25 records. More data may be available.", and each card showed only the record's name.

- **Each lane is its own query.** `ListView` no longer hands its board the rows it fetched for the grid. It hands it the view's query, and the board asks each lane for its first 50 records: the view's filter (toolbar filters and chips included) AND the lane value, in the view's sort, with the toolbar search. The lane's count in its header is the server's count from the same answer. A lane holding more shows **Load more** at its foot. One more query finds the records in no lane (a lane value that is none of the lanes, or empty) for the trailing Uncategorized lane. A refresh asks each lane again at the depth it was loaded to. Dragging a card between columns writes the lane value and asks for the required fields as before, and both lane counts follow the card.
- **The list footer counts the view.** It shows the view's record count from the list's own query, with no "Showing first N records" line and no Rows per page picker on such a board.
- **Which boards page their lanes.** A board whose lanes are known before the query (the lane field's options) and that draws no swimlanes. Any other board under a list view is handed the list's rows, as before. A standalone `object-kanban` keeps its one query, capped by `limit`.
- **Cards carry the view's first columns.** With no `kanban.columns` of its own, a list view hands the board the view's next two columns past the card title, the lane field and the owner field, by field name. Before, a card drew no field lines from the view's columns. Below its name it showed at most a subtitle the board derives from the record where the record yields one (an amount and an account, say). On HotCRM's measured board it showed the name alone: that view's columns are `{ field }` entries, handed over whole, which the card read as field names and matched none. The kanban block's `columns` is the author's card list and is used whole.
- **A card field is a line with its label.** Each card field now shows its label, muted, before its value. The label used to be a tooltip only. The card's reduced padding between its title and its first field line now holds at every breakpoint; from the `sm` breakpoint up it had widened back to the full padding.
- **The owner's avatar.** When the object gives its records an owner (the spec's `ownership` model and `SystemFieldName.OWNER_ID`, read through `resolveInjectedSystemColumns`), each card shows the owner's avatar from the expanded owner record, as the viewer may read it.

Public surface: `ObjectKanbanComponentProps` gains two optional host props, `lanePaging` (`{ search?, searchFields?, reloadKey? }`) and `ownerField`. Neither is an authoring key on the `object-kanban` node. `@object-ui/i18n` gains `kanban.loadMore` in every language pack.
30 changes: 28 additions & 2 deletions content/docs/plugins/plugin-kanban.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -210,8 +210,13 @@ When a board is bound to an object (an `object-kanban`, or a kanban view inside
`ListView` / `ObjectView`) instead of being given static `columns`, the fields
rendered on each card resolve in priority order:

1. **View-level `cardFields`** — the fields the view configures (a kanban view's
`columns`, forwarded as `cardFields`). An explicit choice always wins.
1. **View-level `cardFields`** — the fields the view configures. A kanban
view's `kanban.columns` is forwarded as `cardFields`, every field of it: an
explicit choice always wins. A list view whose kanban block names no
`columns` hands the board the view's own next two columns past the card
title, the lane field and the owner field, by field name — on a view whose
columns are Opportunity Name, Account, Stage, Amount, … the cards carry
Account and Amount.
2. **The object's `highlightFields`** — the object's ADR-0085 semantic role (its
curated "most important fields"), the same list the Grid, List and Detail
surfaces already default to. Used when the view configures no card fields, so
Expand All @@ -224,6 +229,27 @@ rendered on each card resolve in priority order:
Defaulting to `highlightFields` keeps a card's contents consistent with the
object's other views without every kanban view having to re-declare its fields.

Each card field is one line: the field's label, then its value. When the board
sits under a list view and the object gives its records an owner (the spec's
`ownership` model, `user` by default, and the platform's `owner_id` field), the
card also shows the owner's avatar.

### Every record of a list view, a page per lane

A kanban view inside `ListView` does not draw the list's page of rows. The board
queries each lane itself — the view's filter AND the lane value, in the view's
sort — and draws the first 50 records of every lane, with the lane's count from
the same query. A lane that holds more shows **Load more** at its foot. One more
query finds the records whose lane value is none of the lanes, or empty, for the
trailing **Uncategorized** lane. The toolbar search and filters apply to every
lane, and the list footer shows the view's record count, with no "Showing first
N records" line.

The board pages its lanes when they are known before the query (the lane field's
options) and it draws no swimlanes. Otherwise it is handed the list's rows, as
before. A standalone `object-kanban` keeps its one query, capped by `limit`
(below).

### Fetch batch (object-driven boards)

An object-driven board fetches at most `limit` records, defaulting to **100**. A
Expand Down
167 changes: 167 additions & 0 deletions packages/app-shell/src/__tests__/listKanbanEveryRecord-12619.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
/**
* 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.
*/

/**
* objectui#12619 — a kanban under a list view puts every record of the view
* in its column, not the grid's first page, and its cards carry the view's
* first columns.
*
* Walked on HotCRM's 45 opportunities: the board drew 25 cards (the grid's
* "Rows per page" 25), the closed columns read 0, the footer read "Showing
* first 25 records. More data may be available.", and each card showed only
* the record's name although the view's columns are Opportunity Name, Account,
* Stage, Amount, Probability, Expected Revenue, Close Date.
*
* ## Why this file lives in `app-shell`
*
* The two halves sit in two packages — `ListView` in `plugin-list`, which now
* hands the board the view's query instead of its page, and `ObjectKanban` in
* `plugin-kanban`, which pages each column itself — and neither depends on the
* other. `app-shell` depends on both, so the REAL board is mounted under the
* REAL list here with no new dependency edge (the reason
* `listCalendarDateWindow-12081.test.tsx` lives here too). Each half's own pin
* sits in its package: `ListView.kanbanOwnsLanes-12619.test.tsx` and
* `ObjectKanban.lanePaging-12619.test.tsx`.
*
* The records live in a `ValueDataSource`, which evaluates `$filter`, `$skip`,
* `$top` and `$orderby` the way a backend does and answers `total`. The 25 open
* opportunities are listed ahead of the 20 closed ones, which is what a first
* page of the object reaches first.
*/
import React from 'react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
import '@testing-library/jest-dom';
import { ValueDataSource } from '@object-ui/core';
import { ActionProvider, SchemaRendererProvider } from '@object-ui/react';
import { ListView } from '@object-ui/plugin-list';
// Registers the real `object-kanban` renderer, the one `ListView` mounts, and
// loads the board's lazy chunk at module scope (AGENTS.md, test discipline).
import '@object-ui/plugin-kanban';
import '../../../plugin-kanban/src/KanbanImpl';

type Row = Record<string, unknown>;

const OBJECT = 'crm_opportunity';

const STAGES = [
['prospecting', 'Prospecting', 3],
['qualification', 'Qualification', 5],
['needs_analysis', 'Needs Analysis', 7],
['proposal', 'Proposal', 7],
['negotiation', 'Negotiation', 6],
['closed_won', 'Closed Won', 10],
['closed_lost', 'Closed Lost', 7],
] as const;

const OBJECT_DEF = {
name: OBJECT,
label: 'Opportunity',
fields: {
name: { type: 'text', label: 'Opportunity Name' },
account: { type: 'text', label: 'Account' },
stage: {
type: 'select',
label: 'Stage',
options: STAGES.map(([value, label]) => ({ value, label })),
},
amount: { type: 'number', label: 'Amount' },
probability: { type: 'number', label: 'Probability' },
close_date: { type: 'date', label: 'Close Date' },
},
};

/** 45 opportunities, every open stage listed ahead of the closed ones. */
const ROWS: Row[] = STAGES.flatMap(([stage, , count]) =>
Array.from({ length: count }, (_, i) => ({
id: `${stage}-${i}`,
name: `${stage} deal ${i}`,
account: `Account ${stage} ${i}`,
stage,
amount: 1000 + i,
probability: 40,
close_date: '2026-11-01',
})),
);

function makeDataSource() {
const store = new ValueDataSource<Row>({ items: ROWS });
return {
find: vi.fn(async (object: string, params: any) => store.find(object, params)),
findOne: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
getObjectSchema: vi.fn(async () => OBJECT_DEF),
} as any;
}

/** The view the walk opened: the grid's columns and page size, laid out as a board. */
const VIEW = {
type: 'list-view',
objectName: OBJECT,
viewType: 'kanban',
columns: [
{ field: 'name' },
{ field: 'account' },
{ field: 'stage' },
{ field: 'amount' },
{ field: 'probability' },
{ field: 'close_date' },
],
pagination: { pageSize: 25 },
// What the console's object page hands the list for a view with no kanban
// block of its own (`kanbanViewOptions` in `ObjectView.tsx`).
options: { kanban: { groupByField: 'stage', titleField: 'name' } },
};

function mountBoard(ds: any, view: Record<string, unknown> = VIEW) {
return render(
<SchemaRendererProvider dataSource={ds}>
<ActionProvider>
<ListView schema={view as any} dataSource={ds} />
</ActionProvider>
</SchemaRendererProvider>,
);
}

/** Every card the board drew, across all lanes. */
const cards = () => screen.queryAllByRole('listitem');

/** The lane group named `title`. */
const lane = (title: string) => screen.getByRole('group', { name: title });

afterEach(cleanup);

describe('a kanban under a list view draws every record of the view in its column (objectui#12619)', () => {
it('45 records with a page size of 25 draw 45 cards, each lane counting its own records', async () => {
mountBoard(makeDataSource());

await waitFor(() => expect(cards()).toHaveLength(45));
for (const [, title, count] of STAGES) {
expect(within(lane(title)).getAllByRole('listitem')).toHaveLength(count);
}
// The closed columns are not empty: the first page never reached them.
expect(within(lane('Closed Won')).getByText('closed_won deal 0')).toBeInTheDocument();
// The board is not a window, so the list does not say it is one.
expect(screen.queryByTestId('data-limit-warning')).not.toBeInTheDocument();
});

it('a card carries the view\'s next two non-stage columns as label and value, and no more', async () => {
mountBoard(makeDataSource());

await waitFor(() => expect(cards()).toHaveLength(45));
const card = screen.getByRole('listitem', { name: 'proposal deal 2' });
expect(within(card).getByText('Account')).toBeInTheDocument();
expect(within(card).getByText('Account proposal 2')).toBeInTheDocument();
expect(within(card).getByText('Amount')).toBeInTheDocument();
// The third non-stage column is not a card line.
expect(within(card).queryByText('Probability')).not.toBeInTheDocument();
expect(within(card).queryByText('Close Date')).not.toBeInTheDocument();
});
});
1 change: 1 addition & 0 deletions packages/i18n/src/locales/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -971,6 +971,7 @@ const ar = {
},
kanban: {
uncategorized: "غير مصنّف",
loadMore: "تحميل المزيد",
columns: "أعمدة",
addCard: "إضافة بطاقة",
addColumn: "إضافة عمود",
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -852,6 +852,7 @@ const de = {
},
kanban: {
uncategorized: "Nicht kategorisiert",
loadMore: "Mehr laden",
columns: "Spalten",
addCard: "Karte hinzufügen",
addColumn: "Spalte hinzufügen",
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1111,6 +1111,7 @@ const en = {
noCards: 'No cards',
cardTitlePlaceholder: 'Enter card title…',
uncategorized: 'Uncategorized',
loadMore: 'Load more',
columns: 'columns',
requiredFieldsTitle: 'Complete required fields',
requiredFieldsDescription: 'This move makes the fields below required. Fill them in to continue.',
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -882,6 +882,7 @@ const es = {
},
kanban: {
uncategorized: "Sin categoría",
loadMore: "Cargar más",
columns: "columnas",
addCard: "Añadir tarjeta",
addColumn: "Añadir columna",
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -878,6 +878,7 @@ const fr = {
},
kanban: {
uncategorized: "Non catégorisé",
loadMore: "Charger plus",
columns: "colonnes",
addCard: "Ajouter une carte",
addColumn: "Ajouter une colonne",
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -852,6 +852,7 @@ const ja = {
},
kanban: {
uncategorized: "未分類",
loadMore: "さらに読み込む",
columns: "カラム",
addCard: "カードを追加",
addColumn: "カラムを追加",
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -852,6 +852,7 @@ const ko = {
},
kanban: {
uncategorized: "미분류",
loadMore: "더 보기",
columns: "열",
addCard: "카드 추가",
addColumn: "열 추가",
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/pt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -877,6 +877,7 @@ const pt = {
},
kanban: {
uncategorized: "Sem categoria",
loadMore: "Carregar mais",
columns: "colunas",
addCard: "Adicionar cartão",
addColumn: "Adicionar coluna",
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -915,6 +915,7 @@ const ru = {
},
kanban: {
uncategorized: "Без категории",
loadMore: "Загрузить ещё",
columns: "колонок",
addCard: "Добавить карточку",
addColumn: "Добавить колонку",
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -884,6 +884,7 @@ const zh = {
noCards: '暂无卡片',
cardTitlePlaceholder: '输入卡片标题…',
uncategorized: '未分类',
loadMore: '加载更多',
columns: '列',
requiredFieldsTitle: '填写必填字段',
requiredFieldsDescription: '此次移动会使以下字段成为必填项。填写后即可继续。',
Expand Down
60 changes: 60 additions & 0 deletions packages/plugin-kanban/src/KanbanCardOwner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* 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.
*/

import { withoutDeniedFields } from '@object-ui/core';

/**
* The owner a card shows as an avatar (objectui#12619).
*
* Written by `ObjectKanban` onto the card it hands the board as `cardOwner`,
* read by `KanbanImpl`. A package-local hand-off: no caller outside this
* package writes it, so it is typed here, where both ends live, and not on the
* published `KanbanCard`.
*/
export interface KanbanCardOwner {
/** The person's name: the avatar's tooltip, its accessible name and its initials. */
name: string;
/** The person's picture, when the viewer may read it. */
image?: string;
}

/** What a field-level policy answers, as `withoutDeniedFields` reads it. */
type FieldPolicy = Parameters<typeof withoutDeniedFields>[1];

/**
* The owner of one record, read off its owner field's EXPANDED value.
*
* A bare id is a reference this board did not resolve, so it draws no avatar
* rather than a face for an id. The person is drawn as the viewer may read
* them: fields the policy denies on the person's object are removed first, the
* way the grid's user cell removes them. A person with no name draws nothing.
*/
export function resolveCardOwner(
value: unknown,
policy: FieldPolicy,
personObject: string,
): KanbanCardOwner | undefined {
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
const person = withoutDeniedFields(value as Record<string, unknown>, policy, personObject) as Record<string, unknown>;
const rawName = person.name ?? person.username;
const name = typeof rawName === 'string' ? rawName.trim() : '';
if (!name) return undefined;
const image = typeof person.image === 'string' && person.image ? person.image : undefined;
return image ? { name, image } : { name };
}

/** Up to two initials of a name, for the avatar with no picture. */
export function ownerInitials(name: string): string {
return name
.split(/\s+/)
.filter(Boolean)
.map((part) => part[0])
.join('')
.toUpperCase()
.slice(0, 2);
}
Loading
Loading