Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions packages/plugin-grid/src/GroupRow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/**
* 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 React from 'react';
import { ChevronRight, ChevronDown } from 'lucide-react';
import type { AggregationResult } from './useGroupedData';

export interface GroupRowProps {
/** Unique key identifying this group */
groupKey: string;
/** Display label for the group (field value or "(empty)") */
label: string;
/** Number of rows in this group */
count: number;
/** Whether the group is collapsed */
collapsed: boolean;
/** Computed aggregation results for this group */
aggregations?: AggregationResult[];
/** Callback when the group header is clicked to toggle collapse */
onToggle: (key: string) => void;
/** Children to render when not collapsed (the group content) */
children: React.ReactNode;
}

/**
* GroupRow renders a collapsible group header with field value, record count,
* and optional aggregation summary. Used by ObjectGrid for grouped rendering.
*/
export const GroupRow: React.FC<GroupRowProps> = ({
groupKey,
label,
count,
collapsed,
aggregations,
onToggle,
children,
}) => {
return (
<div className="border rounded-md" data-testid={`group-row-${groupKey}`}>
<button
type="button"
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"
onClick={() => onToggle(groupKey)}
aria-expanded={!collapsed}
>
{collapsed
? <ChevronRight className="h-4 w-4 shrink-0" />
: <ChevronDown className="h-4 w-4 shrink-0" />}
<span className="group-label">{label}</span>
{aggregations && aggregations.length > 0 && (
<span className="ml-2 text-xs text-muted-foreground group-aggregations">
{aggregations.map((agg) => (
<span key={`${agg.field}-${agg.type}`} className="mr-2">
{agg.type}: {Number.isInteger(agg.value) ? agg.value : agg.value.toFixed(2)}
</span>
))}
</span>
)}
<span className="ml-auto text-xs text-muted-foreground group-count">({count})</span>
</button>
{!collapsed && children}
</div>
);
};
28 changes: 12 additions & 16 deletions packages/plugin-grid/src/ObjectGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { usePullToRefresh } from '@object-ui/mobile';
import { Edit, Trash2, MoreVertical, ChevronRight, ChevronDown, Download, Rows2, Rows3, Rows4, AlignJustify, Type, Hash, Calendar, CheckSquare, User, Tag, Clock } from 'lucide-react';
import { useRowColor } from './useRowColor';
import { useGroupedData } from './useGroupedData';
import { GroupRow } from './GroupRow';

