Skip to content

Commit ba73a02

Browse files
os-zhuangclaude
andauthored
fix(kanban): surface off-column records in an Uncategorized lane (#2792) (#2804)
Records whose groupBy value matched no declared column were bucketed into `groups` and then dropped — `groups[col.id]` never reads a key that isn't a column id — so the board rendered empty while the list footer still counted the rows, reading as data loss. Triggered by a status the board doesn't render, an edited/removed picklist option, imported legacy data, or an empty value. - Extract the flat-data→columns bucketing into a pure, exported `bucketCardsIntoColumns` (the single choke point, now unit-testable). - Append a trailing 'Uncategorized' lane (sentinel id KANBAN_UNCOLUMNED_ID) holding every record whose key matched no column, so nothing is invisible and the visible card total reconciles with the record count. - Guard the drag-persist handler: dragging a card OUT of the lane into a real column repairs its status; dropping one IN is refused (the sentinel is not a real option). - Add kanban.uncategorized to the en/zh bundles. Tests (both red-verified against the pre-fix code): index.bucket.test.ts covers the pure bucketing (off-column, empty/null, static cards, cover mapping, no-groupBy); KanbanRenderer.uncolumned.test.tsx renders the real board through Suspense and asserts the lane + orphan card appear in the DOM. type-check caught (and this fixes) the hook-destructure at the call site — the pure test alone would have shipped it. Closes #2792 Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 20bd014 commit ba73a02

8 files changed

Lines changed: 288 additions & 49 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"@object-ui/plugin-kanban": patch
3+
"@object-ui/i18n": patch
4+
---
5+
6+
fix(kanban): surface off-column records in an "Uncategorized" lane instead of dropping them (#2792)
7+
8+
Records whose `groupBy` value matched no declared column were bucketed and then silently discarded — the board rendered empty while the list footer still counted the rows, so it read as data loss (a status the board doesn't render, an edited/removed picklist option, imported legacy data, or an empty value all triggered it). They now land in a trailing "Uncategorized" lane so no record is invisible and the visible card total reconciles with the record count. Dragging a card out of that lane into a real column repairs its status; the drag handler refuses to persist a move *into* the lane (its sentinel id is not a real option). Adds `kanban.uncategorized` to the en/zh bundles.

packages/i18n/src/locales/en.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,7 @@ const en = {
416416
deleteColumn: 'Delete column',
417417
noCards: 'No cards',
418418
cardTitlePlaceholder: 'Enter card title...',
419+
uncategorized: 'Uncategorized',
419420
},
420421
timeline: {
421422
bucket: {

packages/i18n/src/locales/zh.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -492,6 +492,7 @@ const zh = {
492492
deleteColumn: '删除列',
493493
noCards: '暂无卡片',
494494
cardTitlePlaceholder: '输入卡片标题...',
495+
uncategorized: '未分类',
495496
},
496497
timeline: {
497498
bucket: {
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* Integration DOM test for #2792: render the real KanbanRenderer (flat-data
11+
* adapter → lazy KanbanImpl) with an off-column record and assert the
12+
* "Uncategorized" lane and its card actually appear on screen — the outcome
13+
* the pure bucketing test can't observe (hook wiring + real render).
14+
*/
15+
import { describe, it, expect } from 'vitest';
16+
import { render, screen } from '@testing-library/react';
17+
import React from 'react';
18+
import { KanbanRenderer } from './index';
19+
20+
const columns = [
21+
{ id: 'todo', title: 'To Do' },
22+
{ id: 'in_progress', title: 'In Progress' },
23+
];
24+
25+
describe('KanbanRenderer — uncategorized lane (#2792)', () => {
26+
it('renders an Uncategorized lane holding the off-column card', async () => {
27+
render(
28+
<KanbanRenderer
29+
schema={{
30+
type: 'kanban',
31+
groupBy: 'status',
32+
columns,
33+
data: [
34+
{ id: '1', title: 'On the board', status: 'todo' },
35+
{ id: '2', title: 'Orphaned card', status: 'done' }, // no 'done' column
36+
],
37+
}}
38+
/>,
39+
);
40+
41+
// Lazy KanbanImpl resolves through Suspense.
42+
expect(await screen.findByText('Orphaned card')).toBeInTheDocument();
43+
expect(screen.getByText('On the board')).toBeInTheDocument();
44+
// The fallback lane header is present — the orphan is visible, not dropped.
45+
expect(screen.getByText('Uncategorized')).toBeInTheDocument();
46+
});
47+
48+
it('renders no Uncategorized lane when every record matches a column', async () => {
49+
render(
50+
<KanbanRenderer
51+
schema={{
52+
type: 'kanban',
53+
groupBy: 'status',
54+
columns,
55+
data: [{ id: '1', title: 'Matches', status: 'in_progress' }],
56+
}}
57+
/>,
58+
);
59+
60+
expect(await screen.findByText('Matches')).toBeInTheDocument();
61+
expect(screen.queryByText('Uncategorized')).not.toBeInTheDocument();
62+
});
63+
});

packages/plugin-kanban/src/ObjectKanban.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import { toast } from '@object-ui/components';
2020
import { RecordDetailDrawer, deriveRecordPageHref } from '@object-ui/plugin-detail';
2121
import { extractRecords, buildExpandFields, getRecordDisplayName } from '@object-ui/core';
2222
import { getBadgeColorClasses, getCellRenderer, resolveCellRendererType } from '@object-ui/fields';
23-
import { KanbanRenderer } from './index';
23+
import { KanbanRenderer, KANBAN_UNCOLUMNED_ID } from './index';
2424
import { KanbanSchema } from './types';
2525

2626
/**
@@ -521,6 +521,11 @@ export const ObjectKanban: React.FC<ObjectKanbanProps> = ({
521521
const groupBy = schema.groupBy;
522522
const objectName = schema.objectName;
523523
if (!groupBy || fromColumnId === toColumnId) return;
524+
// #2792: the "Uncategorized" lane is a display bucket, not a real option.
525+
// Dragging a card OUT of it into a real column repairs the record's
526+
// status (handled below); dropping one IN would write the sentinel id as
527+
// a bogus status, so refuse to persist that direction.
528+
if (toColumnId === KANBAN_UNCOLUMNED_ID) return;
524529

525530
// Optimistic local update so the card visibly stays in the new column.
526531
// Skipped when data is owned by a parent (ListView) — the parent's
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* Unit tests for `bucketCardsIntoColumns` — the single site that distributes
11+
* flat `data` + `groupBy` into per-column card arrays. Guards #2792: records
12+
* whose group value matches no declared column must NOT vanish; they land in
13+
* the trailing "Uncategorized" lane so the visible card total reconciles with
14+
* the record count.
15+
*/
16+
import { describe, it, expect } from 'vitest';
17+
import { bucketCardsIntoColumns, KANBAN_UNCOLUMNED_ID } from './index';
18+
19+
const columns = [
20+
{ id: 'todo', title: 'To Do' },
21+
{ id: 'in_progress', title: 'In Progress' },
22+
];
23+
24+
describe('bucketCardsIntoColumns', () => {
25+
it('distributes matching records into their columns', () => {
26+
const data = [
27+
{ id: '1', title: 'A', status: 'todo' },
28+
{ id: '2', title: 'B', status: 'in_progress' },
29+
];
30+
const result = bucketCardsIntoColumns(columns, data, 'status', undefined, 'Uncategorized');
31+
expect(result).toHaveLength(2);
32+
expect(result[0].cards.map((c: any) => c.id)).toEqual(['1']);
33+
expect(result[1].cards.map((c: any) => c.id)).toEqual(['2']);
34+
});
35+
36+
it('matches by column title/label as well as id (case-insensitive)', () => {
37+
const data = [{ id: '1', title: 'A', status: 'In Progress' }];
38+
const result = bucketCardsIntoColumns(columns, data, 'status', undefined, 'Uncategorized');
39+
expect(result.find((c) => c.id === 'in_progress')!.cards).toHaveLength(1);
40+
expect(result.find((c) => c.id === KANBAN_UNCOLUMNED_ID)).toBeUndefined();
41+
});
42+
43+
it('surfaces off-column records in an Uncategorized lane instead of dropping them (#2792)', () => {
44+
const data = [
45+
{ id: '1', title: 'Matches', status: 'todo' },
46+
{ id: '2', title: 'Off-column', status: 'done' }, // 'done' is no column
47+
{ id: '3', title: 'Also off', status: 'archived' },
48+
];
49+
const result = bucketCardsIntoColumns(columns, data, 'status', undefined, 'Uncategorized');
50+
51+
// Every record is visible: the whole board's cards equal the input count.
52+
const total = result.reduce((n, col) => n + col.cards.length, 0);
53+
expect(total).toBe(data.length);
54+
55+
const fallback = result.find((c) => c.id === KANBAN_UNCOLUMNED_ID);
56+
expect(fallback).toBeDefined();
57+
expect(fallback!.title).toBe('Uncategorized');
58+
expect(fallback!.cards.map((c: any) => c.id).sort()).toEqual(['2', '3']);
59+
expect(result[result.length - 1].id).toBe(KANBAN_UNCOLUMNED_ID); // trailing
60+
});
61+
62+
it('treats empty / null group values as uncategorized rather than losing them', () => {
63+
const data = [
64+
{ id: '1', title: 'No status', status: null },
65+
{ id: '2', title: 'Empty', status: '' },
66+
];
67+
const result = bucketCardsIntoColumns(columns, data, 'status', undefined, 'Uncategorized');
68+
const fallback = result.find((c) => c.id === KANBAN_UNCOLUMNED_ID);
69+
expect(fallback!.cards.map((c: any) => c.id).sort()).toEqual(['1', '2']);
70+
});
71+
72+
it('adds no Uncategorized lane when every record matches a column', () => {
73+
const data = [{ id: '1', title: 'A', status: 'todo' }];
74+
const result = bucketCardsIntoColumns(columns, data, 'status', undefined, 'Uncategorized');
75+
expect(result.find((c) => c.id === KANBAN_UNCOLUMNED_ID)).toBeUndefined();
76+
expect(result).toHaveLength(2);
77+
});
78+
79+
it('preserves static per-column cards alongside distributed data', () => {
80+
const withStatic = [
81+
{ id: 'todo', title: 'To Do', cards: [{ id: 'static-1', title: 'Pinned' }] },
82+
];
83+
const data = [{ id: '1', title: 'A', status: 'todo' }];
84+
const result = bucketCardsIntoColumns(withStatic, data, 'status', undefined, 'Uncategorized');
85+
expect(result[0].cards.map((c: any) => c.id)).toEqual(['static-1', '1']);
86+
});
87+
88+
it('returns columns as-is (cover-mapped) when there is no groupBy', () => {
89+
const withCards = [{ id: 'todo', title: 'To Do', cards: [{ id: '1', title: 'A' }] }];
90+
const result = bucketCardsIntoColumns(withCards, undefined, undefined, undefined, 'Uncategorized');
91+
expect(result).toHaveLength(1);
92+
expect(result[0].cards).toHaveLength(1);
93+
expect(result.find((c) => c.id === KANBAN_UNCOLUMNED_ID)).toBeUndefined();
94+
});
95+
96+
it('maps the cover image field onto cards (including uncategorized ones)', () => {
97+
const data = [
98+
{ id: '1', title: 'A', status: 'todo', avatar: 'https://img/a.png' },
99+
{ id: '2', title: 'B', status: 'gone', avatar: { url: 'https://img/b.png' } },
100+
];
101+
const result = bucketCardsIntoColumns(columns, data, 'status', 'avatar', 'Uncategorized');
102+
expect(result.find((c) => c.id === 'todo')!.cards[0].coverImage).toBe('https://img/a.png');
103+
expect(result.find((c) => c.id === KANBAN_UNCOLUMNED_ID)!.cards[0].coverImage).toBe('https://img/b.png');
104+
});
105+
});

packages/plugin-kanban/src/index.tsx

Lines changed: 103 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,99 @@ import React, { Suspense } from 'react';
1010
import { ComponentRegistry } from '@object-ui/core';
1111
import { useSchemaContext } from '@object-ui/react';
1212
import { Skeleton } from '@object-ui/components';
13+
import { createSafeTranslation } from '@object-ui/i18n';
1314
import type { KanbanConditionalFormattingRule } from '@object-ui/types';
1415
import { ObjectKanban } from './ObjectKanban';
1516

17+
/**
18+
* Sentinel column id for records whose `groupBy` value matches no declared
19+
* column (a status the board doesn't render, an edited/removed picklist
20+
* option, imported legacy data, or an empty value). Before #2792 these were
21+
* accumulated during bucketing and then silently dropped — the board looked
22+
* empty while the list footer still counted the rows. They now surface in a
23+
* trailing "Uncategorized" lane so no record is invisible and the visible
24+
* card total reconciles with the record count. Exported so the drag handler
25+
* can refuse to persist this non-option value as a real status.
26+
*/
27+
export const KANBAN_UNCOLUMNED_ID = '__uncolumned__';
28+
29+
const useUncolumnedT = createSafeTranslation(
30+
{ 'kanban.uncategorized': 'Uncategorized' },
31+
'kanban.uncategorized',
32+
);
33+
34+
/**
35+
* The single place flat `data` + `groupBy` is bucketed into per-column card
36+
* arrays. Kept pure (title passed in, not translated here) so it can be unit
37+
* tested directly — see index.bucket.test.ts. Records whose group value maps
38+
* to no declared column land in a trailing `KANBAN_UNCOLUMNED_ID` lane
39+
* instead of being dropped (#2792).
40+
*/
41+
export function bucketCardsIntoColumns(
42+
columns: Array<any>,
43+
data: Array<any> | undefined,
44+
groupBy: string | undefined,
45+
coverImageField: string | undefined,
46+
uncolumnedTitle: string,
47+
): Array<any> {
48+
const mapCoverImage = (item: any) => {
49+
if (!coverImageField) return item;
50+
const imgValue = item[coverImageField];
51+
if (!imgValue) return item;
52+
const coverImage = typeof imgValue === 'string' ? imgValue : imgValue?.url;
53+
return coverImage ? { ...item, coverImage } : item;
54+
};
55+
56+
// No flat data / grouping key: return columns as-is (cover-mapped).
57+
if (!data || !groupBy || !Array.isArray(data)) {
58+
return columns.map((col: any) => ({
59+
...col,
60+
cards: (col.cards || []).map(mapCoverImage),
61+
}));
62+
}
63+
64+
// Build label→id mapping so data values (labels like "In Progress") match
65+
// column IDs (option values like "in_progress").
66+
const labelToColumnId: Record<string, string> = {};
67+
columns.forEach((col: any) => {
68+
if (col.id) labelToColumnId[String(col.id).toLowerCase()] = col.id;
69+
if (col.title) labelToColumnId[String(col.title).toLowerCase()] = col.id;
70+
});
71+
72+
// 1. Group data by key, normalizing via label→id mapping.
73+
const groups = data.reduce((acc, item) => {
74+
const rawKey = String(item[groupBy] ?? '');
75+
const key = labelToColumnId[rawKey.toLowerCase()] ?? rawKey;
76+
if (!acc[key]) acc[key] = [];
77+
acc[key].push(mapCoverImage(item));
78+
return acc;
79+
}, {} as Record<string, any[]>);
80+
81+
// 2. Inject into declared columns.
82+
const mapped = columns.map((col: any) => ({
83+
...col,
84+
cards: [
85+
...(col.cards || []).map(mapCoverImage), // Preserve static cards
86+
...(groups[col.id] || []), // Add dynamic cards
87+
],
88+
}));
89+
90+
// 3. Catch records whose group key matched no column (#2792). Without this
91+
// they sit in `groups` and are dropped — `groups[col.id]` never reads a key
92+
// that isn't a column id — so the board silently loses rows the list footer
93+
// still counts. Surface them in a trailing "Uncategorized" lane; dragging
94+
// one out to a real column repairs its status (the drag handler refuses to
95+
// persist a move INTO here).
96+
const knownIds = new Set(columns.map((col: any) => col.id));
97+
const uncolumnedCards = Object.keys(groups)
98+
.filter((key) => !knownIds.has(key))
99+
.flatMap((key) => groups[key]);
100+
if (uncolumnedCards.length > 0) {
101+
mapped.push({ id: KANBAN_UNCOLUMNED_ID, title: uncolumnedTitle, cards: uncolumnedCards });
102+
}
103+
return mapped;
104+
}
105+
16106
// Export types for external use
17107
export type { KanbanSchema, KanbanCard, KanbanColumn, CardTemplate, ColumnWidthConfig, InlineFieldDefinition } from './types';
18108
export { ObjectKanban };
@@ -57,54 +147,19 @@ export interface KanbanRendererProps {
57147
* This wrapper handles lazy loading internally using React.Suspense
58148
*/
59149
export const KanbanRenderer: React.FC<KanbanRendererProps> = ({ schema }) => {
60-
// ⚡️ Adapter: Map flat 'data' + 'groupBy' to nested 'cards' structure
61-
const processedColumns = React.useMemo(() => {
62-
const { columns = [], data, groupBy, coverImageField } = schema;
63-
64-
// Helper to map cover image field onto cards
65-
const mapCoverImage = (item: any) => {
66-
if (!coverImageField) return item;
67-
const imgValue = item[coverImageField];
68-
if (!imgValue) return item;
69-
const coverImage = typeof imgValue === 'string' ? imgValue : imgValue?.url;
70-
return coverImage ? { ...item, coverImage } : item;
71-
};
72-
73-
// If we have flat data and a grouping key, distribute items into columns
74-
if (data && groupBy && Array.isArray(data)) {
75-
// Build label→id mapping so data values (labels like "In Progress")
76-
// match column IDs (option values like "in_progress")
77-
const labelToColumnId: Record<string, string> = {};
78-
columns.forEach((col: any) => {
79-
if (col.id) labelToColumnId[String(col.id).toLowerCase()] = col.id;
80-
if (col.title) labelToColumnId[String(col.title).toLowerCase()] = col.id;
81-
});
82-
83-
// 1. Group data by key, normalizing via label→id mapping
84-
const groups = data.reduce((acc, item) => {
85-
const rawKey = String(item[groupBy] ?? '');
86-
const key = labelToColumnId[rawKey.toLowerCase()] ?? rawKey;
87-
if (!acc[key]) acc[key] = [];
88-
acc[key].push(mapCoverImage(item));
89-
return acc;
90-
}, {} as Record<string, any[]>);
91-
92-
// 2. Inject into columns
93-
return columns.map((col: any) => ({
94-
...col,
95-
cards: [
96-
...(col.cards || []).map(mapCoverImage), // Preserve static cards
97-
...(groups[col.id] || []) // Add dynamic cards
98-
]
99-
}));
100-
}
101-
102-
// Default: Return columns as-is, mapping cover images
103-
return columns.map((col: any) => ({
104-
...col,
105-
cards: (col.cards || []).map(mapCoverImage),
106-
}));
107-
}, [schema]);
150+
const { t } = useUncolumnedT();
151+
// ⚡️ Adapter: Map flat 'data' + 'groupBy' to nested 'cards' structure.
152+
const processedColumns = React.useMemo(
153+
() =>
154+
bucketCardsIntoColumns(
155+
schema.columns ?? [],
156+
schema.data,
157+
schema.groupBy,
158+
schema.coverImageField,
159+
t('kanban.uncategorized'),
160+
),
161+
[schema, t],
162+
);
108163

109164
return (
110165
<Suspense fallback={<Skeleton className="w-full h-[600px]" />}>

vitest.config.mts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ const heavyDomTests = [
9191
'packages/plugin-dashboard/src/__tests__/DashboardRenderer.filters.test.tsx',
9292
'packages/plugin-dashboard/src/__tests__/DashboardRenderer.legacyRetired.test.tsx',
9393
'packages/plugin-kanban/src/registration.test.tsx',
94+
'packages/plugin-kanban/src/KanbanRenderer.uncolumned.test.tsx',
9495
];
9596

9697
export default defineConfig({

0 commit comments

Comments
 (0)