diff --git a/.changeset/12619-kanban-every-record.md b/.changeset/12619-kanban-every-record.md new file mode 100644 index 0000000000..f18293f3bf --- /dev/null +++ b/.changeset/12619-kanban-every-record.md @@ -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. diff --git a/content/docs/plugins/plugin-kanban.mdx b/content/docs/plugins/plugin-kanban.mdx index 83b5ed3b43..04c3ddfd8f 100644 --- a/content/docs/plugins/plugin-kanban.mdx +++ b/content/docs/plugins/plugin-kanban.mdx @@ -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 @@ -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 diff --git a/packages/app-shell/src/__tests__/listKanbanEveryRecord-12619.test.tsx b/packages/app-shell/src/__tests__/listKanbanEveryRecord-12619.test.tsx new file mode 100644 index 0000000000..d796d5b9d1 --- /dev/null +++ b/packages/app-shell/src/__tests__/listKanbanEveryRecord-12619.test.tsx @@ -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; + +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({ 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 = VIEW) { + return render( + + + + + , + ); +} + +/** 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(); + }); +}); diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index 66c1f0014d..4ddfc52db2 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -971,6 +971,7 @@ const ar = { }, kanban: { uncategorized: "غير مصنّف", + loadMore: "تحميل المزيد", columns: "أعمدة", addCard: "إضافة بطاقة", addColumn: "إضافة عمود", diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index b1483f49ce..d4e5fcc937 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -852,6 +852,7 @@ const de = { }, kanban: { uncategorized: "Nicht kategorisiert", + loadMore: "Mehr laden", columns: "Spalten", addCard: "Karte hinzufügen", addColumn: "Spalte hinzufügen", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 729f8b0395..486bcf1440 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -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.', diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index d0cc62360c..d4dc7d55ab 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -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", diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index 4776ce8e20..e4ccb57e21 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -878,6 +878,7 @@ const fr = { }, kanban: { uncategorized: "Non catégorisé", + loadMore: "Charger plus", columns: "colonnes", addCard: "Ajouter une carte", addColumn: "Ajouter une colonne", diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index 6d66a3311c..c0a8441696 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -852,6 +852,7 @@ const ja = { }, kanban: { uncategorized: "未分類", + loadMore: "さらに読み込む", columns: "カラム", addCard: "カードを追加", addColumn: "カラムを追加", diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index a094960777..742539926a 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -852,6 +852,7 @@ const ko = { }, kanban: { uncategorized: "미분류", + loadMore: "더 보기", columns: "열", addCard: "카드 추가", addColumn: "열 추가", diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index e422929957..547486eb44 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -877,6 +877,7 @@ const pt = { }, kanban: { uncategorized: "Sem categoria", + loadMore: "Carregar mais", columns: "colunas", addCard: "Adicionar cartão", addColumn: "Adicionar coluna", diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index 9257f325d5..11d27d2dc0 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -915,6 +915,7 @@ const ru = { }, kanban: { uncategorized: "Без категории", + loadMore: "Загрузить ещё", columns: "колонок", addCard: "Добавить карточку", addColumn: "Добавить колонку", diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index 68c6e78f9f..e027f41c18 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -884,6 +884,7 @@ const zh = { noCards: '暂无卡片', cardTitlePlaceholder: '输入卡片标题…', uncategorized: '未分类', + loadMore: '加载更多', columns: '列', requiredFieldsTitle: '填写必填字段', requiredFieldsDescription: '此次移动会使以下字段成为必填项。填写后即可继续。', diff --git a/packages/plugin-kanban/src/KanbanCardOwner.ts b/packages/plugin-kanban/src/KanbanCardOwner.ts new file mode 100644 index 0000000000..356c4e54c2 --- /dev/null +++ b/packages/plugin-kanban/src/KanbanCardOwner.ts @@ -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[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, policy, personObject) as Record; + 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); +} diff --git a/packages/plugin-kanban/src/KanbanImpl.tsx b/packages/plugin-kanban/src/KanbanImpl.tsx index 46e15d7965..2734f57395 100644 --- a/packages/plugin-kanban/src/KanbanImpl.tsx +++ b/packages/plugin-kanban/src/KanbanImpl.tsx @@ -24,7 +24,7 @@ import { verticalListSortingStrategy, } from "@dnd-kit/sortable" import { CSS } from "@dnd-kit/utilities" -import { Badge, Card, CardHeader, CardTitle, CardDescription, CardContent, ScrollArea, Button, Input, useResizeObserver, DataEmptyState } from "@object-ui/components" +import { Avatar, AvatarFallback, AvatarImage, Badge, Card, CardHeader, CardTitle, CardDescription, CardContent, ScrollArea, Button, Input, useResizeObserver, DataEmptyState } from "@object-ui/components" import { useHasDndProvider, useDnd, usePredicateScope } from "@object-ui/react" import { resolveConditionalFormatting } from "@object-ui/core" import type { KanbanConditionalFormattingRule } from "@object-ui/types" @@ -33,6 +33,8 @@ import { createSafeTranslation } from "@object-ui/i18n" import { Plus, Sigma } from "lucide-react" import { useKanbanRecordsSettled } from './KanbanRecordsSettled' import { useKanbanColumnSummary, sumLaneField } from './KanbanColumnSummary' +import { laneCount, useKanbanLanePaging } from './KanbanLanePaging' +import { ownerInitials, type KanbanCardOwner } from './KanbanCardOwner' // Utility function to merge class names (inline to avoid external dependency) const cn = (...classes: Array) => classes.filter(Boolean).join(' ') @@ -44,6 +46,7 @@ const useKanbanT = createSafeTranslation( 'kanban.addCard': 'Add card', 'kanban.noCards': 'No cards', 'kanban.cardTitlePlaceholder': 'Enter card title…', + 'kanban.loadMore': 'Load more', }, 'kanban.noCards', ) @@ -221,6 +224,9 @@ function SortableCard({ card, onCardClick, conditionalFormatting, objectFields } const predicateScope = usePredicateScope() const cardStyles = getCardStyles(card, conditionalFormatting, predicateScope, objectFields) + // objectui#12619 — the owner `ObjectKanban` resolved for this card, when the + // object has one (a package-local hand-off; see `KanbanCardOwner`). + const owner = (card as { cardOwner?: KanbanCardOwner }).cardOwner return (
)} - - {card.title} + {/* objectui#12619 — the reduced side is restated at `sm:`. `sm:p-4` + is emitted after `pb-2` / `pt-0`, so from `sm` up it padded the + header's foot and the content's top back to 16px each: a 32px band + between the title and the first field line on every card with + field lines, which since objectui#12619 is every list-view card. */} + + {/* The title row exists only to place an owner's avatar beside the + title (objectui#12619). A card with no owner draws its title + exactly as before, with no wrapper, so a board without owners + keeps its markup. */} + {owner ? ( +
+ {card.title} + + {owner.image && } + + {ownerInitials(owner.name)} + + +
+ ) : ( + {card.title} + )} {!(card.cardFieldCells && card.cardFieldCells.length > 0) && (card.cardSubtitle ?? card.description) && ( {card.cardSubtitle ?? card.description} @@ -246,23 +273,24 @@ function SortableCard({ card, onCardClick, conditionalFormatting, objectFields } )}
{((card.cardFieldCells && card.cardFieldCells.length > 0) || (card.badges && card.badges.length > 0)) && ( - + {card.cardFieldCells && card.cardFieldCells.length > 0 && ( - // Dense single-column metadata list — values only, with the - // field label as a hover tooltip. Pipeline cards across - // Salesforce / HubSpot / Linear all drop the `Label: value` - // pair pattern because the value's own type (currency, date, - // lookup avatar/badge) already conveys its meaning, and the - // saved horizontal space lets the card title breathe. + // One line per card field, its label then its value + // (objectui#12619). The values used to stand alone with the + // label as a hover tooltip only, so a card read "Acme Corp · + // $150K" and a reader had to guess which column each value came + // from; an amount and a probability, or two dates, cannot be + // told apart by their type. The label is muted and keeps its + // natural width; the value takes the rest and truncates.
{card.cardFieldCells.map((cell) => (
-
{cell.label || cell.field}
-
{cell.node}
+
{cell.label || cell.field}
+
{cell.node}
))}
@@ -603,7 +631,20 @@ function KanbanColumnView({ }, }) - const isLimitExceeded = column.limit && safeCards.length >= column.limit + // objectui#12619 — a lane the board pages for itself carries the server's + // count of its records and a "Load more" while it holds more than it drew. + // A lane with no page is a lane of the board's one query, drawn as before. + const lanePaging = useKanbanLanePaging() + const lanePage = lanePaging?.pages[String(column.id)] + const pagedCount = lanePage ? laneCount(lanePage, safeCards.length) : null + const laneCountText = pagedCount + ? `${pagedCount.count}${pagedCount.atLeast ? '+' : ''}` + : laneCountLabel(safeCards.length, countsAreWindowed) + // A lane total over a lane that holds more than it drew is a total of what + // it drew, so it carries the `+` the windowed count carries. + const laneTotalPartial = countsAreWindowed || lanePage?.hasMore === true + + const isLimitExceeded = column.limit && (pagedCount ? pagedCount.count : safeCards.length) >= column.limit // When the parent passes inline width, drop the viewport-relative classes // so they don't fight with the container-derived value. A collapsed lane @@ -653,7 +694,7 @@ function KanbanColumnView({ : "bg-muted/70 text-muted-foreground", )} > - {laneCountLabel(safeCards.length, countsAreWindowed)} + {laneCountText} {column.limit && {` / ${column.limit}`}} {isLimitExceeded && ( @@ -661,7 +702,7 @@ function KanbanColumnView({ Full )} - {!isCollapsed && } + {!isCollapsed && } @@ -698,6 +739,20 @@ function KanbanColumnView({ ))} + {/* objectui#12619 — the next page of this lane, at its foot. */} + {lanePaging && lanePage?.hasMore && ( + + )} {quickAdd && onQuickAdd && ( )} diff --git a/packages/plugin-kanban/src/KanbanLanePaging.ts b/packages/plugin-kanban/src/KanbanLanePaging.ts new file mode 100644 index 0000000000..40d9a33573 --- /dev/null +++ b/packages/plugin-kanban/src/KanbanLanePaging.ts @@ -0,0 +1,147 @@ +/** + * 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 { createContext, useContext } from 'react'; +import { extractRecords, mergeFilterNodes } from '@object-ui/core'; + +/** + * A board that pages each LANE itself (objectui#12619). + * + * ## Why + * + * A board under a list view used to draw the rows the list fetched for its + * grid: one page of the view (Rows per page 25), bucketed into lanes on the + * client. Measured on HotCRM's 45 opportunities: 25 cards, the closed columns + * at 0, and every lane count a count of that page. A lane is a query of its + * own — the view's filter AND the lane value, in the view's order — so the + * board asks it as one: the first {@link KANBAN_LANE_PAGE_SIZE} records of + * each lane, the lane's count from the same answer (`QueryResult.total`), and a + * "Load more" at the foot of a lane that holds more. + * + * ## Who asks for it + * + * The HOST, through `ObjectKanban`'s `lanePaging` prop, never the document: the + * `object-kanban` node's `limit` is the row cap of the board's ONE query + * ("Maximum number of records loaded onto the board"), and a board authored + * with it keeps that query. `ListView` hands the request to the board it + * mounts, with the toolbar search and its reload signal. + * + * ## The lanes are known before the query + * + * A lane query needs the lane values up front, so a board pages its lanes only + * when it has them: its declared lanes, or the lane field's options. A board + * whose lanes come from the data, or that draws swimlanes, keeps the one query. + * + * ## Records in no lane + * + * One more query asks for the records whose lane value is none of the lanes, or + * empty (`KANBAN_UNCOLUMNED_ID`), so the trailing "Uncategorized" lane still + * holds every record the lanes do not (#2792), paged the same way. + */ + +/** How many records a lane asks for at a time. */ +export const KANBAN_LANE_PAGE_SIZE = 50; + +/** + * What a host hands a board that should page its lanes. Every member is a + * query input the host owns and the board's own document does not carry. + */ +export interface KanbanLanePagingRequest { + /** The host's search term (ADR-0061), sent as `$search` on every lane query. */ + search?: string; + /** The fields the term may match, sent as `$searchFields` beside it. */ + searchFields?: readonly string[]; + /** Moves when the host refreshes: every lane is asked again, at the depth it was loaded to. */ + reloadKey?: string | number; +} + +/** One lane's paging state, as the board holds it. */ +export interface KanbanLanePage { + /** The server's count of the lane's records, `null` when the source answered none. */ + total: number | null; + /** How many of the lane's records the server has handed the board. */ + loaded: number; + /** Does the lane hold records the board has not loaded? */ + hasMore: boolean; + /** Is a page of this lane on its way? */ + loading: boolean; +} + +/** The channel from `ObjectKanban` to the lane headers and feet `KanbanImpl` draws. */ +export interface KanbanLanePaging { + /** Each paged lane's state, by lane id (`KANBAN_UNCOLUMNED_ID` included). */ + pages: Readonly>; + /** Ask for the next page of one lane. */ + loadMore: (laneId: string) => void; +} + +/** + * Package-private, like `KanbanColumnSummaryContext`, for the same reason: the + * producer and the consumer sit either side of the board's `Suspense`/`lazy` + * boundary, and nothing outside this package sets it. `null` (no provider) is + * every board that does not page its lanes, whose headers stay as they were. + */ +export const KanbanLanePagingContext = createContext(null); + +/** Read the lane-paging channel above. */ +export function useKanbanLanePaging(): KanbanLanePaging | null { + return useContext(KanbanLanePagingContext); +} + +/** The view's filter AND one lane's value: the records of that lane. */ +export function laneQueryFilter(base: unknown, laneField: string, laneId: unknown): unknown { + return mergeFilterNodes(base, [laneField, '=', laneId]); +} + +/** + * The view's filter AND "in no lane": a lane value that is none of `laneIds`, + * or none at all. `nin` alone would leave the empty ones out, since a stored + * `null` is not "not in" a list on a SQL backend. + */ +export function uncolumnedQueryFilter(base: unknown, laneField: string, laneIds: readonly unknown[]): unknown { + return mergeFilterNodes(base, ['or', [laneField, 'nin', [...laneIds]], [laneField, 'is_null']]); +} + +/** + * One lane query's answer as a page: its records, and the lane's count when the + * source answered one. `loadedBefore` is what the lane already held, for a + * "Load more" page. Without a count, a full page is the only sign of more. + */ +export function readLanePage( + result: unknown, + loadedBefore: number, + asked: number, +): { rows: any[]; page: KanbanLanePage } { + const rows = extractRecords(result); + const rawTotal = result && typeof result === 'object' ? (result as { total?: unknown }).total : undefined; + const total = typeof rawTotal === 'number' && Number.isFinite(rawTotal) ? rawTotal : null; + const loaded = loadedBefore + rows.length; + return { + rows, + page: { + total, + loaded, + hasMore: total !== null ? loaded < total : rows.length >= asked, + loading: false, + }, + }; +} + +/** + * The number a paged lane's header shows, and whether it is a lower bound. + * + * `cards` is what the lane draws NOW. It moves from what the server handed the + * lane (`loaded`) when a card is dragged in or out or deleted before the next + * read, and the count moves with it, so the header agrees with the lane in + * between. Without a server count the lane can say only what it holds, with a + * `+` while it holds more. + */ +export function laneCount(page: KanbanLanePage, cards: number): { count: number; atLeast: boolean } { + if (page.total === null) return { count: cards, atLeast: page.hasMore }; + return { count: Math.max(cards, page.total + (cards - page.loaded)), atLeast: false }; +} diff --git a/packages/plugin-kanban/src/ObjectKanban.tsx b/packages/plugin-kanban/src/ObjectKanban.tsx index d473c3b978..53373dc28d 100644 --- a/packages/plugin-kanban/src/ObjectKanban.tsx +++ b/packages/plugin-kanban/src/ObjectKanban.tsx @@ -55,6 +55,17 @@ import { import { RequiredFieldsDialog } from './RequiredFieldsDialog'; import { KanbanRecordsSettledContext } from './KanbanRecordsSettled'; import { KanbanColumnSummaryContext, type KanbanColumnSummary } from './KanbanColumnSummary'; +import { + KANBAN_LANE_PAGE_SIZE, + KanbanLanePagingContext, + laneQueryFilter, + readLanePage, + uncolumnedQueryFilter, + type KanbanLanePage, + type KanbanLanePaging, + type KanbanLanePagingRequest, +} from './KanbanLanePaging'; +import { resolveCardOwner, type KanbanCardOwner } from './KanbanCardOwner'; /** * English fallbacks for the record-detail drawer heading this board opens on @@ -393,6 +404,28 @@ export interface ObjectKanbanComponentProps { * `React.MouseEvent`, which is what actually arrives. */ onCardClick?: (record: any, event?: any) => void; + /** + * The host asks the board to query each LANE itself (objectui#12619): the + * first {@link KANBAN_LANE_PAGE_SIZE} records of every lane, in the node's + * `sort`, under the node's `filter`, with the lane's count and a "Load more" + * at its foot. `ListView` passes it, with its toolbar search and its reload + * signal, to the board it mounts instead of handing it one page of rows. + * + * A HOST channel, never an authoring key: the node's `limit` is the row cap + * of the board's one query, and a board without this prop keeps that query. + * A board whose lanes are not known before the query (no declared lanes and + * no options on the lane field), or that draws swimlanes, keeps it too, with + * the search and the reload signal applied to it. + */ + lanePaging?: KanbanLanePagingRequest; + /** + * The record field holding the record's owner, when the host's object has + * one. Each card shows the owner's avatar from the expanded record. The host + * resolves it from the object's declared ownership (`ListView` reads the + * spec's `resolveInjectedSystemColumns` and `SystemFieldName.OWNER_ID`), so + * this board guesses no field name. + */ + ownerField?: string; } /** @@ -436,6 +469,8 @@ export const ObjectKanban: React.FC = ({ loading: externalLoading, onRowClick, onCardClick, + lanePaging, + ownerField, ..._props }) => { void _props; @@ -637,6 +672,75 @@ export const ObjectKanban: React.FC = ({ !hasExternalData && !boundData && !schema.data ? schema.objectName || undefined : undefined, ); + // objectui#12619 — the host's query inputs (`lanePaging`), keyed on CONTENT: + // a host rebuilds the request object on every render (AGENTS.md #10). The + // search pair rides on whichever query this board issues, exactly as the + // host's own fetch sends it (`$search`, with `$searchFields` only beside a + // term). + const lanePagingAsked = lanePaging !== undefined; + const hostSearchKey = JSON.stringify( + lanePaging?.search + ? { + $search: lanePaging.search, + ...(lanePaging.searchFields && lanePaging.searchFields.length > 0 + ? { $searchFields: [...lanePaging.searchFields] } + : {}), + } + : {}, + ); + const hostReloadKey = lanePagingAsked ? String(lanePaging?.reloadKey ?? '') : ''; + + /** + * The lanes this board pages, when it pages them (objectui#12619): known + * BEFORE the query, so each can be asked as one. The declared lanes, else the + * lane field's options — the two sources `effectiveColumns` draws lanes from + * first. `null` keeps the board's one query: no host request, no lane field, + * swimlanes (each lane is cut by a second field), or lanes only the data + * would name. A string, so the fetch effect re-keys on the lane values and + * never on an array a parent rebuilds. + */ + const pagedLaneIdsKey = useMemo((): string | null => { + if (!lanePagingAsked || !schema.groupBy) return null; + if (schema.swimlaneField || schema.grouping?.fields?.[0]?.field) return null; + const usable = (id: unknown) => id !== undefined && id !== null && id !== ''; + const declared = + Array.isArray(schema.columns) && schema.columns.length > 0 && typeof schema.columns[0] === 'object' + ? (schema.columns as Array<{ id?: unknown }>).map((col) => col?.id).filter(usable) + : []; + const options = objectDef?.fields?.[schema.groupBy]?.options; + const ids = declared.length > 0 + ? declared + : Array.isArray(options) + ? options.map((opt: { value?: unknown }) => opt?.value).filter(usable) + : []; + return ids.length > 0 ? JSON.stringify(ids) : null; + }, [lanePagingAsked, schema.groupBy, schema.swimlaneField, schema.grouping, schema.columns, objectDef]); + + /** Each paged lane's state; `null` on a board that issues one query. */ + const [lanePages, setLanePages] = useState | null>(null); + // Read by the fetch effect (the depth a refresh asks each lane to) and by + // "Load more", neither of which may re-run on every page that lands. + const lanePagesRef = React.useRef(lanePages); + lanePagesRef.current = lanePages; + /** + * The lane queries the cards on screen answer: the question they were asked + * under, their shared params and each lane's filter. "Load more" asks the + * next page of the SAME query; a page answering an older one is dropped + * (`generation`). + */ + const laneQueryRef = React.useRef<{ + generation: number; + question: string; + objectName: string; + params: Record; + filters: Record; + } | null>(null); + const laneGenerationRef = React.useRef(0); + const queryFilterKey = JSON.stringify(queryFilter ?? null); + // What the lanes answer. A refresh asks the same question and keeps the depth + // each lane was loaded to; any other change starts every lane over. + const laneQuestion = JSON.stringify([schemaKey, schema.groupBy ?? null, pagedLaneIdsKey, queryFilterKey, sortKey, hostSearchKey]); + useEffect(() => { // Skip internal fetch when data is managed by a parent component if (hasExternalData) return; @@ -729,6 +833,70 @@ export const ObjectKanban: React.FC = ({ const expand = !perms?.isLoaded ? expandable : expandable.filter((f) => perms.checkField(schema.objectName as string, f, 'read')); + // objectui#12619 — the host's search pair, on whichever query + // this board issues (`{}` without a host or a term). + const hostSearch = JSON.parse(hostSearchKey) as Record; + + // objectui#12619 — LANE PAGING. One query per lane (the node's + // filter AND the lane value), plus one for the records in no lane, + // each asking its first page and the lane's count. See + // `KanbanLanePaging.ts`. + if (pagedLaneIdsKey !== null && schema.groupBy) { + const objectName = schema.objectName; + const groupBy = schema.groupBy; + const laneIds = JSON.parse(pagedLaneIdsKey) as unknown[]; + const generation = ++laneGenerationRef.current; + // A refresh of the same question asks each lane to the depth + // the viewer loaded it to, so "Load more" is not undone by a + // drag or a toolbar refresh. + const held = laneQueryRef.current?.question === laneQuestion ? lanePagesRef.current : null; + const params: Record = { + $orderby: convertSortToQueryParams(schema.sort), + ...(expand.length > 0 ? { $expand: expand } : {}), + ...hostSearch, + // The lane's count is the point of the query, so ask for it. + $count: true, + }; + const lanes = [ + ...laneIds.map((id) => ({ key: String(id), filter: laneQueryFilter(queryFilter, groupBy, id) })), + { key: KANBAN_UNCOLUMNED_ID, filter: uncolumnedQueryFilter(queryFilter, groupBy, laneIds) }, + ]; + const answers = await Promise.all(lanes.map(async (lane) => { + const loaded = held?.[lane.key]?.loaded ?? 0; + const depth = Math.max(KANBAN_LANE_PAGE_SIZE, Math.ceil(loaded / KANBAN_LANE_PAGE_SIZE) * KANBAN_LANE_PAGE_SIZE); + const result = await dataSource.find(objectName, { ...params, $filter: lane.filter, $top: depth } as any); + return { lane, ...readLanePage(result, 0, depth) }; + })); + if (!isMounted || generation !== laneGenerationRef.current) return; + const seen = new Set(); + const rows: any[] = []; + const pages: Record = {}; + for (const answer of answers) { + pages[answer.lane.key] = answer.page; + for (const row of answer.rows) { + const id = String(row?.id ?? row?._id); + if (seen.has(id)) continue; + seen.add(id); + rows.push(row); + } + } + laneQueryRef.current = { + generation, + question: laneQuestion, + objectName, + params, + filters: Object.fromEntries(lanes.map((lane) => [lane.key, lane.filter])), + }; + lanePagesRef.current = pages; + setFetchedData(rows); + setLanePages(pages); + // Every lane carries its own count, so no header is a window's. + setFetchWindowSaturated(false); + setError(null); + return; + } + laneQueryRef.current = null; + setLanePages(null); // The row cap is a REAL `$top` (objectui#4025). It used to be // `{ options: { $top: 100 } }` — `$filter` at the top level where the // adapters read it, the cap one level down under a key that is not a @@ -763,6 +931,7 @@ export const ObjectKanban: React.FC = ({ $orderby: convertSortToQueryParams(schema.sort), $top: resolveRowLimit(schema.limit, DEFAULT_KANBAN_FETCH_BATCH_SIZE), ...(expand.length > 0 ? { $expand: expand } : {}), + ...hostSearch, }; const results = await dataSource.find(schema.objectName, query); @@ -813,8 +982,50 @@ export const ObjectKanban: React.FC = ({ // `schema.sort` is tracked by CONTENT (`sortKey`), not identity — the // `ObjectTimeline` spelling: an array rebuilt with the same members must not // refetch the board. + // objectui#12619 — `pagedLaneIdsKey`, `laneQuestion`, `hostSearchKey` and + // `hostReloadKey` are strings: the lanes, what they answer, the host's + // search pair and the host's refresh signal, each compared by value. // eslint-disable-next-line react-hooks/exhaustive-deps -- `schema.sort` is tracked by CONTENT (sortKey) on purpose; see above - }, [schema.objectName, schemaKey, dataSource, boundData, schema.data, queryFilter, sortKey, schema.limit, hasExternalData, objectDefReady, objectDef, refreshKey, perms, invalidationNonce]); + }, [schema.objectName, schemaKey, dataSource, boundData, schema.data, queryFilter, sortKey, schema.limit, hasExternalData, objectDefReady, objectDef, refreshKey, perms, invalidationNonce, pagedLaneIdsKey, laneQuestion, hostSearchKey, hostReloadKey]); + + /** + * objectui#12619 — the next page of one lane, under the query the lane's + * cards answer (`laneQueryRef`). Appended to the cards, never replacing them; + * a page that lands after the lanes were asked again is dropped. + */ + const loadMoreLane = React.useCallback((laneId: string) => { + const held = laneQueryRef.current; + const page = lanePagesRef.current?.[laneId]; + const filter = held?.filters[laneId]; + if (!held || !page || !page.hasMore || page.loading || filter === undefined) return; + if (!dataSource || typeof dataSource.find !== 'function') return; + const { generation } = held; + const setPage = (next: KanbanLanePage) => + setLanePages((prev) => (prev ? { ...prev, [laneId]: next } : prev)); + setPage({ ...page, loading: true }); + dataSource + .find(held.objectName, { ...held.params, $filter: filter, $top: KANBAN_LANE_PAGE_SIZE, $skip: page.loaded } as any) + .then((result) => { + if (laneQueryRef.current?.generation !== generation) return; + const next = readLanePage(result, page.loaded, KANBAN_LANE_PAGE_SIZE); + setFetchedData((prev) => { + const seen = new Set(prev.map((row) => String(row?.id ?? row?._id))); + return [...prev, ...next.rows.filter((row) => !seen.has(String(row?.id ?? row?._id)))]; + }); + setPage(next.page); + }) + .catch((err) => { + if (laneQueryRef.current?.generation !== generation) return; + console.error('[ObjectKanban] Load more failed:', err); + // The lane keeps what it holds, and its "Load more" stays to retry. + setPage({ ...page, loading: false }); + }); + }, [dataSource]); + + const lanePagingChannel = useMemo( + () => (lanePages ? { pages: lanePages, loadMore: loadMoreLane } : null), + [lanePages, loadMoreLane], + ); // Determine which data to use: external -> bound -> inline -> fetched const rawData = (hasExternalData ? externalData : undefined) || boundData || schema.data || fetchedData; @@ -831,6 +1042,19 @@ export const ObjectKanban: React.FC = ({ */ const countsAreWindowed = fetchWindowSaturated && rawData === fetchedData; + /** + * objectui#12619 — may the viewer read the owner field the host named, and + * which object is the person on it? A field the policy denies draws no + * avatar (an unanswered policy defers, as `$expand` does above). The person's + * object is the field's `reference`, and `sys_user` when it names none: the + * object the grid's user cell reads a person's fields against. + */ + const ownerReadable = + !!ownerField && + !(perms?.isLoaded && schema.objectName && !perms.checkField(schema.objectName, ownerField, 'read')); + const ownerReference = ownerField ? objectDef?.fields?.[ownerField]?.reference : undefined; + const ownerPersonObject = typeof ownerReference === 'string' && ownerReference ? ownerReference : 'sys_user'; + // Enhance data with title mapping and ensure IDs const effectiveData = useMemo(() => { if (!Array.isArray(rawData)) return []; @@ -1182,6 +1406,12 @@ export const ObjectKanban: React.FC = ({ const synthesizedSubtitle = descMissing && descParts.length > 0 ? descParts.join(' · ') : undefined; + // objectui#12619 — the owner's avatar, off the owner field the host + // named (`ownerField`), as the viewer may read it. + const cardOwner: KanbanCardOwner | undefined = ownerReadable && ownerField + ? resolveCardOwner((item as any)[ownerField], perms, ownerPersonObject) + : undefined; + return { ...item, // Ensure id exists @@ -1195,9 +1425,10 @@ export const ObjectKanban: React.FC = ({ ...(!Array.isArray(item.badges) && cardBadges.length > 0 ? { badges: cardBadges } : {}), + ...(cardOwner ? { cardOwner } : {}), }; }); - }, [rawData, schema, objectDef]); + }, [rawData, schema, objectDef, ownerField, ownerReadable, ownerPersonObject, perms]); // Generate columns if missing but groupBy is present const effectiveColumns = useMemo(() => { @@ -1716,6 +1947,10 @@ export const ObjectKanban: React.FC = ({ channel, for the same reason. `null` (no `summarizeField`) leaves every header exactly as it was. See `KanbanColumnSummary`. */} + {/* objectui#12619 — each paged lane's count and "Load more", on the + same kind of private channel. `null` (one query) leaves every lane + as it was. See `KanbanLanePaging.ts`. */} + {/* objectui#11234 — the internal board, not the exported `KanbanRenderer`. It takes the Quick Add pair only as explicit props, and this call passes neither, so the object-bound board draws no @@ -1776,6 +2011,7 @@ export const ObjectKanban: React.FC = ({ }, }} /> + {pendingMove && ( diff --git a/packages/plugin-kanban/src/__tests__/ObjectKanban.lanePaging-12619.test.tsx b/packages/plugin-kanban/src/__tests__/ObjectKanban.lanePaging-12619.test.tsx new file mode 100644 index 0000000000..69829629f0 --- /dev/null +++ b/packages/plugin-kanban/src/__tests__/ObjectKanban.lanePaging-12619.test.tsx @@ -0,0 +1,293 @@ +/** + * 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 board asked to page its lanes (`lanePaging`) queries each + * lane itself: the first page of every lane under the node's filter and sort, + * the lane's count from the same answer, "Load more" at the lane's foot, and + * one more query for the records in no lane. + * + * The records live in a `ValueDataSource`, which evaluates `$filter`, `$skip`, + * `$top` and `$orderby` the way a backend does and answers `total`. The fetch + * is spied on, so every query the board sends is read back as it left. + * + * The board half of the cross-package pin + * `packages/app-shell/src/__tests__/listKanbanEveryRecord-12619.test.tsx`, + * which mounts it under the real `ListView`. + */ + +import React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { ValueDataSource } from '@object-ui/core'; +import { SchemaRendererProvider } from '@object-ui/react'; +import { ObjectKanban } from '../ObjectKanban'; +import { KANBAN_LANE_PAGE_SIZE } from '../KanbanLanePaging'; +// The board renders inside a `React.lazy` boundary; importing the chunk at +// module scope bills the cold transform to the import phase (objectui#3010). +import '../KanbanImpl'; + +// The board's real `onDragEnd`, captured while the real provider still renders +// (the device `ObjectKanban.rejectedMoveRollback.test.tsx` uses): dnd-kit's +// pointer sensors need a layout this DOM does not have. +const dnd = vi.hoisted(() => ({ + onDragEnd: undefined as undefined | ((event: unknown) => void), +})); +vi.mock('@dnd-kit/core', async (importOriginal) => { + const actual = await importOriginal(); + const ReactMod = await import('react'); + const CapturingDndContext = (props: Record) => { + dnd.onDragEnd = props.onDragEnd as (event: unknown) => void; + return ReactMod.createElement(actual.DndContext, props as never); + }; + return { ...actual, DndContext: CapturingDndContext }; +}); + +afterEach(cleanup); + +type Row = Record; + +const OBJECT = 'deal'; + +const OBJECT_DEF = { + name: OBJECT, + fields: { + name: { type: 'text', label: 'Name' }, + stage: { + type: 'select', + label: 'Stage', + options: [ + { value: 'open', label: 'Open' }, + { value: 'won', label: 'Won' }, + { value: 'lost', label: 'Lost' }, + ], + }, + amount: { type: 'number', label: 'Amount' }, + region: { type: 'text', label: 'Region' }, + owner_id: { type: 'lookup', label: 'Owner', reference: 'sys_user' }, + }, +}; + +/** `n` deals in `stage`, named so their order is their index. */ +const deals = (stage: string | null, n: number, extra: Row = {}): Row[] => + Array.from({ length: n }, (_, i) => ({ + id: `${stage ?? 'none'}-${String(i).padStart(3, '0')}`, + name: `${stage ?? 'none'} ${String(i).padStart(3, '0')}`, + stage, + amount: i, + region: i % 2 === 0 ? 'north' : 'south', + ...extra, + })); + +function makeAdapter(rows: Row[]) { + const store = new ValueDataSource({ items: rows }); + return { + find: vi.fn(async (object: string, params: any) => store.find(object, params)), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(async (_object: string, id: string, patch: Row) => ({ id, ...patch })), + delete: vi.fn(), + getObjectSchema: vi.fn(async () => OBJECT_DEF), + } as any; +} + +const BOARD = { type: 'object-kanban', objectName: OBJECT, groupBy: 'stage' } as const; + +function mount(adapter: any, props: Record = {}, schema: Record = {}) { + return render( + + + , + ); +} + +const lane = (title: string) => screen.getByRole('group', { name: title }); +const laneCards = (title: string) => within(lane(title)).queryAllByRole('listitem'); + +/** The count badge a person reads beside a lane title. */ +function laneCountText(columnId: string): string { + const title = document.querySelector(`#kanban-col-${columnId}`); + const badge = title?.parentElement?.querySelector('span'); + return (badge?.textContent ?? '').trim(); +} + +/** The queries the board sent, without the definition read. */ +const queries = (adapter: any) => adapter.find.mock.calls.map((call: any[]) => call[1]); + +describe('a board pages each lane itself when its host asks (objectui#12619)', () => { + it('draws the first page of every lane, with the lane\'s count, past any single window', async () => { + const adapter = makeAdapter([...deals('open', 70), ...deals('won', 5), ...deals('lost', 3)]); + mount(adapter); + + await waitFor(() => expect(laneCards('Open')).toHaveLength(KANBAN_LANE_PAGE_SIZE)); + expect(laneCards('Won')).toHaveLength(5); + expect(laneCards('Lost')).toHaveLength(3); + // The lane's count is the server's, not what the lane drew. + expect(laneCountText('open')).toBe('70'); + expect(laneCountText('won')).toBe('5'); + expect(laneCountText('lost')).toBe('3'); + // A lane that holds more says so at its foot; a lane that holds all does not. + expect(within(lane('Open')).getByRole('button', { name: 'Load more' })).toBeInTheDocument(); + expect(within(lane('Won')).queryByRole('button', { name: 'Load more' })).not.toBeInTheDocument(); + }); + + it('asks one query per lane plus one for records in no lane, each a page with its count, in the node\'s order', async () => { + const adapter = makeAdapter([...deals('open', 2), ...deals('won', 1)]); + mount(adapter, {}, { sort: [{ field: 'amount', order: 'desc' }], filter: [{ field: 'region', operator: 'equals', value: 'north' }] }); + + await waitFor(() => expect(queries(adapter)).toHaveLength(4)); + const sent = queries(adapter); + for (const params of sent) { + expect(params.$top).toBe(KANBAN_LANE_PAGE_SIZE); + expect(params.$count).toBe(true); + // The node's `sort`, lowered the way the board's one query lowers it. + expect(params.$orderby).toEqual({ amount: 'desc' }); + } + const filters = sent.map((p: any) => JSON.stringify(p.$filter)); + // Each lane: the node's filter AND the lane value. + for (const id of ['open', 'won', 'lost']) { + expect(filters.some((f: string) => f.includes(JSON.stringify(['stage', '=', id])) && f.includes('north'))).toBe(true); + } + // In no lane: none of the lane values, or none at all. + expect(filters.some((f: string) => f.includes(JSON.stringify(['stage', 'nin', ['open', 'won', 'lost']])) && f.includes(JSON.stringify(['stage', 'is_null'])))).toBe(true); + }); + + it('"Load more" appends the lane\'s next page and keeps its count', async () => { + const adapter = makeAdapter([...deals('open', 70), ...deals('won', 1)]); + mount(adapter); + await waitFor(() => expect(laneCards('Open')).toHaveLength(KANBAN_LANE_PAGE_SIZE)); + + fireEvent.click(within(lane('Open')).getByRole('button', { name: 'Load more' })); + + await waitFor(() => expect(laneCards('Open')).toHaveLength(70)); + const last = queries(adapter).at(-1); + expect(last.$skip).toBe(KANBAN_LANE_PAGE_SIZE); + expect(last.$top).toBe(KANBAN_LANE_PAGE_SIZE); + expect(JSON.stringify(last.$filter)).toContain(JSON.stringify(['stage', '=', 'open'])); + expect(laneCountText('open')).toBe('70'); + expect(within(lane('Open')).queryByRole('button', { name: 'Load more' })).not.toBeInTheDocument(); + // The cards are the lane's own, in the lane's order, none twice. + const names = laneCards('Open').map((card) => card.getAttribute('aria-label')); + expect(new Set(names).size).toBe(70); + }); + + it('records in no lane still reach the trailing "Uncategorized" lane, with its count', async () => { + const adapter = makeAdapter([...deals('open', 2), ...deals(null, 2), ...deals('archived', 1)]); + mount(adapter); + + await waitFor(() => expect(laneCards('Uncategorized')).toHaveLength(3)); + expect(laneCountText('__uncolumned__')).toBe('3'); + expect(laneCards('Open')).toHaveLength(2); + }); + + it('the host\'s search rides on every lane query; a refresh asks each lane again at the depth it was loaded to', async () => { + const adapter = makeAdapter([...deals('open', 70), ...deals('won', 1)]); + const { rerender } = mount(adapter, { lanePaging: { search: 'open', searchFields: ['name'], reloadKey: 'r0' } }); + await waitFor(() => expect(laneCards('Open')).toHaveLength(KANBAN_LANE_PAGE_SIZE)); + for (const params of queries(adapter)) { + expect(params.$search).toBe('open'); + expect(params.$searchFields).toEqual(['name']); + } + + fireEvent.click(within(lane('Open')).getByRole('button', { name: 'Load more' })); + await waitFor(() => expect(laneCards('Open')).toHaveLength(70)); + const before = adapter.find.mock.calls.length; + + rerender( + + + , + ); + + await waitFor(() => expect(adapter.find.mock.calls.length).toBeGreaterThan(before)); + const reasked = queries(adapter).slice(before); + const openQuery = reasked.find((p: any) => JSON.stringify(p.$filter).includes(JSON.stringify(['stage', '=', 'open']))); + // Two pages were on screen, so the refresh asks for two pages' worth. + expect(openQuery.$top).toBe(2 * KANBAN_LANE_PAGE_SIZE); + await waitFor(() => expect(laneCards('Open')).toHaveLength(70)); + }); + + it('CONTROL: without the host\'s request the board keeps its one query, at its fetch batch', async () => { + const adapter = makeAdapter([...deals('open', 70), ...deals('won', 5)]); + render( + + + , + ); + + await waitFor(() => expect(laneCards('Open')).toHaveLength(70)); + expect(queries(adapter)).toHaveLength(1); + expect(queries(adapter)[0].$top).toBe(100); + expect(screen.queryByRole('button', { name: 'Load more' })).not.toBeInTheDocument(); + }); + + it('CONTROL: a board that draws swimlanes keeps its one query, with the host\'s search on it', async () => { + const adapter = makeAdapter([...deals('open', 3), ...deals('won', 2)]); + mount(adapter, { lanePaging: { search: 'o' } }, { swimlaneField: 'region' }); + + await waitFor(() => expect(adapter.find).toHaveBeenCalled()); + await waitFor(() => expect(document.querySelector('[aria-label="Kanban board with swimlanes"]')).not.toBeNull()); + expect(queries(adapter)).toHaveLength(1); + expect(queries(adapter)[0].$search).toBe('o'); + }); + + it('dragging a card keeps today\'s stage write, and both lane counts follow the card', async () => { + const adapter = makeAdapter([...deals('open', 3), ...deals('won', 2)]); + mount(adapter); + await waitFor(() => expect(laneCards('Open')).toHaveLength(3)); + expect(laneCountText('open')).toBe('3'); + expect(laneCountText('won')).toBe('2'); + + expect(dnd.onDragEnd).toBeTypeOf('function'); + await act(async () => { + dnd.onDragEnd!({ active: { id: 'open-000' }, over: { id: 'won' } }); + }); + + expect(adapter.update).toHaveBeenCalledWith(OBJECT, 'open-000', { stage: 'won' }); + await waitFor(() => expect(laneCards('Won')).toHaveLength(3)); + expect(laneCards('Open')).toHaveLength(2); + expect(laneCountText('won')).toBe('3'); + expect(laneCountText('open')).toBe('2'); + }); +}); + +describe('a card shows its owner\'s avatar when the host names the owner field (objectui#12619)', () => { + const OWNED = deals('open', 1, { owner_id: { id: 'u1', name: 'Ada Lovelace' } }); + + it('draws the owner\'s initials, named by the owner', async () => { + const adapter = makeAdapter(OWNED); + mount(adapter, { ownerField: 'owner_id' }); + + await waitFor(() => expect(laneCards('Open')).toHaveLength(1)); + const avatar = within(laneCards('Open')[0]).getByLabelText('Ada Lovelace'); + expect(avatar).toHaveTextContent('AL'); + }); + + it('CONTROL: no avatar without the host naming the field, or for an owner that is a bare id', async () => { + const adapter = makeAdapter([...OWNED, ...deals('won', 1, { owner_id: 'u2' })]); + mount(adapter); + await waitFor(() => expect(laneCards('Open')).toHaveLength(1)); + expect(document.querySelector('[data-kanban-card-owner]')).toBeNull(); + cleanup(); + + const again = makeAdapter([...deals('won', 1, { owner_id: 'u2' })]); + mount(again, { ownerField: 'owner_id' }); + await waitFor(() => expect(laneCards('Won')).toHaveLength(1)); + expect(document.querySelector('[data-kanban-card-owner]')).toBeNull(); + }); +}); diff --git a/packages/plugin-kanban/src/__tests__/cardPaddingEveryBreakpoint-12619.test.tsx b/packages/plugin-kanban/src/__tests__/cardPaddingEveryBreakpoint-12619.test.tsx new file mode 100644 index 0000000000..88354fe474 --- /dev/null +++ b/packages/plugin-kanban/src/__tests__/cardPaddingEveryBreakpoint-12619.test.tsx @@ -0,0 +1,95 @@ +/** + * 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 card's reduced padding holds at every breakpoint. + * + * The card header wrote `p-2 sm:p-4 pb-2` and the card content `p-2 sm:p-4 + * pt-0`. Tailwind emits the `sm:` rule after the base side rule, so from `sm` + * up `sm:p-4` padded the header's foot and the content's top back to 16px + * each: measured in a browser at 1280 px as a blank band between the card's + * title and its first field line. It was there on main wherever a card had + * field lines; objectui#12619 gives every list-view card field lines, so every + * card showed it. + * + * The rule pinned is the CLASS of defect, read off the rendered elements + * rather than one string: wherever a card element pads a side down at the base + * breakpoint and also carries a responsive `sm:p-*` shorthand, it restates + * that side at `sm:`. This DOM applies no stylesheet, so the class list is the + * reading; the shorthand control keeps the rule from passing on a card that + * has no responsive padding at all. + */ + +import React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { cleanup, render, screen, waitFor } from '@testing-library/react'; +import { ObjectKanban } from '../ObjectKanban'; +// The board renders inside a `React.lazy` boundary; importing the chunk at +// module scope bills the cold transform to the import phase (objectui#3010). +import '../KanbanImpl'; + +afterEach(cleanup); + +const OBJECT_DEF = { + name: 'deal', + fields: { + name: { type: 'text', label: 'Name' }, + stage: { type: 'select', label: 'Stage', options: [{ value: 'open', label: 'Open' }] }, + account: { type: 'text', label: 'Account' }, + amount: { type: 'number', label: 'Amount' }, + }, +}; + +const ROWS = [{ id: 'd1', name: 'First deal', stage: 'open', account: 'Acme', amount: 2000 }]; + +const adapter = { + find: vi.fn(async () => ({ data: ROWS })), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn(async () => OBJECT_DEF), +} as any; + +/** The side tokens a base breakpoint pads down, and their `sm:` restatement. */ +function unrestatedSides(el: Element): string[] { + const classes = new Set(el.className.split(/\s+/).filter(Boolean)); + const hasResponsiveShorthand = [...classes].some((c) => /^sm:p-\d/.test(c)); + if (!hasResponsiveShorthand) return []; + return [...classes] + .filter((c) => /^p[tbrlxy]-\d/.test(c)) + .filter((c) => !classes.has(`sm:${c}`)); +} + +describe('a card keeps its reduced padding at every breakpoint (objectui#12619)', () => { + it('the header\'s foot and the content\'s top are restated at `sm:`', async () => { + render( + , + ); + await waitFor(() => expect(screen.getAllByRole('listitem')).toHaveLength(1)); + + const card = screen.getByRole('listitem', { name: 'First deal' }); + const content = card.querySelector('dl')?.parentElement; + const header = content?.previousElementSibling; + expect(content).toBeTruthy(); + expect(header).toBeTruthy(); + + // CONTROL: both elements carry the responsive shorthand the rule is about. + expect(header!.className).toMatch(/(^|\s)sm:p-4(\s|$)/); + expect(content!.className).toMatch(/(^|\s)sm:p-4(\s|$)/); + // And both pad a side down at the base breakpoint. + expect(header!.className).toMatch(/(^|\s)pb-2(\s|$)/); + expect(content!.className).toMatch(/(^|\s)pt-0(\s|$)/); + + expect({ header: unrestatedSides(header!), content: unrestatedSides(content!) }).toEqual({ header: [], content: [] }); + }); +}); diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx index 1c9cffd90a..c7c9cbf141 100644 --- a/packages/plugin-list/src/ListView.tsx +++ b/packages/plugin-list/src/ListView.tsx @@ -31,7 +31,7 @@ import { useDensityMode, resolveInlineAriaProps } from '@object-ui/react'; import type { 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, filterPlatformSortableSort, applyNonGridRowCeiling, type NonGridCeilingResult } 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, filterPlatformSortableSort, applyNonGridRowCeiling, type NonGridCeilingResult, resolveNameField } from '@object-ui/core'; import { useObjectLabel, useSafeFieldLabel, createSafeTranslation, useDisplayLocale, pickLocalized } from '@object-ui/i18n'; // Two resolvers, two vocabularies — the repo spells the distinction into the // NAMES (objectui#4167). `resolveInlineI18nLabel` is the spec's own @@ -43,6 +43,8 @@ import { useObjectLabel, useSafeFieldLabel, createSafeTranslation, useDisplayLoc // (objectui#5134). import { resolveI18nLabel as resolveInlineI18nLabel, normalizeFilterOperator, PaginationConfigSchema } from '@objectstack/spec/ui'; import type { GroupingConfig, ListView as SpecListView } from '@objectstack/spec/ui'; +import { resolveInjectedSystemColumns } from '@objectstack/spec/data'; +import { SystemFieldName } from '@objectstack/spec/system'; import { usePermissions } from '@object-ui/permissions'; /** @@ -1140,6 +1142,68 @@ const DEFAULT_LIST_DISPLAY_PAGE_SIZE = readSpecDisplayPageSize(); */ export const DEFAULT_LIST_FETCH_BATCH_SIZE = 100; +/** + * The view's kanban block, as the board branch reads it: the spec's block over + * the legacy `options.kanban` bag, per key (objectui#10380). One reading for the + * branch and for `kanbanLanesPaged`, so the lane the board is built on and the + * lane its query is gated on are one answer. + */ +function kanbanBlockOf(schema: ListViewSchema): Record { + return { ...((schema.options as any)?.kanban || {}), ...((schema as any).kanban || {}) }; +} + +/** + * The board's lane field: the block's `groupByField`, else the object's + * declared lifecycle (ADR-0085 `stageField`, through the shared detector). + */ +function kanbanLaneFieldOf(schema: ListViewSchema, objectDef: unknown): string | undefined { + return kanbanBlockOf(schema).groupByField || detectStatusField(objectDef as any) || undefined; +} + +/** + * How many of the view's columns a card carries when the kanban block names + * no card fields of its own (objectui#12619). + */ +export const KANBAN_VIEW_CARD_FIELD_COUNT = 2; + +/** + * The card fields a board takes from the VIEW's columns (objectui#12619): the + * first {@link KANBAN_VIEW_CARD_FIELD_COUNT} that are not the card's title, + * its lane or its owner, by field name. + * + * The board used to be handed the view's columns whole, as entries — and a + * view's columns are `{ field }` objects as often as names, which the card + * reads as field names, so a card over `[{ field: 'name' }, { field: + * 'account' }, …]` drew nothing below its title. Names only, here, through the + * same `columnIdentity` the grid and this view's `$select` read a column by. + * The kanban block's own `columns` is the author's card list and is not cut. + */ +function kanbanCardFieldsFromView(columns: readonly unknown[], skip: ReadonlyArray): string[] { + const skipped = new Set(skip.filter((name): name is string => !!name)); + const out: string[] = []; + for (const column of columns) { + const name = columnIdentity(column); + if (!name || skipped.has(name) || out.includes(name)) continue; + out.push(name); + if (out.length === KANBAN_VIEW_CARD_FIELD_COUNT) break; + } + return out; +} + +/** + * The record field holding a record's owner, when the object has one + * (objectui#12619) — the board shows the owner's avatar off it. Read from the + * spec's own declaration, never guessed: `resolveInjectedSystemColumns` answers + * whether the object's ownership model gives its records an owner, and + * `SystemFieldName.OWNER_ID` names the field the platform injects for it. The + * object definition has to declare that field too, or there is nothing to read. + */ +function kanbanOwnerFieldOf(objectDef: any): string | undefined { + if (!objectDef || typeof objectDef !== 'object') return undefined; + if (!resolveInjectedSystemColumns(objectDef).owner) return undefined; + return objectDef.fields?.[SystemFieldName.OWNER_ID] ? SystemFieldName.OWNER_ID : undefined; +} + /** * The rows of one `find` answer, in every envelope this view's fetch accepts: a * bare array, or an object carrying `data`, `records` or `value`. One reader @@ -2488,9 +2552,43 @@ export const ListView = React.forwardRef(({ * headers carry the true counts and every group pages its own rows, so this * bar's "N records · Showing first N" would describe a window it never drew. * objectui#10881 adds the refused grouped grid (`groupingNeedsHeaderQuery`), - * which draws no rows at all. + * which draws no rows at all. objectui#12619 adds the board that pages its + * own lanes (`kanbanLanesPaged`); the footer shows the view's total for it + * instead (`boardRecordCountShown`). */ - const surfaceDrawsFetchedRows = currentView !== 'gantt' && !gridOwnsGroupedFetch && !groupingNeedsHeaderQuery; + /** + * Does the board query its own lanes? (objectui#12619) + * + * A board under this view used to draw this component's window — on a view + * declaring `pagination.pageSize: 25`, the grid's first 25 rows — bucketed + * into lanes on the client. Measured on HotCRM's 45 opportunities: 25 cards, + * the closed columns at 0, and the footer's "Showing first 25 records". A + * lane is a query of its own, so the board is handed NO rows: it asks each + * lane for its first page and its count (`lanePaging` on `ObjectKanban`), + * under the same effective filter, search and sort this component would have + * sent. The window this component still fetches feeds what it always fed + * besides the board — the empty state, the export, the filter option counts — + * and its total is the record count the footer shows for the board. + * + * Only where the lanes are known before the query — the lane field's options + * in the object definition — and with no swimlane grouping, because those + * are the boards that page their lanes; any other board keeps the window, as + * before. Only where THIS component fetches: rows handed in whole stay whole. + */ + const kanbanLaneField = currentView === 'kanban' ? kanbanLaneFieldOf(schema, objectDef) : undefined; + const kanbanLaneOptions: unknown = kanbanLaneField ? objectDef?.fields?.[kanbanLaneField]?.options : undefined; + const kanbanLanesPaged = + !!kanbanLaneField && + Array.isArray(kanbanLaneOptions) && + kanbanLaneOptions.length > 0 && + !(groupingConfig?.fields?.length) && + !Array.isArray(schema.data) && + (schema.data as any)?.provider !== 'value' && + !!schema.objectName && + typeof dataSource?.find === 'function'; + + const surfaceDrawsFetchedRows = + currentView !== 'gantt' && !gridOwnsGroupedFetch && !groupingNeedsHeaderQuery && !kanbanLanesPaged; // objectui#10572 — the data-invalidation bus (`notifyDataChanged` from // `@object-ui/react`), read the objectui#10494 way: the nonce moves when a @@ -3649,7 +3747,7 @@ export const ListView = React.forwardRef(({ */ const selfQueryFilterRef = React.useRef<{ key: string; value: unknown } | null>(null); let selfQueryFilter: unknown = authoredFilter; - if (currentView === 'gantt' || currentView === 'tree' || currentView === 'chart' || gridOwnsGroupedFetch) { + if (currentView === 'gantt' || currentView === 'tree' || currentView === 'chart' || gridOwnsGroupedFetch || kanbanLanesPaged) { try { const value = buildEffectiveFilter(authoredFilter, appliedFilters, appliedUserFilterConditions); const key = JSON.stringify(value ?? null); @@ -3804,7 +3902,7 @@ export const ListView = React.forwardRef(({ // detector — mirrors ObjectView's default so both entry paths agree. // objectDef loads async: until it lands this stays undefined and the board // re-derives lanes once it does. - const kanbanCfg = { ...(schema.options?.kanban || {}), ...(schema.kanban || {}) }; + const kanbanCfg = kanbanBlockOf(schema); // ⭐ THE BLOCK'S DECLARED KEYS ONLY (objectui#6152 round 14). The spec's // kanban block is `groupByField` / `columns` / `titleField` / // `summarizeField`, each read by name below. This branch used to read @@ -3818,8 +3916,21 @@ 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, groupByField, titleField, summarizeField } = kanbanCfg as Record; - const laneField = groupByField || detectStatusField(objectDef) || undefined; + const { columns: kanbanCardColumns, titleField, summarizeField } = kanbanCfg as Record; + const laneField = kanbanLaneFieldOf(schema, objectDef); + // 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 + // the owner, by name (`kanbanCardFieldsFromView`). + const cardFields = + Array.isArray(kanbanCardColumns) && kanbanCardColumns.length > 0 + ? kanbanCardColumns + : kanbanCardFieldsFromView(effectiveFields, [ + titleField, + resolveNameField(objectDef), + laneField, + kanbanOwnerFieldOf(objectDef), + ]); // `groupBy` is the lane key and the ONLY one written here. This node // used to carry `groupField: laneField` alongside it — a duplicate the // `object-kanban` renderer never read (zero `groupField` sites under @@ -3841,10 +3952,15 @@ export const ListView = React.forwardRef(({ ...baseProps, groupBy: laneField, ...(titleField ? { titleField } : {}), - cardFields: kanbanCardColumns || effectiveFields || [], + cardFields, ...(groupingConfig ? { grouping: groupingConfig } : {}), // objectui#11629: the board sums this field at the top of each lane. ...(summarizeField ? { summarizeField } : {}), + // objectui#12619 — a board that pages its own lanes queries with the + // EFFECTIVE filter, as every view that queries for itself does + // (`selfQueryFilter`): the toolbar Filter and the chips reach its + // lanes only here. See `kanbanLanesPaged`. + ...(kanbanLanesPaged ? { filter: selfQueryFilter } : {}), }; } case 'calendar': { @@ -4243,7 +4359,7 @@ export const ListView = React.forwardRef(({ // asynchronously (`/me/permissions`) and `objectDef` loads into state, so a // grid schema built before either resolved must be rebuilt when they do — // otherwise `editable` keeps the pre-verdict answer for the session. - }, [currentView, schema, authoredFilter, currentSort, effectiveFields, hasAuthoredColumns, groupingConfig, rowColorConfig, navigation.handleClick, density.mode, galleryCardSize, inlineEdit, inlineEditOffered, objectDef, selfQueryFilter, ganttSearchTerm, gridOwnsGroupedFetch, calendarWindowed, handleCalendarVisibleRange]); + }, [currentView, schema, authoredFilter, currentSort, effectiveFields, hasAuthoredColumns, groupingConfig, rowColorConfig, navigation.handleClick, density.mode, galleryCardSize, inlineEdit, inlineEditOffered, objectDef, selfQueryFilter, ganttSearchTerm, gridOwnsGroupedFetch, calendarWindowed, handleCalendarVisibleRange, kanbanLanesPaged]); const hasFilters = currentFilters.conditions && currentFilters.conditions.length > 0; @@ -4993,6 +5109,13 @@ export const ListView = React.forwardRef(({ // not take the summaries down with the count. const recordCountShown = !loading && data.length > 0 && surfaceDrawsFetchedRows && schema.showRecordCount !== false; + // objectui#12619 — a board that pages its own lanes draws every record of the + // view, a page per lane at a time, so the footer counts the VIEW: the total + // this component's own query answered, which is the sum of the board's lane + // counts. No "Showing first N" line: the board is not a window. Without a + // total the footer says nothing rather than count its own window. + const boardRecordCountShown = + kanbanLanesPaged && !loading && data.length > 0 && typeof fetchedTotal === 'number' && schema.showRecordCount !== false; const footerSummaryText = currentView === 'grid' && gridSummaries && gridSummaries.length > 0 ? gridSummaries.map((s) => `${s.header}: ${s.label}`).join(' · ') @@ -5900,12 +6023,34 @@ export const ListView = React.forwardRef(({ 0 + ? { searchFields: schema.searchableFields } + : {}), + reloadKey: `${refreshKey}:${String(schema.refreshTrigger ?? '')}`, + }, + } + : {})} + {...(viewComponentSchema.type === 'object-kanban' && kanbanOwnerFieldOf(objectDef) + // objectui#12619 — the owner field the object declares, for the + // avatar on each card (a host prop; the board guesses none). + ? { ownerField: kanbanOwnerFieldOf(objectDef) } + : {})} {...(gridOwnsGroupedFetch // objectui#11021 — the grid that groups on the server queries for // itself, so it is handed the toolbar term to query with (an @@ -6079,11 +6224,16 @@ export const ListView = React.forwardRef(({ (objectui#12528): the count at the left, the grid's column summaries at the right of the same row, truncated with the full text as their tooltip when they do not fit. */} - {(recordCountShown || footerSummaryText) && ( + {(recordCountShown || boardRecordCountShown || footerSummaryText) && (
+ {boardRecordCountShown && ( + + {t('list.recordCount', { count: fetchedTotal })} + + )} {recordCountShown && ( {/* Under server pagination `data` is only the current page, so the @@ -6113,7 +6263,9 @@ export const ListView = React.forwardRef(({ {/* Not on a windowed calendar either (objectui#12081): its fetch walks the date window in fetch batches, so a page size changes nothing it draws. */} - {currentView !== 'grid' && !calendarWindowed && schema.pagination?.pageSizeOptions && schema.pagination.pageSizeOptions.length > 0 && ( + {/* Nor on a board that pages its own lanes (objectui#12619): each + lane asks its own page, so a page size changes nothing it draws. */} + {currentView !== 'grid' && !calendarWindowed && !kanbanLanesPaged && schema.pagination?.pageSizeOptions && schema.pagination.pageSizeOptions.length > 0 && (
{t('table.rowsPerPage', { defaultValue: 'Rows per page' })} { const ds = makeDataSource(contractsIn(9, 122, 'oct')); renderList({ viewType: 'kanban', kanban: { groupByField: 'status' } }, ds); - await waitFor(() => expect(lastKanbanProps?.data).toHaveLength(FETCH_BATCH)); + // objectui#12619 — the board pages its own lanes and is handed no rows; + // this view's own query, below, is what it asked before. + await waitFor(() => expect(lastKanbanProps?.lanePaging).toBeDefined()); + expect(lastKanbanProps.data).toBeUndefined(); expect(ds.calls).toHaveLength(1); expect(ds.calls[0].$top).toBe(FETCH_BATCH); expect(ds.calls[0].$filter).toBeUndefined(); diff --git a/packages/plugin-list/src/__tests__/ListView.kanbanOwnsLanes-12619.test.tsx b/packages/plugin-list/src/__tests__/ListView.kanbanOwnsLanes-12619.test.tsx new file mode 100644 index 0000000000..8faee77bf3 --- /dev/null +++ b/packages/plugin-list/src/__tests__/ListView.kanbanOwnsLanes-12619.test.tsx @@ -0,0 +1,196 @@ +/** + * 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 — the list half: what this view hands the board it mounts. + * + * A board under this view used to be handed this view's window — on a view + * declaring `pagination.pageSize: 25`, one page of the grid — and the view's + * columns whole, as entries a card reads as field names. Measured on HotCRM's + * 45 opportunities: 25 cards, the closed columns at 0, "Showing first 25 + * records" in the footer, and cards carrying the record's name only. + * + * `plugin-kanban` is not a dependency of `plugin-list`, so the board slot is a + * registry stub that records the props it is handed (the device + * `ListView.viewSwitchRefetch-7394.test.tsx` uses). The real board under the + * real list is `packages/app-shell/src/__tests__/listKanbanEveryRecord-12619.test.tsx`. + */ +import React from 'react'; +import { describe, it, expect, vi, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; +import { cleanup, render, screen, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { ComponentRegistry } from '@object-ui/core'; +import { SchemaRendererProvider } from '@object-ui/react'; +import { ListView } from '../ListView'; + +const OBJECT = 'crm_opportunity'; +const TOTAL = 45; + +const FIELDS = { + name: { type: 'text', label: 'Opportunity Name' }, + account: { type: 'text', label: 'Account' }, + stage: { + type: 'select', + label: 'Stage', + options: [ + { value: 'open', label: 'Open' }, + { value: 'won', label: 'Won' }, + ], + }, + amount: { type: 'number', label: 'Amount' }, + probability: { type: 'number', label: 'Probability' }, + owner_id: { type: 'lookup', label: 'Owner', reference: 'sys_user' }, +}; + +function makeDataSource(objectDef: Record = { name: OBJECT, fields: FIELDS }) { + const find = vi.fn(async (_object: string, params: any) => { + const top = params.$top ?? TOTAL; + const rows = Array.from({ length: Math.min(top, TOTAL) }, (_, i) => ({ + id: `o-${i}`, + name: `Deal ${i}`, + account: `Account ${i}`, + stage: i % 2 === 0 ? 'open' : 'won', + amount: i, + })); + return { data: rows, total: TOTAL }; + }); + return { + find, + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn(async () => objectDef), + } as any; +} + +const VIEW = { + type: 'list-view', + objectName: OBJECT, + viewType: 'kanban', + columns: [{ field: 'name' }, { field: 'account' }, { field: 'stage' }, { field: 'amount' }, { field: 'probability' }], + pagination: { pageSize: 25, pageSizeOptions: [25, 50] }, + options: { kanban: { groupByField: 'stage', titleField: 'name' } }, +}; + +let lastKanbanProps: any = null; +let prevKanban: any; +beforeAll(() => { + prevKanban = ComponentRegistry.get('object-kanban'); + ComponentRegistry.register('object-kanban', (props: any) => { + lastKanbanProps = props; + return
; + }, { namespace: 'plugin-kanban' } as never); +}); +afterAll(() => { + if (prevKanban) ComponentRegistry.register('object-kanban', prevKanban); + else ComponentRegistry.unregister('object-kanban'); +}); +beforeEach(() => { lastKanbanProps = null; }); +afterEach(() => { cleanup(); lastKanbanProps = null; }); + +function renderList(ds: any, view: Record = VIEW) { + return render( + + + , + ); +} + +async function boardProps() { + await waitFor(() => expect(screen.queryByTestId('kanban-stub')).not.toBeNull()); + return lastKanbanProps; +} + +describe('a list view hands its board the query, not a page (objectui#12619)', () => { + it('withholds its window and hands the board the lane paging request', async () => { + const ds = makeDataSource(); + renderList(ds); + const props = await boardProps(); + + expect(props.data).toBeUndefined(); + expect(props.lanePaging).toEqual({ search: '', reloadKey: expect.any(String) }); + expect(props.schema.groupBy).toBe('stage'); + }); + + it('hands the board the EFFECTIVE filter it queries its lanes under, lowered as this view\'s own query sends it', async () => { + const ds = makeDataSource(); + renderList(ds, { ...VIEW, filter: [{ field: 'amount', operator: 'greater_than', value: 3 }] }); + const props = await boardProps(); + + const sent = JSON.stringify(props.schema.filter); + expect(sent).toContain('amount'); + // The lowered node, not the authored rule objects (`selfQueryFilter`). + expect(sent).not.toContain('"operator"'); + const ownQuery = ds.find.mock.calls[0][1]; + expect(props.schema.filter).toEqual(ownQuery.$filter); + }); + + it('the footer counts the view, and no longer says the board is its first page', async () => { + const ds = makeDataSource(); + renderList(ds); + await boardProps(); + + await waitFor(() => expect(screen.getByTestId('record-count-bar')).toHaveTextContent(`${TOTAL} records`)); + expect(screen.queryByTestId('data-limit-warning')).not.toBeInTheDocument(); + // Each lane asks its own page, so a page size would change nothing drawn. + expect(screen.queryByTestId('page-size-selector')).not.toBeInTheDocument(); + }); + + it('a card carries the view\'s next two columns past its title and its lane, by name', async () => { + const ds = makeDataSource(); + renderList(ds); + const props = await boardProps(); + + expect(props.schema.cardFields).toEqual(['account', 'amount']); + }); + + it('the kanban block\'s own `columns` is the author\'s card list, every one of it', async () => { + const ds = makeDataSource(); + renderList(ds, { ...VIEW, options: { kanban: { groupByField: 'stage', columns: ['amount', 'probability', 'account'] } } }); + const props = await boardProps(); + + expect(props.schema.cardFields).toEqual(['amount', 'probability', 'account']); + }); + + it('names the owner field the object declares, for the card\'s avatar', async () => { + const ds = makeDataSource(); + renderList(ds); + const props = await boardProps(); + + expect(props.ownerField).toBe('owner_id'); + }); + + it('CONTROL: names no owner field on an object whose ownership gives its records none', async () => { + const ds = makeDataSource({ name: OBJECT, ownership: 'org', fields: FIELDS }); + renderList(ds); + const props = await boardProps(); + + expect(props.ownerField).toBeUndefined(); + }); + + it('CONTROL: a board whose lanes are not known before the query keeps the window', async () => { + const { stage: _stage, ...rest } = FIELDS; + const ds = makeDataSource({ name: OBJECT, fields: { ...rest, stage: { type: 'text', label: 'Stage' } } }); + renderList(ds); + const props = await boardProps(); + + expect(props.lanePaging).toBeUndefined(); + expect(Array.isArray(props.data)).toBe(true); + expect(props.data).toHaveLength(25); + }); + + it('CONTROL: a board grouped into swimlanes keeps the window', async () => { + const ds = makeDataSource(); + renderList(ds, { ...VIEW, grouping: { fields: [{ field: 'account', order: 'asc', collapsed: false }] } }); + const props = await boardProps(); + + expect(props.lanePaging).toBeUndefined(); + expect(Array.isArray(props.data)).toBe(true); + }); +}); diff --git a/packages/plugin-list/src/__tests__/ListView.viewSwitchRefetch-7394.test.tsx b/packages/plugin-list/src/__tests__/ListView.viewSwitchRefetch-7394.test.tsx index 676c2b81f6..b2f88bac07 100644 --- a/packages/plugin-list/src/__tests__/ListView.viewSwitchRefetch-7394.test.tsx +++ b/packages/plugin-list/src/__tests__/ListView.viewSwitchRefetch-7394.test.tsx @@ -66,7 +66,11 @@ let lastGridProps: any = null; let kanbanMounts = 0; let lastKanbanProps: any = null; -function makeDataSource() { +/** + * `laneOptions: false` declares the lane field with no options, so the board + * cannot page its own lanes and keeps this view's window (objectui#12619). + */ +function makeDataSource({ laneOptions = true }: { laneOptions?: boolean } = {}) { const find = vi.fn(async (_object: string, params: any) => { const top = params.$top ?? PAGE_SIZE; const skip = params.$skip ?? 0; @@ -87,7 +91,9 @@ function makeDataSource() { fields: { id: { type: 'text' }, title: { type: 'text' }, - status: { type: 'select', options: [{ label: 'Todo', value: 'todo' }] }, + status: laneOptions + ? { type: 'select', options: [{ label: 'Todo', value: 'todo' }] } + : { type: 'text' }, }, }), } as any; @@ -155,11 +161,14 @@ describe('objectui#7394 — a visualization switch does not re-issue the same qu await clickView('Kanban'); - // The switch REALLY happened and the board really got the rows — a count - // taken from a page that never left the grid would pass §1 for free. + // The switch REALLY happened — a count taken from a page that never left + // the grid would pass §1 for free. The board is handed no rows since + // objectui#12619: it pages its own lanes (`lanePaging`), so the one query + // counted below is still this view's own. await waitFor(() => expect(screen.queryByTestId('kanban-stub')).not.toBeNull()); expect(kanbanMounts).toBe(1); - expect((lastKanbanProps.data ?? []).length).toBe(PAGE_SIZE); + expect(lastKanbanProps.data).toBeUndefined(); + expect(lastKanbanProps.lanePaging).toBeDefined(); // THE DEFECT: this was 2, with the second call identical to the first. expect(ds.find).toHaveBeenCalledTimes(1); @@ -226,8 +235,10 @@ describe('objectui#7394 — a visualization switch does not re-issue the same qu it('§5 CONTROL: the row-cap banner keeps the gate the fetch used to apply', async () => { // Hidden on a paged grid that knows its total — every row is reachable - // through the pager — and shown on a surface that consumes one window. - const ds = makeDataSource(); + // through the pager — and shown on a surface that consumes one window: a + // board whose lanes are not known before the query, which keeps this + // view's window (a board that pages its lanes is no window, objectui#12619). + const ds = makeDataSource({ laneOptions: false }); renderList(ds); await waitFor(() => expect(screen.queryByTestId('grid-stub')).not.toBeNull()); await waitFor(() => expect(ds.find).toHaveBeenCalledTimes(1));