diff --git a/packages/plugin-grid/src/GroupRow.tsx b/packages/plugin-grid/src/GroupRow.tsx new file mode 100644 index 000000000..630732347 --- /dev/null +++ b/packages/plugin-grid/src/GroupRow.tsx @@ -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 = ({ + groupKey, + label, + count, + collapsed, + aggregations, + onToggle, + children, +}) => { + return ( +
+ + {!collapsed && children} +
+ ); +}; diff --git a/packages/plugin-grid/src/ObjectGrid.tsx b/packages/plugin-grid/src/ObjectGrid.tsx index 9d0ca37ee..b710053f5 100644 --- a/packages/plugin-grid/src/ObjectGrid.tsx +++ b/packages/plugin-grid/src/ObjectGrid.tsx @@ -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; @@ -1200,22 +1201,17 @@ export const ObjectGrid: React.FC = ({ const gridContent = isGrouped ? (
{groups.map((group) => ( -
- - {!group.collapsed && ( - - )} -
+ + + ))}
) : ( diff --git a/packages/plugin-grid/src/__tests__/GroupRow.test.tsx b/packages/plugin-grid/src/__tests__/GroupRow.test.tsx new file mode 100644 index 000000000..c1cb02f3c --- /dev/null +++ b/packages/plugin-grid/src/__tests__/GroupRow.test.tsx @@ -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( + {}} + > +
Content
+
, + ); + + expect(screen.getByText('Electronics')).toBeInTheDocument(); + expect(screen.getByText('(5)')).toBeInTheDocument(); + }); + + it('renders children when not collapsed', () => { + render( + {}} + > +
Group Content
+
, + ); + + expect(screen.getByTestId('group-content')).toBeInTheDocument(); + }); + + it('hides children when collapsed', () => { + render( + {}} + > +
Group Content
+
, + ); + + expect(screen.queryByTestId('group-content')).not.toBeInTheDocument(); + }); + + it('shows ChevronDown when expanded', () => { + render( + {}} + > +
Content
+
, + ); + + // 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( + {}} + > +
Content
+
, + ); + + 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( + +
Content
+
, + ); + + fireEvent.click(screen.getByRole('button')); + expect(onToggle).toHaveBeenCalledWith('electronics'); + expect(onToggle).toHaveBeenCalledTimes(1); + }); + + it('sets aria-expanded=true when expanded', () => { + render( + {}} + > +
Content
+
, + ); + + expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'true'); + }); + + it('sets aria-expanded=false when collapsed', () => { + render( + {}} + > +
Content
+
, + ); + + 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( + {}} + > +
Content
+
, + ); + + expect(screen.getByText(/sum: 150/)).toBeInTheDocument(); + expect(screen.getByText(/avg: 37.50/)).toBeInTheDocument(); + }); + + it('does not render aggregation section when aggregations is empty', () => { + render( + {}} + > +
Content
+
, + ); + + expect(screen.queryByText(/sum:/)).not.toBeInTheDocument(); + }); + + it('renders data-testid with group key', () => { + render( + {}} + > +
Content
+
, + ); + + expect(screen.getByTestId('group-row-electronics')).toBeInTheDocument(); + }); +}); diff --git a/packages/plugin-grid/src/__tests__/useGroupedData.test.ts b/packages/plugin-grid/src/__tests__/useGroupedData.test.ts new file mode 100644 index 000000000..4f9e5a274 --- /dev/null +++ b/packages/plugin-grid/src/__tests__/useGroupedData.test.ts @@ -0,0 +1,165 @@ +/** + * 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 } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useGroupedData } from '../useGroupedData'; + +const sampleData = [ + { category: 'A', priority: 'High', amount: 10 }, + { category: 'A', priority: 'Low', amount: 20 }, + { category: 'B', priority: 'High', amount: 30 }, + { category: 'B', priority: 'Medium', amount: 40 }, + { category: 'C', priority: 'Low', amount: 50 }, +]; + +describe('useGroupedData – collapsed state management', () => { + it('returns isGrouped=false when config is undefined', () => { + const { result } = renderHook(() => useGroupedData(undefined, sampleData)); + expect(result.current.isGrouped).toBe(false); + expect(result.current.groups).toEqual([]); + }); + + it('returns isGrouped=false when config has empty fields', () => { + const { result } = renderHook(() => useGroupedData({ fields: [] }, sampleData)); + expect(result.current.isGrouped).toBe(false); + expect(result.current.groups).toEqual([]); + }); + + it('groups data correctly with single field', () => { + const config = { fields: [{ field: 'category', order: 'asc' as const, collapsed: false }] }; + const { result } = renderHook(() => useGroupedData(config, sampleData)); + + expect(result.current.isGrouped).toBe(true); + expect(result.current.groups).toHaveLength(3); + expect(result.current.groups[0].key).toBe('A'); + expect(result.current.groups[0].rows).toHaveLength(2); + expect(result.current.groups[1].key).toBe('B'); + expect(result.current.groups[1].rows).toHaveLength(2); + expect(result.current.groups[2].key).toBe('C'); + expect(result.current.groups[2].rows).toHaveLength(1); + }); + + it('all groups default to expanded when collapsed=false', () => { + const config = { fields: [{ field: 'category', order: 'asc' as const, collapsed: false }] }; + const { result } = renderHook(() => useGroupedData(config, sampleData)); + + result.current.groups.forEach((group) => { + expect(group.collapsed).toBe(false); + }); + }); + + it('all groups default to collapsed when collapsed=true', () => { + const config = { fields: [{ field: 'category', order: 'asc' as const, collapsed: true }] }; + const { result } = renderHook(() => useGroupedData(config, sampleData)); + + result.current.groups.forEach((group) => { + expect(group.collapsed).toBe(true); + }); + }); + + it('toggleGroup toggles a group from expanded to collapsed', () => { + const config = { fields: [{ field: 'category', order: 'asc' as const, collapsed: false }] }; + const { result } = renderHook(() => useGroupedData(config, sampleData)); + + // Initially all expanded + expect(result.current.groups[0].collapsed).toBe(false); + + // Toggle group A + act(() => { + result.current.toggleGroup('A'); + }); + + expect(result.current.groups[0].collapsed).toBe(true); + // Other groups remain expanded + expect(result.current.groups[1].collapsed).toBe(false); + expect(result.current.groups[2].collapsed).toBe(false); + }); + + it('toggleGroup toggles a group from collapsed back to expanded', () => { + const config = { fields: [{ field: 'category', order: 'asc' as const, collapsed: false }] }; + const { result } = renderHook(() => useGroupedData(config, sampleData)); + + // Toggle twice: expand -> collapse -> expand + act(() => { + result.current.toggleGroup('A'); + }); + expect(result.current.groups[0].collapsed).toBe(true); + + act(() => { + result.current.toggleGroup('A'); + }); + expect(result.current.groups[0].collapsed).toBe(false); + }); + + it('toggleGroup expands a group that defaults to collapsed', () => { + const config = { fields: [{ field: 'category', order: 'asc' as const, collapsed: true }] }; + const { result } = renderHook(() => useGroupedData(config, sampleData)); + + // Initially all collapsed + expect(result.current.groups[0].collapsed).toBe(true); + + // Toggle group A to expand + act(() => { + result.current.toggleGroup('A'); + }); + + expect(result.current.groups[0].collapsed).toBe(false); + // Other groups remain collapsed + expect(result.current.groups[1].collapsed).toBe(true); + }); + + it('sorts groups in descending order when configured', () => { + const config = { fields: [{ field: 'category', order: 'desc' as const, collapsed: false }] }; + const { result } = renderHook(() => useGroupedData(config, sampleData)); + + expect(result.current.groups[0].key).toBe('C'); + expect(result.current.groups[1].key).toBe('B'); + expect(result.current.groups[2].key).toBe('A'); + }); + + it('builds correct labels for groups', () => { + const config = { fields: [{ field: 'category', order: 'asc' as const, collapsed: false }] }; + const { result } = renderHook(() => useGroupedData(config, sampleData)); + + expect(result.current.groups[0].label).toBe('A'); + expect(result.current.groups[1].label).toBe('B'); + expect(result.current.groups[2].label).toBe('C'); + }); + + it('shows (empty) label for rows with missing grouping field', () => { + const data = [ + { category: 'A', amount: 10 }, + { amount: 20 }, // no category + { category: '', amount: 30 }, // empty category + ]; + const config = { fields: [{ field: 'category', order: 'asc' as const, collapsed: false }] }; + const { result } = renderHook(() => useGroupedData(config, data)); + + const emptyGroup = result.current.groups.find((g) => g.label === '(empty)'); + expect(emptyGroup).toBeDefined(); + expect(emptyGroup!.rows).toHaveLength(2); + }); + + it('supports multi-field grouping', () => { + const config = { + fields: [ + { field: 'category', order: 'asc' as const, collapsed: false }, + { field: 'priority', order: 'asc' as const, collapsed: false }, + ], + }; + const { result } = renderHook(() => useGroupedData(config, sampleData)); + + expect(result.current.isGrouped).toBe(true); + // Each unique combination of category + priority should be a group + expect(result.current.groups.length).toBeGreaterThanOrEqual(4); + // Check label format is "A / High" + const firstGroup = result.current.groups[0]; + expect(firstGroup.label).toContain(' / '); + }); +}); diff --git a/packages/plugin-grid/src/index.tsx b/packages/plugin-grid/src/index.tsx index f5635ad71..ca6ea1544 100644 --- a/packages/plugin-grid/src/index.tsx +++ b/packages/plugin-grid/src/index.tsx @@ -17,6 +17,7 @@ export { ObjectGrid, VirtualGrid, ImportWizard }; export { InlineEditing } from './InlineEditing'; export { useRowColor } from './useRowColor'; export { useGroupedData } from './useGroupedData'; +export { GroupRow } from './GroupRow'; export { useCellClipboard } from './useCellClipboard'; export { useGradientColor } from './useGradientColor'; export { useGroupReorder } from './useGroupReorder'; @@ -27,6 +28,7 @@ export type { VirtualGridProps, VirtualGridColumn } from './VirtualGrid'; export type { InlineEditingProps } from './InlineEditing'; export type { ImportWizardProps, ImportResult } from './ImportWizard'; export type { GroupEntry, UseGroupedDataResult, AggregationType, AggregationConfig, AggregationResult } from './useGroupedData'; +export type { GroupRowProps } from './GroupRow'; export type { CellRange, UseCellClipboardOptions, UseCellClipboardResult } from './useCellClipboard'; export type { GradientStop, UseGradientColorOptions } from './useGradientColor'; export type { UseGroupReorderOptions, UseGroupReorderResult } from './useGroupReorder'; diff --git a/packages/plugin-kanban/src/ObjectKanban.tsx b/packages/plugin-kanban/src/ObjectKanban.tsx index 07f97a954..5500dceee 100644 --- a/packages/plugin-kanban/src/ObjectKanban.tsx +++ b/packages/plugin-kanban/src/ObjectKanban.tsx @@ -188,11 +188,16 @@ export const ObjectKanban: React.FC = ({ }, [schema.columns, schema.groupBy, effectiveData, objectDef]); // Clone schema to inject data and className + // Use grouping.fields[0].field as swimlaneField fallback when no explicit swimlaneField + const effectiveSwimlaneField = schema.swimlaneField + || (schema.grouping?.fields?.[0]?.field); + const effectiveSchema = { ...schema, data: effectiveData, columns: effectiveColumns, - className: className || schema.className + className: className || schema.className, + ...(effectiveSwimlaneField ? { swimlaneField: effectiveSwimlaneField } : {}), }; const navigation = useNavigationOverlay({ diff --git a/packages/plugin-kanban/src/__tests__/KanbanGrouping.test.tsx b/packages/plugin-kanban/src/__tests__/KanbanGrouping.test.tsx new file mode 100644 index 000000000..dcfd46b9a --- /dev/null +++ b/packages/plugin-kanban/src/__tests__/KanbanGrouping.test.tsx @@ -0,0 +1,164 @@ +/** + * 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, act } from '@testing-library/react'; +import React, { Suspense } from 'react'; + +// Mock dnd-kit +vi.mock('@dnd-kit/core', () => ({ + DndContext: ({ children }: any) =>
{children}
, + DragOverlay: ({ children }: any) =>
{children}
, + PointerSensor: vi.fn(), + TouchSensor: vi.fn(), + useSensor: vi.fn(), + useSensors: () => [], + closestCorners: vi.fn(), +})); + +vi.mock('@dnd-kit/sortable', () => ({ + SortableContext: ({ children }: any) =>
{children}
, + useSortable: () => ({ + attributes: {}, + listeners: {}, + setNodeRef: vi.fn(), + transform: null, + transition: null, + isDragging: false, + }), + arrayMove: (array: any[], from: number, to: number) => { + const newArray = [...array]; + newArray.splice(to, 0, newArray.splice(from, 1)[0]); + return newArray; + }, + verticalListSortingStrategy: vi.fn(), +})); + +vi.mock('@dnd-kit/utilities', () => ({ + CSS: { + Transform: { + toString: () => '', + }, + }, +})); + +vi.mock('@object-ui/components', () => ({ + Badge: ({ children, ...props }: any) => {children}, + Card: ({ children, ...props }: any) =>
{children}
, + CardHeader: ({ children, ...props }: any) =>
{children}
, + CardTitle: ({ children, ...props }: any) =>
{children}
, + CardDescription: ({ children, ...props }: any) =>
{children}
, + CardContent: ({ children, ...props }: any) =>
{children}
, + ScrollArea: ({ children, ...props }: any) =>
{children}
, + Button: ({ children, ...props }: any) => , + Input: (props: any) => , + Skeleton: ({ className }: any) =>
, + NavigationOverlay: ({ children, selectedRecord }: any) => ( + selectedRecord ?
{children(selectedRecord)}
: null + ), +})); + +vi.mock('@object-ui/react', () => ({ + useHasDndProvider: () => false, + useDnd: () => ({ + startDrag: vi.fn(), + endDrag: vi.fn(), + }), + useDataScope: () => undefined, + useNavigationOverlay: () => ({ + isOverlay: false, + handleClick: vi.fn(), + selectedRecord: null, + isOpen: false, + close: vi.fn(), + setIsOpen: vi.fn(), + mode: 'page' as const, + }), +})); + +vi.mock('lucide-react', () => ({ + Plus: () => +, +})); + +// Import KanbanBoard (the impl) directly to avoid lazy-loading issues in tests +import KanbanBoard from '../KanbanImpl'; + +const mockColumns = [ + { + id: 'todo', + title: 'To Do', + cards: [ + { id: 'c1', title: 'Task 1', priority: 'High', team: 'Frontend' }, + { id: 'c2', title: 'Task 2', priority: 'Low', team: 'Backend' }, + ], + }, + { + id: 'done', + title: 'Done', + cards: [ + { id: 'c3', title: 'Task 3', priority: 'High', team: 'Frontend' }, + { id: 'c4', title: 'Task 4', priority: 'Medium', team: 'Backend' }, + ], + }, +]; + +describe('ObjectKanban grouping config → swimlaneField mapping', () => { + it('uses grouping field as swimlane when passed to KanbanImpl', () => { + // This simulates what ObjectKanban does: map grouping.fields[0].field to swimlaneField + render(); + + // Swimlane layout should render + expect(screen.getByRole('region', { name: 'Kanban board with swimlanes' })).toBeInTheDocument(); + + // Swimlane headers for each team + expect(screen.getByText('Backend')).toBeInTheDocument(); + expect(screen.getByText('Frontend')).toBeInTheDocument(); + }); + + it('renders flat kanban when no swimlane/grouping is provided', () => { + render(); + + // Flat layout renders "Kanban board" + expect(screen.getByRole('region', { name: 'Kanban board' })).toBeInTheDocument(); + expect(screen.queryByRole('region', { name: 'Kanban board with swimlanes' })).not.toBeInTheDocument(); + }); + + describe('ObjectKanban swimlaneField resolution logic', () => { + // Test the resolution logic independently (same as ObjectKanban.tsx effectiveSwimlaneField) + function resolveEffectiveSwimlaneField( + swimlaneField?: string, + grouping?: { fields: Array<{ field: string; order: string; collapsed: boolean }> }, + ): string | undefined { + return swimlaneField || grouping?.fields?.[0]?.field; + } + + it('prefers explicit swimlaneField over grouping', () => { + const result = resolveEffectiveSwimlaneField('priority', { + fields: [{ field: 'team', order: 'asc', collapsed: false }], + }); + expect(result).toBe('priority'); + }); + + it('falls back to grouping.fields[0].field when no swimlaneField', () => { + const result = resolveEffectiveSwimlaneField(undefined, { + fields: [{ field: 'team', order: 'asc', collapsed: false }], + }); + expect(result).toBe('team'); + }); + + it('returns undefined when neither swimlaneField nor grouping is set', () => { + const result = resolveEffectiveSwimlaneField(undefined, undefined); + expect(result).toBeUndefined(); + }); + + it('returns undefined when grouping has empty fields array', () => { + const result = resolveEffectiveSwimlaneField(undefined, { fields: [] }); + expect(result).toBeUndefined(); + }); + }); +}); diff --git a/packages/plugin-kanban/src/types.ts b/packages/plugin-kanban/src/types.ts index 27752cb24..7b3dd0aed 100644 --- a/packages/plugin-kanban/src/types.ts +++ b/packages/plugin-kanban/src/types.ts @@ -6,7 +6,7 @@ * LICENSE file in the root directory of this source tree. */ -import type { BaseSchema } from '@object-ui/types'; +import type { BaseSchema, GroupingConfig } from '@object-ui/types'; /** * Kanban card interface. @@ -130,6 +130,12 @@ export interface KanbanSchema extends BaseSchema { * Supports per-column overrides with min/max constraints. */ columnWidths?: ColumnWidthConfig; + + /** + * Grouping configuration from ListView. + * When set, the first grouping field is used as swimlaneField fallback. + */ + grouping?: GroupingConfig; } /** diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx index ab9a2b708..6df43ef1e 100644 --- a/packages/plugin-list/src/ListView.tsx +++ b/packages/plugin-list/src/ListView.tsx @@ -756,6 +756,7 @@ export const ListView: React.FC = ({ groupField: schema.kanban?.groupField || schema.options?.kanban?.groupField || 'status', titleField: schema.kanban?.titleField || schema.options?.kanban?.titleField || 'name', cardFields: schema.kanban?.cardFields || effectiveFields || [], + ...(groupingConfig ? { grouping: groupingConfig } : {}), ...(schema.options?.kanban || {}), ...(schema.kanban || {}), }; @@ -780,6 +781,7 @@ export const ListView: React.FC = ({ ...(schema.gallery?.coverFit ? { coverFit: schema.gallery.coverFit } : {}), ...(schema.gallery?.cardSize ? { cardSize: schema.gallery.cardSize } : {}), ...(schema.gallery?.visibleFields ? { visibleFields: schema.gallery.visibleFields } : {}), + ...(groupingConfig ? { grouping: groupingConfig } : {}), ...(schema.options?.gallery || {}), ...(schema.gallery || {}), }; diff --git a/packages/plugin-list/src/ObjectGallery.tsx b/packages/plugin-list/src/ObjectGallery.tsx index 79970ccec..6e288491b 100644 --- a/packages/plugin-list/src/ObjectGallery.tsx +++ b/packages/plugin-list/src/ObjectGallery.tsx @@ -6,11 +6,12 @@ * LICENSE file in the root directory of this source tree. */ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useCallback, useMemo } from 'react'; import { useDataScope, useSchemaContext, useNavigationOverlay } from '@object-ui/react'; import { ComponentRegistry } from '@object-ui/core'; import { cn, Card, CardContent, NavigationOverlay } from '@object-ui/components'; -import type { GalleryConfig, ViewNavigationConfig } from '@object-ui/types'; +import type { GalleryConfig, ViewNavigationConfig, GroupingConfig } from '@object-ui/types'; +import { ChevronRight, ChevronDown } from 'lucide-react'; export interface ObjectGalleryProps { schema: { @@ -22,6 +23,8 @@ export interface ObjectGalleryProps { gallery?: GalleryConfig; /** Navigation config for item click behavior */ navigation?: ViewNavigationConfig; + /** Grouping configuration for sectioned display */ + grouping?: GroupingConfig; /** @deprecated Use gallery.coverField instead */ imageField?: string; /** @deprecated Use gallery.titleField instead */ @@ -117,71 +120,147 @@ export const ObjectGallery: React.FC = (props) => { const items: Record[] = props.data || boundData || schema.data || fetchedData || []; + // --- Grouping support --- + const groupingFields = schema.grouping?.fields; + const isGrouped = !!(groupingFields && groupingFields.length > 0); + + const [collapsedGroups, setCollapsedGroups] = useState>({}); + + // Initialize collapsed state from grouping config + const defaultCollapsed = useMemo(() => { + if (!groupingFields) return false; + return groupingFields.some((f) => f.collapsed); + }, [groupingFields]); + + const toggleGroup = useCallback((key: string) => { + setCollapsedGroups((prev) => ({ + ...prev, + [key]: prev[key] !== undefined ? !prev[key] : !defaultCollapsed, + })); + }, [defaultCollapsed]); + + const groupedItems = useMemo(() => { + if (!isGrouped || !groupingFields) return []; + const map = new Map[] }>(); + const keyOrder: string[] = []; + for (const item of items) { + const key = groupingFields.map((f) => String(item[f.field] ?? '')).join(' / '); + if (!map.has(key)) { + const label = groupingFields + .map((f) => { + const val = item[f.field]; + return val !== undefined && val !== null && val !== '' ? String(val) : '(empty)'; + }) + .join(' / '); + map.set(key, { label, items: [] }); + keyOrder.push(key); + } + map.get(key)!.items.push(item); + } + const primaryOrder = groupingFields[0]?.order ?? 'asc'; + keyOrder.sort((a, b) => { + const cmp = a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }); + return primaryOrder === 'desc' ? -cmp : cmp; + }); + return keyOrder.map((key) => { + const entry = map.get(key)!; + const collapsed = key in collapsedGroups ? collapsedGroups[key] : defaultCollapsed; + return { key, label: entry.label, items: entry.items, collapsed }; + }); + }, [items, groupingFields, isGrouped, collapsedGroups, defaultCollapsed]); + if (loading && !items.length) return
Loading Gallery...
; if (!items.length) return
No items to display
; - return ( - <> -
, i: number) => { + const id = (item._id ?? item.id ?? i) as string | number; + const title = String(item[titleField] ?? 'Untitled'); + const imageUrl = item[coverField] as string | undefined; + + return ( + navigation.handleClick(item)} > - {items.map((item, i) => { - const id = (item._id ?? item.id ?? i) as string | number; - const title = String(item[titleField] ?? 'Untitled'); - const imageUrl = item[coverField] as string | undefined; - - return ( - + {imageUrl ? ( + {title} navigation.handleClick(item)} - > -
- {imageUrl ? ( - {title} - ) : ( -
- - {title[0]?.toUpperCase()} - -
- )} -
- -

- {title} -

- {visibleFields && visibleFields.length > 0 && ( -
- {visibleFields.map((field) => { - const value = item[field]; - if (value == null) return null; - return ( -

- {String(value)} -

- ); - })} -
- )} -
-
- ); - })} -
+ /> + ) : ( +
+ + {title[0]?.toUpperCase()} + +
+ )} +
+ +

+ {title} +

+ {visibleFields && visibleFields.length > 0 && ( +
+ {visibleFields.map((field) => { + const value = item[field]; + if (value == null) return null; + return ( +

+ {String(value)} +

+ ); + })} +
+ )} +
+ + ); + }; + + const renderGrid = (gridItems: Record[]) => ( +
+ {gridItems.map((item, i) => renderCard(item, i))} +
+ ); + + return ( + <> + {isGrouped ? ( +
+ {groupedItems.map((group) => ( +
+ + {!group.collapsed && renderGrid(group.items)} +
+ ))} +
+ ) : ( + renderGrid(items) + )} {navigation.isOverlay && ( {(record) => ( diff --git a/packages/plugin-list/src/__tests__/GalleryGrouping.test.tsx b/packages/plugin-list/src/__tests__/GalleryGrouping.test.tsx new file mode 100644 index 000000000..bcaa5da8c --- /dev/null +++ b/packages/plugin-list/src/__tests__/GalleryGrouping.test.tsx @@ -0,0 +1,234 @@ +/** + * 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, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { ObjectGallery } from '../ObjectGallery'; + +const mockHandleClick = vi.fn(); +const mockNavigationOverlay = { + isOverlay: false, + handleClick: mockHandleClick, + selectedRecord: null, + isOpen: false, + close: vi.fn(), + setIsOpen: vi.fn(), + mode: 'page' as const, + width: undefined, + view: undefined, + open: vi.fn(), +}; + +vi.mock('@object-ui/react', () => ({ + useDataScope: () => undefined, + useSchemaContext: () => ({ dataSource: undefined }), + useNavigationOverlay: () => mockNavigationOverlay, +})); + +vi.mock('@object-ui/components', () => ({ + cn: (...args: any[]) => args.filter(Boolean).join(' '), + Card: ({ children, onClick, ...props }: any) => ( +
{children}
+ ), + CardContent: ({ children, ...props }: any) =>
{children}
, + NavigationOverlay: ({ children, selectedRecord }: any) => ( + selectedRecord ?
{children(selectedRecord)}
: null + ), +})); + +vi.mock('@object-ui/core', () => ({ + ComponentRegistry: { register: vi.fn() }, +})); + +vi.mock('lucide-react', () => ({ + ChevronRight: () => , + ChevronDown: () => , +})); + +const mockItems = [ + { id: '1', name: 'Alpha Widget', category: 'Electronics', image: 'https://example.com/1.jpg' }, + { id: '2', name: 'Beta Gadget', category: 'Electronics', image: 'https://example.com/2.jpg' }, + { id: '3', name: 'Gamma Tool', category: 'Tools', image: 'https://example.com/3.jpg' }, + { id: '4', name: 'Delta Supply', category: 'Office', image: 'https://example.com/4.jpg' }, + { id: '5', name: 'Epsilon Gear', category: 'Tools', image: 'https://example.com/5.jpg' }, +]; + +describe('ObjectGallery Grouping', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders without grouping (flat list) when no grouping config', () => { + const schema = { objectName: 'products' }; + render(); + + // All items visible + expect(screen.getByText('Alpha Widget')).toBeInTheDocument(); + expect(screen.getByText('Beta Gadget')).toBeInTheDocument(); + expect(screen.getByText('Gamma Tool')).toBeInTheDocument(); + expect(screen.getByText('Delta Supply')).toBeInTheDocument(); + expect(screen.getByText('Epsilon Gear')).toBeInTheDocument(); + + // No group headers + expect(screen.queryByText('Electronics')).not.toBeInTheDocument(); + expect(screen.queryByText('Tools')).not.toBeInTheDocument(); + }); + + it('renders grouped sections when grouping config is provided', () => { + const schema = { + objectName: 'products', + grouping: { + fields: [{ field: 'category', order: 'asc' as const, collapsed: false }], + }, + }; + render(); + + // Group headers should be visible + expect(screen.getByText('Electronics')).toBeInTheDocument(); + expect(screen.getByText('Tools')).toBeInTheDocument(); + expect(screen.getByText('Office')).toBeInTheDocument(); + + // All items should be visible (none collapsed) + expect(screen.getByText('Alpha Widget')).toBeInTheDocument(); + expect(screen.getByText('Beta Gadget')).toBeInTheDocument(); + expect(screen.getByText('Gamma Tool')).toBeInTheDocument(); + expect(screen.getByText('Delta Supply')).toBeInTheDocument(); + expect(screen.getByText('Epsilon Gear')).toBeInTheDocument(); + }); + + it('shows record count per group', () => { + const schema = { + objectName: 'products', + grouping: { + fields: [{ field: 'category', order: 'asc' as const, collapsed: false }], + }, + }; + render(); + + // Electronics has 2 items, Tools has 2, Office has 1 + const buttons = screen.getAllByRole('button'); + const electronicsBtn = buttons.find(b => b.textContent?.includes('Electronics')); + const toolsBtn = buttons.find(b => b.textContent?.includes('Tools')); + const officeBtn = buttons.find(b => b.textContent?.includes('Office')); + + expect(electronicsBtn?.textContent).toContain('2'); + expect(toolsBtn?.textContent).toContain('2'); + expect(officeBtn?.textContent).toContain('1'); + }); + + it('collapses a group when clicking the group header', () => { + const schema = { + objectName: 'products', + grouping: { + fields: [{ field: 'category', order: 'asc' as const, collapsed: false }], + }, + }; + render(); + + // All items visible initially + expect(screen.getByText('Alpha Widget')).toBeInTheDocument(); + expect(screen.getByText('Beta Gadget')).toBeInTheDocument(); + + // Click Electronics group header to collapse + const buttons = screen.getAllByRole('button'); + const electronicsBtn = buttons.find(b => b.textContent?.includes('Electronics'))!; + fireEvent.click(electronicsBtn); + + // Electronics items should be hidden + expect(screen.queryByText('Alpha Widget')).not.toBeInTheDocument(); + expect(screen.queryByText('Beta Gadget')).not.toBeInTheDocument(); + + // Other items still visible + expect(screen.getByText('Gamma Tool')).toBeInTheDocument(); + expect(screen.getByText('Delta Supply')).toBeInTheDocument(); + }); + + it('expands a collapsed group when clicking again', () => { + const schema = { + objectName: 'products', + grouping: { + fields: [{ field: 'category', order: 'asc' as const, collapsed: false }], + }, + }; + render(); + + const buttons = screen.getAllByRole('button'); + const electronicsBtn = buttons.find(b => b.textContent?.includes('Electronics'))!; + + // Collapse + fireEvent.click(electronicsBtn); + expect(screen.queryByText('Alpha Widget')).not.toBeInTheDocument(); + + // Expand + fireEvent.click(electronicsBtn); + expect(screen.getByText('Alpha Widget')).toBeInTheDocument(); + expect(screen.getByText('Beta Gadget')).toBeInTheDocument(); + }); + + it('respects initial collapsed state from grouping config', () => { + const schema = { + objectName: 'products', + grouping: { + fields: [{ field: 'category', order: 'asc' as const, collapsed: true }], + }, + }; + render(); + + // Group headers should be visible + expect(screen.getByText('Electronics')).toBeInTheDocument(); + expect(screen.getByText('Tools')).toBeInTheDocument(); + expect(screen.getByText('Office')).toBeInTheDocument(); + + // All items should be hidden (all groups collapsed by default) + expect(screen.queryByText('Alpha Widget')).not.toBeInTheDocument(); + expect(screen.queryByText('Beta Gadget')).not.toBeInTheDocument(); + expect(screen.queryByText('Gamma Tool')).not.toBeInTheDocument(); + expect(screen.queryByText('Delta Supply')).not.toBeInTheDocument(); + expect(screen.queryByText('Epsilon Gear')).not.toBeInTheDocument(); + }); + + it('shows (empty) label for items with empty grouping field', () => { + const items = [ + { id: '1', name: 'Item A', category: 'Cat1' }, + { id: '2', name: 'Item B', category: '' }, + { id: '3', name: 'Item C' }, // no category field + ]; + const schema = { + objectName: 'products', + grouping: { + fields: [{ field: 'category', order: 'asc' as const, collapsed: false }], + }, + }; + render(); + + expect(screen.getByText('Cat1')).toBeInTheDocument(); + expect(screen.getByText('(empty)')).toBeInTheDocument(); + }); + + it('sorts groups by descending order when configured', () => { + const schema = { + objectName: 'products', + grouping: { + fields: [{ field: 'category', order: 'desc' as const, collapsed: false }], + }, + }; + render(); + + const buttons = screen.getAllByRole('button'); + const labels = buttons.map(b => { + // Extract the group label text (the inside button) + const spans = b.querySelectorAll('span'); + return spans[1]?.textContent; // label span + }).filter(Boolean); + + // With desc order: Tools > Office > Electronics + expect(labels[0]).toBe('Tools'); + expect(labels[1]).toBe('Office'); + expect(labels[2]).toBe('Electronics'); + }); +}); diff --git a/packages/plugin-list/src/__tests__/ListViewGroupingPropagation.test.tsx b/packages/plugin-list/src/__tests__/ListViewGroupingPropagation.test.tsx new file mode 100644 index 000000000..e562d6395 --- /dev/null +++ b/packages/plugin-list/src/__tests__/ListViewGroupingPropagation.test.tsx @@ -0,0 +1,200 @@ +/** + * 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, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import React from 'react'; + +// Capture the schema prop passed to SchemaRenderer +let capturedSchema: any = null; + +vi.mock('@object-ui/react', () => ({ + SchemaRenderer: (props: any) => { + capturedSchema = props.schema; + return
{props.schema?.type}
; + }, + useNavigationOverlay: () => ({ + isOverlay: false, + handleClick: vi.fn(), + selectedRecord: null, + isOpen: false, + close: vi.fn(), + setIsOpen: vi.fn(), + mode: 'page' as const, + width: undefined, + view: undefined, + open: vi.fn(), + }), + useDensityMode: () => ['comfortable', vi.fn()] as const, + SchemaRendererProvider: ({ children }: any) =>
{children}
, +})); + +vi.mock('@object-ui/components', () => ({ + cn: (...args: any[]) => args.filter(Boolean).join(' '), + Input: (props: any) => , + Button: ({ children, ...props }: any) => , + Badge: ({ children, ...props }: any) => {children}, + Popover: ({ children }: any) =>
{children}
, + PopoverTrigger: ({ children, ...props }: any) =>
{children}
, + PopoverContent: ({ children }: any) =>
{children}
, + FilterBuilder: () => null, + SortBuilder: () => null, + NavigationOverlay: () => null, +})); + +vi.mock('@object-ui/mobile', () => ({ + usePullToRefresh: () => ({ pullRef: { current: null } }), +})); + +vi.mock('@object-ui/core', () => ({ + ExpressionEvaluator: { + evaluate: vi.fn((expr: string) => expr), + }, +})); + +vi.mock('@object-ui/i18n', () => ({ + useObjectTranslation: () => ({ + t: (key: string, fallback?: string) => fallback || key, + }), +})); + +vi.mock('../ViewSwitcher', () => ({ + ViewSwitcher: ({ currentView, onViewChange }: any) => ( +
+ + + +
+ ), + ViewType: {}, +})); + +vi.mock('../UserFilters', () => ({ + UserFilters: () => null, +})); + +vi.mock('lucide-react', () => ({ + Search: () => Search, + SlidersHorizontal: () => Sliders, + ArrowUpDown: () => Sort, + X: () => X, + EyeOff: () => EyeOff, + Group: () => Group, + Paintbrush: () => Color, + Ruler: () => Ruler, + Inbox: () => Inbox, + Download: () => Download, + AlignJustify: () => Density, + Share2: () => Share, + Printer: () => Print, + Plus: () => Plus, + icons: {}, +})); + +import { ListView } from '../ListView'; + +const groupingConfig = { + fields: [{ field: 'category', order: 'asc' as const, collapsed: false }], +}; + +const testData = [ + { id: '1', name: 'Product A', category: 'Electronics' }, + { id: '2', name: 'Product B', category: 'Tools' }, +]; + +describe('ListView grouping config propagation', () => { + beforeEach(() => { + capturedSchema = null; + }); + + it('passes grouping config to grid view schema', () => { + render( + , + ); + + expect(capturedSchema).toBeDefined(); + expect(capturedSchema.type).toBe('object-grid'); + expect(capturedSchema.grouping).toEqual(groupingConfig); + }); + + it('passes grouping config to kanban view schema', () => { + render( + , + ); + + // Switch to kanban view + const kanbanBtn = screen.getByLabelText('Kanban'); + fireEvent.click(kanbanBtn); + + expect(capturedSchema).toBeDefined(); + expect(capturedSchema.type).toBe('object-kanban'); + expect(capturedSchema.grouping).toEqual(groupingConfig); + }); + + it('passes grouping config to gallery view schema', () => { + render( + , + ); + + // Switch to gallery view + const galleryBtn = screen.getByLabelText('Gallery'); + fireEvent.click(galleryBtn); + + expect(capturedSchema).toBeDefined(); + expect(capturedSchema.type).toBe('object-gallery'); + expect(capturedSchema.grouping).toEqual(groupingConfig); + }); + + it('does not pass grouping when no grouping config exists', () => { + render( + , + ); + + expect(capturedSchema).toBeDefined(); + expect(capturedSchema.type).toBe('object-grid'); + expect(capturedSchema.grouping).toBeUndefined(); + }); +});