Skip to content

Commit 7cbb7a1

Browse files
authored
Merge pull request #779 from objectstack-ai/copilot/add-grouping-support-to-all-views
2 parents 0b76744 + 2e31e8f commit 7cbb7a1

File tree

12 files changed

+1207
-79
lines changed

12 files changed

+1207
-79
lines changed
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
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 React from 'react';
10+
import { ChevronRight, ChevronDown } from 'lucide-react';
11+
import type { AggregationResult } from './useGroupedData';
12+
13+
export interface GroupRowProps {
14+
/** Unique key identifying this group */
15+
groupKey: string;
16+
/** Display label for the group (field value or "(empty)") */
17+
label: string;
18+
/** Number of rows in this group */
19+
count: number;
20+
/** Whether the group is collapsed */
21+
collapsed: boolean;
22+
/** Computed aggregation results for this group */
23+
aggregations?: AggregationResult[];
24+
/** Callback when the group header is clicked to toggle collapse */
25+
onToggle: (key: string) => void;
26+
/** Children to render when not collapsed (the group content) */
27+
children: React.ReactNode;
28+
}
29+
30+
/**
31+
* GroupRow renders a collapsible group header with field value, record count,
32+
* and optional aggregation summary. Used by ObjectGrid for grouped rendering.
33+
*/
34+
export const GroupRow: React.FC<GroupRowProps> = ({
35+
groupKey,
36+
label,
37+
count,
38+
collapsed,
39+
aggregations,
40+
onToggle,
41+
children,
42+
}) => {
43+
return (
44+
<div className="border rounded-md" data-testid={`group-row-${groupKey}`}>
45+
<button
46+
type="button"
47+
className="flex w-full items-center gap-2 px-3 py-2 text-sm font-medium text-left bg-muted/50 hover:bg-muted transition-colors"
48+
onClick={() => onToggle(groupKey)}
49+
aria-expanded={!collapsed}
50+
>
51+
{collapsed
52+
? <ChevronRight className="h-4 w-4 shrink-0" />
53+
: <ChevronDown className="h-4 w-4 shrink-0" />}
54+
<span className="group-label">{label}</span>
55+
{aggregations && aggregations.length > 0 && (
56+
<span className="ml-2 text-xs text-muted-foreground group-aggregations">
57+
{aggregations.map((agg) => (
58+
<span key={`${agg.field}-${agg.type}`} className="mr-2">
59+
{agg.type}: {Number.isInteger(agg.value) ? agg.value : agg.value.toFixed(2)}
60+
</span>
61+
))}
62+
</span>
63+
)}
64+
<span className="ml-auto text-xs text-muted-foreground group-count">({count})</span>
65+
</button>
66+
{!collapsed && children}
67+
</div>
68+
);
69+
};

packages/plugin-grid/src/ObjectGrid.tsx

Lines changed: 12 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import { usePullToRefresh } from '@object-ui/mobile';
3434
import { Edit, Trash2, MoreVertical, ChevronRight, ChevronDown, Download, Rows2, Rows3, Rows4, AlignJustify, Type, Hash, Calendar, CheckSquare, User, Tag, Clock } from 'lucide-react';
3535
import { useRowColor } from './useRowColor';
3636
import { useGroupedData } from './useGroupedData';
37+
import { GroupRow } from './GroupRow';
3738

