Skip to content

Commit d112c28

Browse files
Copilothotlong
andcommitted
feat: add grouping support to Gallery and Kanban views, pass groupingConfig from ListView
- ListView.tsx: pass groupingConfig to kanban and gallery view schemas - ObjectGallery.tsx: add grouped sections with collapsible headers - ObjectKanban.tsx: map grouping.fields[0].field to swimlaneField fallback - KanbanSchema: add optional grouping field - Add GalleryGrouping.test.tsx (8 tests) and KanbanGrouping.test.tsx (6 tests) Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent 2824cc6 commit d112c28

6 files changed

Lines changed: 553 additions & 63 deletions

File tree

packages/plugin-kanban/src/ObjectKanban.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,11 +188,16 @@ export const ObjectKanban: React.FC<ObjectKanbanProps> = ({
188188
}, [schema.columns, schema.groupBy, effectiveData, objectDef]);
189189

190190
// Clone schema to inject data and className
191+
// Use grouping.fields[0].field as swimlaneField fallback when no explicit swimlaneField
192+
const effectiveSwimlaneField = schema.swimlaneField
193+
|| (schema.grouping?.fields?.[0]?.field);
194+
191195
const effectiveSchema = {
192196
...schema,
193197
data: effectiveData,
194198
columns: effectiveColumns,
195-
className: className || schema.className
199+
className: className || schema.className,
200+
...(effectiveSwimlaneField ? { swimlaneField: effectiveSwimlaneField } : {}),
196201
};
197202