export interface ObjectGridProps {
schema: ObjectGridSchema;
Expand Down Expand Up @@ -1200,22 +1201,17 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
const gridContent = isGrouped ? (
<div className="space-y-2">
{groups.map((group) => (
<div key={group.key} className="border rounded-md">
<button
type="button"
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"
onClick={() => toggleGroup(group.key)}
>
{group.collapsed
? <ChevronRight className="h-4 w-4 shrink-0" />
: <ChevronDown className="h-4 w-4 shrink-0" />}
<span>{group.label}</span>
<span className="ml-auto text-xs text-muted-foreground">{group.rows.length}</span>
</button>
{!group.collapsed && (
<SchemaRenderer schema={buildGroupTableSchema(group.rows)} />
)}
</div>
<GroupRow
key={group.key}
groupKey={group.key}
label={group.label}
count={group.rows.length}
collapsed={group.collapsed}
aggregations={group.aggregations}
onToggle={toggleGroup}
>
<SchemaRenderer schema={buildGroupTableSchema(group.rows)} />
</GroupRow>
))}
</div>
) : (
Expand Down
206 changes: 206 additions & 0 deletions packages/plugin-grid/src/__tests__/GroupRow.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
/**
* 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 { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import React from 'react';
import { GroupRow } from '../GroupRow';

describe('GroupRow', () => {
it('renders group label and count', () => {
render(
<GroupRow
groupKey="electronics"
label="Electronics"
count={5}
collapsed={false}
onToggle={() => {}}
>
<div>Content</div>
</GroupRow>,
);

expect(screen.getByText('Electronics')).toBeInTheDocument();
expect(screen.getByText('(5)')).toBeInTheDocument();
});

it('renders children when not collapsed', () => {
render(
<GroupRow
groupKey="tools"
label="Tools"
count={3}
collapsed={false}
onToggle={() => {}}
>
<div data-testid="group-content">Group Content</div>
</GroupRow>,
);

expect(screen.getByTestId('group-content')).toBeInTheDocument();
});

it('hides children when collapsed', () => {
render(
<GroupRow
groupKey="tools"
label="Tools"
count={3}
collapsed={true}
onToggle={() => {}}
>
<div data-testid="group-content">Group Content</div>
</GroupRow>,
);

expect(screen.queryByTestId('group-content')).not.toBeInTheDocument();
});

it('shows ChevronDown when expanded', () => {
render(
<GroupRow
groupKey="tools"
label="Tools"
count={3}
collapsed={false}
onToggle={() => {}}
>
<div>Content</div>
</GroupRow>,
);

// Lucide renders SVGs with class 'lucide-chevron-down'
const button = screen.getByRole('button');
expect(button.querySelector('.lucide-chevron-down')).toBeInTheDocument();
expect(button.querySelector('.lucide-chevron-right')).not.toBeInTheDocument();
});

it('shows ChevronRight when collapsed', () => {
render(
<GroupRow
groupKey="tools"
label="Tools"
count={3}
collapsed={true}
onToggle={() => {}}
>
<div>Content</div>
</GroupRow>,
);

const button = screen.getByRole('button');
expect(button.querySelector('.lucide-chevron-right')).toBeInTheDocument();
expect(button.querySelector('.lucide-chevron-down')).not.toBeInTheDocument();
});

it('calls onToggle with groupKey when header is clicked', () => {
const onToggle = vi.fn();
render(
<GroupRow
groupKey="electronics"
label="Electronics"
count={5}
collapsed={false}
onToggle={onToggle}
>
<div>Content</div>
</GroupRow>,
);

fireEvent.click(screen.getByRole('button'));
expect(onToggle).toHaveBeenCalledWith('electronics');
expect(onToggle).toHaveBeenCalledTimes(1);
});

it('sets aria-expanded=true when expanded', () => {
render(
<GroupRow
groupKey="tools"
label="Tools"
count={3}
collapsed={false}
onToggle={() => {}}
>
<div>Content</div>
</GroupRow>,
);

expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'true');
});

it('sets aria-expanded=false when collapsed', () => {
render(
<GroupRow
groupKey="tools"
label="Tools"
count={3}
collapsed={true}
onToggle={() => {}}
>
<div>Content</div>
</GroupRow>,
);

expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'false');
});

it('renders aggregation summary when provided', () => {
const aggregations = [
{ field: 'amount', type: 'sum' as const, value: 150 },
{ field: 'amount', type: 'avg' as const, value: 37.5 },
];
render(
<GroupRow
groupKey="electronics"
label="Electronics"
count={4}
collapsed={false}
aggregations={aggregations}
onToggle={() => {}}
>
<div>Content</div>
</GroupRow>,
);

expect(screen.getByText(/sum: 150/)).toBeInTheDocument();
expect(screen.getByText(/avg: 37.50/)).toBeInTheDocument();
});

it('does not render aggregation section when aggregations is empty', () => {
render(
<GroupRow
groupKey="electronics"
label="Electronics"
count={4}
collapsed={false}
aggregations={[]}
onToggle={() => {}}
>
<div>Content</div>
</GroupRow>,
);

expect(screen.queryByText(/sum:/)).not.toBeInTheDocument();
});

it('renders data-testid with group key', () => {
render(
<GroupRow
groupKey="electronics"
label="Electronics"
count={5}
collapsed={false}
onToggle={() => {}}
>
<div>Content</div>
</GroupRow>,
);

expect(screen.getByTestId('group-row-electronics')).toBeInTheDocument();
});
});
Loading