3839
export interface ObjectGridProps {
3940
schema: ObjectGridSchema;
@@ -1200,22 +1201,17 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
12001201
const gridContent = isGrouped ? (
12011202
<div className="space-y-2">
12021203
{groups.map((group) => (
1203-
<div key={group.key} className="border rounded-md">
1204-
<button
1205-
type="button"
1206-
className="flex w-full items-center gap-2 px-3 py-2 text-sm font-medium text-left bg-muted/50 hover:bg-muted transition-colors"
1207-
onClick={() => toggleGroup(group.key)}
1208-
>
1209-
{group.collapsed
1210-
? <ChevronRight className="h-4 w-4 shrink-0" />
1211-
: <ChevronDown className="h-4 w-4 shrink-0" />}
1212-
<span>{group.label}</span>
1213-
<span className="ml-auto text-xs text-muted-foreground">{group.rows.length}</span>
1214-
</button>
1215-
{!group.collapsed && (
1216-
<SchemaRenderer schema={buildGroupTableSchema(group.rows)} />
1217-
)}
1218-
</div>
1204+
<GroupRow
1205+
key={group.key}
1206+
groupKey={group.key}
1207+
label={group.label}
1208+
count={group.rows.length}
1209+
collapsed={group.collapsed}
1210+
aggregations={group.aggregations}
1211+
onToggle={toggleGroup}
1212+
>
1213+
<SchemaRenderer schema={buildGroupTableSchema(group.rows)} />
1214+
</GroupRow>
12191215
))}
12201216
</div>
12211217
) : (
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
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, fireEvent } from '@testing-library/react';
11+
import React from 'react';
12+
import { GroupRow } from '../GroupRow';
13+
14+
describe('GroupRow', () => {
15+
it('renders group label and count', () => {
16+
render(
17+
<GroupRow
18+
groupKey="electronics"
19+
label="Electronics"
20+
count={5}
21+
collapsed={false}
22+
onToggle={() => {}}
23+
>
24+
<div>Content</div>
25+
</GroupRow>,
26+
);
27+
28+
expect(screen.getByText('Electronics')).toBeInTheDocument();
29+
expect(screen.getByText('(5)')).toBeInTheDocument();
30+
});
31+
32+
it('renders children when not collapsed', () => {
33+
render(
34+
<GroupRow
35+
groupKey="tools"
36+
label="Tools"
37+
count={3}
38+
collapsed={false}
39+
onToggle={() => {}}
40+
>
41+
<div data-testid="group-content">Group Content</div>
42+
</GroupRow>,
43+
);
44+
45+
expect(screen.getByTestId('group-content')).toBeInTheDocument();
46+
});
47+
48+
it('hides children when collapsed', () => {
49+
render(
50+
<GroupRow
51+
groupKey="tools"
52+
label="Tools"
53+
count={3}
54+
collapsed={true}
55+
onToggle={() => {}}
56+
>
57+
<div data-testid="group-content">Group Content</div>
58+
</GroupRow>,
59+
);
60+
61+
expect(screen.queryByTestId('group-content')).not.toBeInTheDocument();
62+
});
63+
64+
it('shows ChevronDown when expanded', () => {
65+
render(
66+
<GroupRow
67+
groupKey="tools"
68+
label="Tools"
69+
count={3}
70+
collapsed={false}
71+
onToggle={() => {}}
72+
>
73+
<div>Content</div>
74+
</GroupRow>,
75+
);
76+
77+
// Lucide renders SVGs with class 'lucide-chevron-down'
78+
const button = screen.getByRole('button');
79+
expect(button.querySelector('.lucide-chevron-down')).toBeInTheDocument();
80+
expect(button.querySelector('.lucide-chevron-right')).not.toBeInTheDocument();
81+
});
82+
83+
it('shows ChevronRight when collapsed', () => {
84+
render(
85+
<GroupRow
86+
groupKey="tools"
87+
label="Tools"
88+
count={3}
89+
collapsed={true}
90+
onToggle={() => {}}
91+
>
92+
<div>Content</div>
93+
</GroupRow>,
94+
);
95+
96+
const button = screen.getByRole('button');
97+
expect(button.querySelector('.lucide-chevron-right')).toBeInTheDocument();
98+
expect(button.querySelector('.lucide-chevron-down')).not.toBeInTheDocument();
99+
});
100+
101+
it('calls onToggle with groupKey when header is clicked', () => {
102+
const onToggle = vi.fn();
103+
render(
104+
<GroupRow
105+
groupKey="electronics"
106+
label="Electronics"
107+
count={5}
108+
collapsed={false}
109+
onToggle={onToggle}
110+
>
111+
<div>Content</div>
112+
</GroupRow>,
113+
);
114+
115+
fireEvent.click(screen.getByRole('button'));
116+
expect(onToggle).toHaveBeenCalledWith('electronics');
117+
expect(onToggle).toHaveBeenCalledTimes(1);
118+
});
119+
120+
it('sets aria-expanded=true when expanded', () => {
121+
render(
122+
<GroupRow
123+
groupKey="tools"
124+
label="Tools"
125+
count={3}
126+
collapsed={false}
127+
onToggle={() => {}}
128+
>
129+
<div>Content</div>
130+
</GroupRow>,
131+
);
132+
133+
expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'true');
134+
});
135+
136+
it('sets aria-expanded=false when collapsed', () => {
137+
render(
138+
<GroupRow
139+
groupKey="tools"
140+
label="Tools"
141+
count={3}
142+
collapsed={true}
143+
onToggle={() => {}}
144+
>
145+
<div>Content</div>
146+
</GroupRow>,
147+
);
148+
149+
expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'false');
150+
});
151+
152+
it('renders aggregation summary when provided', () => {
153+
const aggregations = [
154+
{ field: 'amount', type: 'sum' as const, value: 150 },
155+
{ field: 'amount', type: 'avg' as const, value: 37.5 },
156+
];
157+
render(
158+
<GroupRow
159+
groupKey="electronics"
160+
label="Electronics"
161+
count={4}
162+
collapsed={false}
163+
aggregations={aggregations}
164+
onToggle={() => {}}
165+
>
166+
<div>Content</div>
167+
</GroupRow>,
168+
);
169+
170+
expect(screen.getByText(/sum: 150/)).toBeInTheDocument();
171+
expect(screen.getByText(/avg: 37.50/)).toBeInTheDocument();
172+
});
173+
174+
it('does not render aggregation section when aggregations is empty', () => {
175+
render(
176+
<GroupRow
177+
groupKey="electronics"
178+
label="Electronics"
179+
count={4}
180+
collapsed={false}
181+
aggregations={[]}
182+
onToggle={() => {}}
183+
>
184+
<div>Content</div>
185+
</GroupRow>,
186+
);
187+
188+
expect(screen.queryByText(/sum:/)).not.toBeInTheDocument();
189+
});
190+
191+
it('renders data-testid with group key', () => {
192+
render(
193+
<GroupRow
194+
groupKey="electronics"
195+
label="Electronics"
196+
count={5}
197+
collapsed={false}
198+
onToggle={() => {}}
199+
>
200+
<div>Content</div>
201+
</GroupRow>,
202+
);
203+
204+
expect(screen.getByTestId('group-row-electronics')).toBeInTheDocument();
205+
});
206+
});

0 commit comments

Comments
 (0)