198203
const navigation = useNavigationOverlay({
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
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+
import { describe, it, expect, vi } from 'vitest';
10+
import { render, screen, act } from '@testing-library/react';
11+
import React, { Suspense } from 'react';
12+
13+
// Mock dnd-kit
14+
vi.mock('@dnd-kit/core', () => ({
15+
DndContext: ({ children }: any) => <div data-testid="dnd-context">{children}</div>,
16+
DragOverlay: ({ children }: any) => <div data-testid="drag-overlay">{children}</div>,
17+
PointerSensor: vi.fn(),
18+
TouchSensor: vi.fn(),
19+
useSensor: vi.fn(),
20+
useSensors: () => [],
21+
closestCorners: vi.fn(),
22+
}));
23+
24+
vi.mock('@dnd-kit/sortable', () => ({
25+
SortableContext: ({ children }: any) => <div data-testid="sortable-context">{children}</div>,
26+
useSortable: () => ({
27+
attributes: {},
28+
listeners: {},
29+
setNodeRef: vi.fn(),
30+
transform: null,
31+
transition: null,
32+
isDragging: false,
33+
}),
34+
arrayMove: (array: any[], from: number, to: number) => {
35+
const newArray = [...array];
36+
newArray.splice(to, 0, newArray.splice(from, 1)[0]);
37+
return newArray;
38+
},
39+
verticalListSortingStrategy: vi.fn(),
40+
}));
41+
42+
vi.mock('@dnd-kit/utilities', () => ({
43+
CSS: {
44+
Transform: {
45+
toString: () => '',
46+
},
47+
},
48+
}));
49+
50+
vi.mock('@object-ui/components', () => ({
51+
Badge: ({ children, ...props }: any) => <span {...props}>{children}</span>,
52+
Card: ({ children, ...props }: any) => <div {...props}>{children}</div>,
53+
CardHeader: ({ children, ...props }: any) => <div {...props}>{children}</div>,
54+
CardTitle: ({ children, ...props }: any) => <div {...props}>{children}</div>,
55+
CardDescription: ({ children, ...props }: any) => <div {...props}>{children}</div>,
56+
CardContent: ({ children, ...props }: any) => <div {...props}>{children}</div>,
57+
ScrollArea: ({ children, ...props }: any) => <div {...props}>{children}</div>,
58+
Button: ({ children, ...props }: any) => <button {...props}>{children}</button>,
59+
Input: (props: any) => <input {...props} />,
60+
Skeleton: ({ className }: any) => <div data-testid="skeleton" className={className} />,
61+
NavigationOverlay: ({ children, selectedRecord }: any) => (
62+
selectedRecord ? <div data-testid="navigation-overlay">{children(selectedRecord)}</div> : null
63+
),
64+
}));
65+
66+
vi.mock('@object-ui/react', () => ({
67+
useHasDndProvider: () => false,
68+
useDnd: () => ({
69+
startDrag: vi.fn(),
70+
endDrag: vi.fn(),
71+
}),
72+
useDataScope: () => undefined,
73+
useNavigationOverlay: () => ({
74+
isOverlay: false,
75+
handleClick: vi.fn(),
76+
selectedRecord: null,
77+
isOpen: false,
78+
close: vi.fn(),
79+
setIsOpen: vi.fn(),
80+
mode: 'page' as const,
81+
}),
82+
}));
83+
84+
vi.mock('lucide-react', () => ({
85+
Plus: () => <span>+</span>,
86+
}));
87+
88+
// Import KanbanBoard (the impl) directly to avoid lazy-loading issues in tests
89+
import KanbanBoard from '../KanbanImpl';
90+
91+
const mockColumns = [
92+
{
93+
id: 'todo',
94+
title: 'To Do',
95+
cards: [
96+
{ id: 'c1', title: 'Task 1', priority: 'High', team: 'Frontend' },
97+
{ id: 'c2', title: 'Task 2', priority: 'Low', team: 'Backend' },
98+
],
99+
},
100+
{
101+
id: 'done',
102+
title: 'Done',
103+
cards: [
104+
{ id: 'c3', title: 'Task 3', priority: 'High', team: 'Frontend' },
105+
{ id: 'c4', title: 'Task 4', priority: 'Medium', team: 'Backend' },
106+
],
107+
},
108+
];
109+
110+
describe('ObjectKanban grouping config → swimlaneField mapping', () => {
111+
it('uses grouping field as swimlane when passed to KanbanImpl', () => {
112+
// This simulates what ObjectKanban does: map grouping.fields[0].field to swimlaneField
113+
render(<KanbanBoard columns={mockColumns} swimlaneField="team" />);
114+
115+
// Swimlane layout should render
116+
expect(screen.getByRole('region', { name: 'Kanban board with swimlanes' })).toBeInTheDocument();
117+
118+
// Swimlane headers for each team
119+
expect(screen.getByText('Backend')).toBeInTheDocument();
120+
expect(screen.getByText('Frontend')).toBeInTheDocument();
121+
});
122+
123+
it('renders flat kanban when no swimlane/grouping is provided', () => {
124+
render(<KanbanBoard columns={mockColumns} />);
125+
126+
// Flat layout renders "Kanban board"
127+
expect(screen.getByRole('region', { name: 'Kanban board' })).toBeInTheDocument();
128+
expect(screen.queryByRole('region', { name: 'Kanban board with swimlanes' })).not.toBeInTheDocument();
129+
});
130+
131+
describe('ObjectKanban swimlaneField resolution logic', () => {
132+
// Test the resolution logic independently (same as ObjectKanban.tsx effectiveSwimlaneField)
133+
function resolveEffectiveSwimlaneField(
134+
swimlaneField?: string,
135+
grouping?: { fields: Array<{ field: string; order: string; collapsed: boolean }> },
136+
): string | undefined {
137+
return swimlaneField || grouping?.fields?.[0]?.field;
138+
}
139+
140+
it('prefers explicit swimlaneField over grouping', () => {
141+
const result = resolveEffectiveSwimlaneField('priority', {
142+
fields: [{ field: 'team', order: 'asc', collapsed: false }],
143+
});
144+
expect(result).toBe('priority');
145+
});
146+
147+
it('falls back to grouping.fields[0].field when no swimlaneField', () => {
148+
const result = resolveEffectiveSwimlaneField(undefined, {
149+
fields: [{ field: 'team', order: 'asc', collapsed: false }],
150+
});
151+
expect(result).toBe('team');
152+
});
153+
154+
it('returns undefined when neither swimlaneField nor grouping is set', () => {
155+
const result = resolveEffectiveSwimlaneField(undefined, undefined);
156+
expect(result).toBeUndefined();
157+
});
158+
159+
it('returns undefined when grouping has empty fields array', () => {
160+
const result = resolveEffectiveSwimlaneField(undefined, { fields: [] });
161+
expect(result).toBeUndefined();
162+
});
163+
});
164+
});

packages/plugin-kanban/src/types.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* LICENSE file in the root directory of this source tree.
77
*/
88

9-
import type { BaseSchema } from '@object-ui/types';
9+
import type { BaseSchema, GroupingConfig } from '@object-ui/types';
1010

1111
/**
1212
* Kanban card interface.
@@ -130,6 +130,12 @@ export interface KanbanSchema extends BaseSchema {
130130
* Supports per-column overrides with min/max constraints.
131131
*/
132132
columnWidths?: ColumnWidthConfig;
133+
134+
/**
135+
* Grouping configuration from ListView.
136+
* When set, the first grouping field is used as swimlaneField fallback.
137+
*/
138+
grouping?: GroupingConfig;
133139
}
134140

135141
/**

packages/plugin-list/src/ListView.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -756,6 +756,7 @@ export const ListView: React.FC<ListViewProps> = ({
756756
groupField: schema.kanban?.groupField || schema.options?.kanban?.groupField || 'status',
757757
titleField: schema.kanban?.titleField || schema.options?.kanban?.titleField || 'name',
758758
cardFields: schema.kanban?.cardFields || effectiveFields || [],
759+
...(groupingConfig ? { grouping: groupingConfig } : {}),
759760
...(schema.options?.kanban || {}),
760761
...(schema.kanban || {}),
761762
};
@@ -780,6 +781,7 @@ export const ListView: React.FC<ListViewProps> = ({
780781
...(schema.gallery?.coverFit ? { coverFit: schema.gallery.coverFit } : {}),
781782
...(schema.gallery?.cardSize ? { cardSize: schema.gallery.cardSize } : {}),
782783
...(schema.gallery?.visibleFields ? { visibleFields: schema.gallery.visibleFields } : {}),
784+
...(groupingConfig ? { grouping: groupingConfig } : {}),
783785
...(schema.options?.gallery || {}),
784786
...(schema.gallery || {}),
785787
};

0 commit comments

Comments
 (0)