From e7be6824191b41fe8ad5f057b95a1a4ae9bf0a3b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Feb 2026 09:32:46 +0000 Subject: [PATCH 1/6] Initial plan From 36db280783bd343dbc6718373cc670ac2f048888 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Feb 2026 09:41:19 +0000 Subject: [PATCH 2/6] feat: implement column pinned/summary/link/action features - Add pinned and summary to ListColumnSchema Zod definition - Create useColumnSummary hook with count/sum/avg/min/max aggregation - Implement pinned column reordering (left first, right last with sticky CSS) - Implement summary footer rendering with per-column aggregation display - Export useColumnSummary from plugin-grid index - Add 21 comprehensive tests for all new features Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- packages/plugin-grid/src/ObjectGrid.tsx | 62 ++- .../src/__tests__/column-features.test.tsx | 360 ++++++++++++++++++ packages/plugin-grid/src/index.tsx | 2 + packages/plugin-grid/src/useColumnSummary.ts | 126 ++++++ packages/types/src/zod/objectql.zod.ts | 8 + 5 files changed, 555 insertions(+), 3 deletions(-) create mode 100644 packages/plugin-grid/src/__tests__/column-features.test.tsx create mode 100644 packages/plugin-grid/src/useColumnSummary.ts diff --git a/packages/plugin-grid/src/ObjectGrid.tsx b/packages/plugin-grid/src/ObjectGrid.tsx index b710053f5..b5aeb6f69 100644 --- a/packages/plugin-grid/src/ObjectGrid.tsx +++ b/packages/plugin-grid/src/ObjectGrid.tsx @@ -35,6 +35,7 @@ import { Edit, Trash2, MoreVertical, ChevronRight, ChevronDown, Download, Rows2, import { useRowColor } from './useRowColor'; import { useGroupedData } from './useGroupedData'; import { GroupRow } from './GroupRow'; +import { useColumnSummary } from './useColumnSummary'; export interface ObjectGridProps { schema: ObjectGridSchema; @@ -352,6 +353,16 @@ export const ObjectGrid: React.FC = ({ // --- Grouping support --- const { groups, isGrouped, toggleGroup } = useGroupedData(schema.grouping, data); + // --- Column summary support --- + const summaryColumns = React.useMemo(() => { + const cols = normalizeColumns(schema.columns); + if (cols && cols.length > 0 && typeof cols[0] === 'object') { + return cols as ListColumn[]; + } + return undefined; + }, [schema.columns]); + const { summaries, hasSummary } = useColumnSummary(summaryColumns, data); + const generateColumns = useCallback(() => { // Map field type to column header icon (Airtable-style) const getTypeIcon = (fieldType: string | null): React.ReactNode => { @@ -580,6 +591,7 @@ export const ObjectGrid: React.FC = ({ ...(col.resizable !== undefined && { resizable: col.resizable }), ...(col.wrap !== undefined && { wrap: col.wrap }), ...(cellRenderer && { cell: cellRenderer }), + ...(col.pinned && { pinned: col.pinned }), }; }); } @@ -781,6 +793,29 @@ export const ObjectGrid: React.FC = ({ }, ] : persistedColumns; + // --- Pinned column reordering --- + // Reorder: pinned:'left' first, unpinned middle, pinned:'right' last + const pinnedLeftCols = columnsWithActions.filter((c: any) => c.pinned === 'left'); + const pinnedRightCols = columnsWithActions.filter((c: any) => c.pinned === 'right'); + const unpinnedCols = columnsWithActions.filter((c: any) => !c.pinned); + const hasPinnedColumns = pinnedLeftCols.length > 0 || pinnedRightCols.length > 0; + const orderedColumns = hasPinnedColumns + ? [ + ...pinnedLeftCols, + ...unpinnedCols, + ...pinnedRightCols.map((col: any) => ({ + ...col, + className: [col.className, 'sticky right-0 z-10 bg-background border-l border-border'].filter(Boolean).join(' '), + cellClassName: [col.cellClassName, 'sticky right-0 z-10 bg-background border-l border-border'].filter(Boolean).join(' '), + })), + ] + : columnsWithActions; + + // Calculate frozenColumns: if pinned columns exist, use left-pinned count; otherwise use schema default + const effectiveFrozenColumns = hasPinnedColumns + ? pinnedLeftCols.length + : (schema.frozenColumns ?? 1); + // Determine selection mode (support both new and legacy formats) let selectionMode: 'none' | 'single' | 'multiple' | boolean = false; if (schema.selection?.type) { @@ -807,7 +842,7 @@ export const ObjectGrid: React.FC = ({ const dataTableSchema: any = { type: 'data-table', caption: schema.label || schema.title, - columns: columnsWithActions, + columns: orderedColumns, data, pagination: paginationEnabled, pageSize: pageSize, @@ -833,7 +868,7 @@ export const ObjectGrid: React.FC = ({ showAddRow: !!operations?.create, onAddRecord: onAddRecord, rowClassName: schema.rowColor ? (row: any, _idx: number) => getRowClassName(row) : undefined, - frozenColumns: schema.frozenColumns ?? 1, + frozenColumns: effectiveFrozenColumns, onSelectionChange: onRowSelect, onRowClick: navigation.handleClick, onCellChange: onCellChange, @@ -1197,6 +1232,24 @@ export const ObjectGrid: React.FC = ({ ); }; + // Summary footer row + const summaryFooter = hasSummary ? ( +
+
+ {orderedColumns + .filter((col: any) => summaries.has(col.accessorKey)) + .map((col: any) => { + const summary = summaries.get(col.accessorKey)!; + return ( + + {col.header}: {summary.label} + + ); + })} +
+
+ ) : null; + // Render grid content: grouped (multiple tables with headers) or flat (single table) const gridContent = isGrouped ? (
@@ -1215,7 +1268,10 @@ export const ObjectGrid: React.FC = ({ ))}
) : ( - + <> + + {summaryFooter} + ); // For split mode, wrap the grid in the ResizablePanelGroup diff --git a/packages/plugin-grid/src/__tests__/column-features.test.tsx b/packages/plugin-grid/src/__tests__/column-features.test.tsx new file mode 100644 index 000000000..3e8cdf608 --- /dev/null +++ b/packages/plugin-grid/src/__tests__/column-features.test.tsx @@ -0,0 +1,360 @@ +/** + * Column Features Tests — pinned, summary, link, action + * + * Covers: pinned columns (left/right), column summary footer, + * link column rendering, action column rendering, + * and the useColumnSummary hook. + */ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import { renderHook } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; + +import { ObjectGrid } from '../ObjectGrid'; +import { useColumnSummary } from '../useColumnSummary'; +import { registerAllFields } from '@object-ui/fields'; +import { ActionProvider } from '@object-ui/react'; + +registerAllFields(); + +// --------------------------------------------------------------------------- +// Test data +// --------------------------------------------------------------------------- +const numericData = [ + { _id: '1', name: 'Alice', amount: 100, score: 80 }, + { _id: '2', name: 'Bob', amount: 200, score: 90 }, + { _id: '3', name: 'Charlie', amount: 300, score: 70 }, +]; + +const mixedData = [ + { _id: '1', name: 'Alice', status: 'active', amount: 150.5 }, + { _id: '2', name: 'Bob', status: 'inactive', amount: 250 }, + { _id: '3', name: 'Charlie', status: 'active', amount: 350 }, +]; + +// --------------------------------------------------------------------------- +// Helper +// --------------------------------------------------------------------------- +function renderGrid(opts?: Record) { + const schema: any = { + type: 'object-grid' as const, + objectName: 'test_object', + columns: [ + { field: 'name', label: 'Name' }, + { field: 'amount', label: 'Amount', type: 'number' }, + ], + data: { provider: 'value', items: numericData }, + ...opts, + }; + + return render( + + + + ); +} + +// ========================================================================= +// useColumnSummary hook tests +// ========================================================================= +describe('useColumnSummary', () => { + it('returns empty summaries when no columns have summary config', () => { + const columns = [ + { field: 'name', label: 'Name' }, + { field: 'amount', label: 'Amount' }, + ] as any[]; + const { result } = renderHook(() => useColumnSummary(columns, numericData)); + expect(result.current.hasSummary).toBe(false); + expect(result.current.summaries.size).toBe(0); + }); + + it('computes sum aggregation', () => { + const columns = [ + { field: 'name', label: 'Name' }, + { field: 'amount', label: 'Amount', summary: { type: 'sum' } }, + ] as any[]; + const { result } = renderHook(() => useColumnSummary(columns, numericData)); + expect(result.current.hasSummary).toBe(true); + const summary = result.current.summaries.get('amount'); + expect(summary).toBeDefined(); + expect(summary!.value).toBe(600); + expect(summary!.label).toContain('Sum'); + }); + + it('computes count aggregation', () => { + const columns = [ + { field: 'name', label: 'Name', summary: 'count' }, + ] as any[]; + const { result } = renderHook(() => useColumnSummary(columns, numericData)); + expect(result.current.hasSummary).toBe(true); + const summary = result.current.summaries.get('name'); + expect(summary!.value).toBe(3); + expect(summary!.label).toContain('Count'); + }); + + it('computes avg aggregation', () => { + const columns = [ + { field: 'score', label: 'Score', summary: { type: 'avg' } }, + ] as any[]; + const { result } = renderHook(() => useColumnSummary(columns, numericData)); + const summary = result.current.summaries.get('score'); + expect(summary!.value).toBe(80); + expect(summary!.label).toContain('Avg'); + }); + + it('computes min aggregation', () => { + const columns = [ + { field: 'amount', label: 'Amount', summary: { type: 'min' } }, + ] as any[]; + const { result } = renderHook(() => useColumnSummary(columns, numericData)); + const summary = result.current.summaries.get('amount'); + expect(summary!.value).toBe(100); + expect(summary!.label).toContain('Min'); + }); + + it('computes max aggregation', () => { + const columns = [ + { field: 'amount', label: 'Amount', summary: { type: 'max' } }, + ] as any[]; + const { result } = renderHook(() => useColumnSummary(columns, numericData)); + const summary = result.current.summaries.get('amount'); + expect(summary!.value).toBe(300); + expect(summary!.label).toContain('Max'); + }); + + it('handles string shorthand for summary type', () => { + const columns = [ + { field: 'amount', label: 'Amount', summary: 'sum' }, + ] as any[]; + const { result } = renderHook(() => useColumnSummary(columns, numericData)); + const summary = result.current.summaries.get('amount'); + expect(summary!.value).toBe(600); + }); + + it('handles empty data array', () => { + const columns = [ + { field: 'amount', label: 'Amount', summary: { type: 'sum' } }, + ] as any[]; + const { result } = renderHook(() => useColumnSummary(columns, [])); + expect(result.current.hasSummary).toBe(false); + }); + + it('handles undefined columns', () => { + const { result } = renderHook(() => useColumnSummary(undefined, numericData)); + expect(result.current.hasSummary).toBe(false); + }); + + it('handles summary with custom field reference', () => { + const columns = [ + { field: 'name', label: 'Name', summary: { type: 'sum', field: 'amount' } }, + ] as any[]; + const { result } = renderHook(() => useColumnSummary(columns, numericData)); + const summary = result.current.summaries.get('name'); + expect(summary!.value).toBe(600); + }); + + it('handles string numeric values', () => { + const data = [ + { _id: '1', amount: '100' }, + { _id: '2', amount: '200' }, + ]; + const columns = [ + { field: 'amount', label: 'Amount', summary: { type: 'sum' } }, + ] as any[]; + const { result } = renderHook(() => useColumnSummary(columns, data)); + const summary = result.current.summaries.get('amount'); + expect(summary!.value).toBe(300); + }); + + it('computes multiple summaries simultaneously', () => { + const columns = [ + { field: 'amount', label: 'Amount', summary: { type: 'sum' } }, + { field: 'score', label: 'Score', summary: { type: 'avg' } }, + ] as any[]; + const { result } = renderHook(() => useColumnSummary(columns, numericData)); + expect(result.current.summaries.size).toBe(2); + expect(result.current.summaries.get('amount')!.value).toBe(600); + expect(result.current.summaries.get('score')!.value).toBe(80); + }); +}); + +// ========================================================================= +// Summary footer rendering in ObjectGrid +// ========================================================================= +describe('Summary footer rendering', () => { + it('renders summary footer when columns have summary config', async () => { + renderGrid({ + columns: [ + { field: 'name', label: 'Name', summary: 'count' }, + { field: 'amount', label: 'Amount', type: 'number', summary: { type: 'sum' } }, + ], + }); + + await waitFor(() => { + expect(screen.getByText('Name')).toBeInTheDocument(); + }); + + const footer = screen.getByTestId('column-summary-footer'); + expect(footer).toBeInTheDocument(); + expect(screen.getByTestId('summary-name')).toHaveTextContent('Count: 3'); + expect(screen.getByTestId('summary-amount')).toHaveTextContent('Sum: 600'); + }); + + it('does not render summary footer when no columns have summary config', async () => { + renderGrid(); + + await waitFor(() => { + expect(screen.getByText('Name')).toBeInTheDocument(); + }); + + expect(screen.queryByTestId('column-summary-footer')).not.toBeInTheDocument(); + }); +}); + +// ========================================================================= +// Pinned column support +// ========================================================================= +describe('Pinned columns', () => { + it('renders pinned left columns first in order', async () => { + renderGrid({ + columns: [ + { field: 'amount', label: 'Amount', type: 'number' }, + { field: 'name', label: 'Name', pinned: 'left' }, + ], + }); + + await waitFor(() => { + expect(screen.getByText('Name')).toBeInTheDocument(); + }); + + // Both columns should be visible + expect(screen.getByText('Amount')).toBeInTheDocument(); + expect(screen.getByText('Name')).toBeInTheDocument(); + }); + + it('renders pinned right columns last in order', async () => { + renderGrid({ + columns: [ + { field: 'name', label: 'Name' }, + { field: 'amount', label: 'Amount', type: 'number', pinned: 'right' }, + ], + }); + + await waitFor(() => { + expect(screen.getByText('Name')).toBeInTheDocument(); + }); + + expect(screen.getByText('Amount')).toBeInTheDocument(); + }); + + it('handles mixed pinned and unpinned columns', async () => { + renderGrid({ + columns: [ + { field: 'score', label: 'Score', type: 'number' }, + { field: 'name', label: 'Name', pinned: 'left' }, + { field: 'amount', label: 'Amount', type: 'number', pinned: 'right' }, + ], + data: { provider: 'value', items: numericData }, + }); + + await waitFor(() => { + expect(screen.getByText('Name')).toBeInTheDocument(); + }); + + // All columns should be rendered + expect(screen.getByText('Score')).toBeInTheDocument(); + expect(screen.getByText('Amount')).toBeInTheDocument(); + }); +}); + +// ========================================================================= +// Link column rendering +// ========================================================================= +describe('Link columns', () => { + it('renders link column content as clickable button', async () => { + renderGrid({ + columns: [ + { field: 'name', label: 'Name', link: true }, + { field: 'amount', label: 'Amount', type: 'number' }, + ], + }); + + await waitFor(() => { + expect(screen.getByText('Name')).toBeInTheDocument(); + }); + + // Link columns should have clickable buttons with text-primary class + const linkButtons = screen.getAllByRole('button').filter( + btn => btn.classList.contains('text-primary') + ); + expect(linkButtons.length).toBeGreaterThan(0); + }); +}); + +// ========================================================================= +// Action column rendering +// ========================================================================= +describe('Action columns', () => { + it('renders action column with action button', async () => { + const mockExecuteAction = vi.fn(); + + renderGrid({ + columns: [ + { field: 'name', label: 'Name' }, + { field: 'amount', label: 'Amount', type: 'number', action: 'edit' }, + ], + }); + + await waitFor(() => { + expect(screen.getByText('Name')).toBeInTheDocument(); + }); + + // Action columns render with clickable buttons + const actionButtons = screen.getAllByRole('button').filter( + btn => btn.classList.contains('text-primary') + ); + expect(actionButtons.length).toBeGreaterThan(0); + }); +}); + +// ========================================================================= +// Combined features +// ========================================================================= +describe('Combined column features', () => { + it('supports pinned + summary on the same column', async () => { + renderGrid({ + columns: [ + { field: 'name', label: 'Name', pinned: 'left' }, + { field: 'amount', label: 'Amount', type: 'number', summary: { type: 'sum' } }, + ], + }); + + await waitFor(() => { + expect(screen.getByText('Name')).toBeInTheDocument(); + }); + + const footer = screen.getByTestId('column-summary-footer'); + expect(footer).toBeInTheDocument(); + expect(screen.getByTestId('summary-amount')).toHaveTextContent('Sum: 600'); + }); + + it('supports link + action on same column (link takes priority)', async () => { + renderGrid({ + columns: [ + { field: 'name', label: 'Name', link: true, action: 'view' }, + { field: 'amount', label: 'Amount', type: 'number' }, + ], + }); + + await waitFor(() => { + expect(screen.getByText('Name')).toBeInTheDocument(); + }); + + // Should render as clickable (link takes priority) + const linkButtons = screen.getAllByRole('button').filter( + btn => btn.classList.contains('text-primary') + ); + expect(linkButtons.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/plugin-grid/src/index.tsx b/packages/plugin-grid/src/index.tsx index ca6ea1544..e9604841a 100644 --- a/packages/plugin-grid/src/index.tsx +++ b/packages/plugin-grid/src/index.tsx @@ -21,6 +21,7 @@ export { GroupRow } from './GroupRow'; export { useCellClipboard } from './useCellClipboard'; export { useGradientColor } from './useGradientColor'; export { useGroupReorder } from './useGroupReorder'; +export { useColumnSummary } from './useColumnSummary'; export { FormulaBar } from './FormulaBar'; export { SplitPaneGrid } from './SplitPaneGrid'; export type { ObjectGridProps } from './ObjectGrid'; @@ -32,6 +33,7 @@ export type { GroupRowProps } from './GroupRow'; export type { CellRange, UseCellClipboardOptions, UseCellClipboardResult } from './useCellClipboard'; export type { GradientStop, UseGradientColorOptions } from './useGradientColor'; export type { UseGroupReorderOptions, UseGroupReorderResult } from './useGroupReorder'; +export type { ColumnSummaryConfig, ColumnSummaryResult } from './useColumnSummary'; export type { FormulaBarProps } from './FormulaBar'; export type { SplitPaneGridProps } from './SplitPaneGrid'; diff --git a/packages/plugin-grid/src/useColumnSummary.ts b/packages/plugin-grid/src/useColumnSummary.ts new file mode 100644 index 000000000..dd0d40f87 --- /dev/null +++ b/packages/plugin-grid/src/useColumnSummary.ts @@ -0,0 +1,126 @@ +/** + * 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 { useMemo } from 'react'; +import type { ListColumn } from '@object-ui/types'; + +/** + * Summary configuration for a column. + * Can be a string shorthand (e.g. 'sum') or a full config object. + */ +export type ColumnSummaryConfig = string | { type: 'count' | 'sum' | 'avg' | 'min' | 'max'; field?: string }; + +export interface ColumnSummaryResult { + field: string; + value: number | null; + label: string; +} + +/** + * Normalize summary config from string or object to a standard shape. + */ +function normalizeSummary(summary: ColumnSummaryConfig): { type: string; field?: string } { + if (typeof summary === 'string') { + return { type: summary }; + } + return summary; +} + +/** + * Compute a single aggregation over data values. + */ +function computeAggregation(type: string, values: number[]): number | null { + if (values.length === 0) return null; + + switch (type) { + case 'count': + return values.length; + case 'sum': + return values.reduce((a, b) => a + b, 0); + case 'avg': + return values.reduce((a, b) => a + b, 0) / values.length; + case 'min': + return Math.min(...values); + case 'max': + return Math.max(...values); + default: + return null; + } +} + +/** + * Format a summary value for display. + */ +function formatSummaryLabel(type: string, value: number | null): string { + if (value === null) return ''; + const typeLabels: Record = { + count: 'Count', + sum: 'Sum', + avg: 'Avg', + min: 'Min', + max: 'Max', + }; + const label = typeLabels[type] || type; + const formatted = type === 'avg' + ? value.toLocaleString(undefined, { maximumFractionDigits: 2 }) + : value.toLocaleString(); + return `${label}: ${formatted}`; +} + +/** + * Hook to compute column summary/aggregation values. + * + * @param columns - Column definitions (may include `summary` config) + * @param data - Row data array + * @returns Map of field name to summary result, and a flag if any summaries exist + */ +export function useColumnSummary( + columns: ListColumn[] | undefined, + data: any[] +): { summaries: Map; hasSummary: boolean } { + return useMemo(() => { + const summaries = new Map(); + + if (!columns || columns.length === 0 || data.length === 0) { + return { summaries, hasSummary: false }; + } + + for (const col of columns) { + if (!col.summary) continue; + + const config = normalizeSummary(col.summary as ColumnSummaryConfig); + const targetField = config.field || col.field; + + // Extract numeric values from data + const values: number[] = []; + for (const row of data) { + const v = row[targetField]; + if (v != null && typeof v === 'number' && !isNaN(v)) { + values.push(v); + } else if (v != null && typeof v === 'string') { + const parsed = parseFloat(v); + if (!isNaN(parsed)) values.push(parsed); + } + } + + // For 'count', count all non-null values (not just numeric) + const effectiveValues = config.type === 'count' + ? data.filter(row => row[targetField] != null && row[targetField] !== '').map((_, i) => i) + : values; + + const result = computeAggregation(config.type, effectiveValues); + summaries.set(col.field, { + field: col.field, + value: result, + label: formatSummaryLabel(config.type, result), + }); + } + + return { summaries, hasSummary: summaries.size > 0 }; + }, [columns, data]); +} diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index 13380da40..c7d791408 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -73,6 +73,14 @@ export const ListColumnSchema = z.object({ type: z.string().optional().describe('Renderer type override'), link: z.boolean().optional().describe('Functions as the primary navigation link (triggers View navigation)'), action: z.string().optional().describe('Registered Action ID to execute when clicked'), + pinned: z.enum(['left', 'right']).optional().describe('Pin column to left or right edge'), + summary: z.union([ + z.string(), + z.object({ + type: z.enum(['count', 'sum', 'avg', 'min', 'max']).describe('Aggregation type'), + field: z.string().optional().describe('Field to aggregate (defaults to column field)'), + }), + ]).optional().describe('Column footer summary/aggregation'), prefix: z.object({ field: z.string().describe('Field name to render as prefix'), type: z.enum(['badge', 'text']).optional().describe('Renderer type for the prefix'), From a440f1db7c084e3d69c73f73f80ad9ca85782157 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Feb 2026 09:44:22 +0000 Subject: [PATCH 3/6] refactor: address code review feedback for column features Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- packages/plugin-grid/src/ObjectGrid.tsx | 5 +++-- packages/plugin-grid/src/useColumnSummary.ts | 12 +++++++----- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/plugin-grid/src/ObjectGrid.tsx b/packages/plugin-grid/src/ObjectGrid.tsx index b5aeb6f69..04a7cfb49 100644 --- a/packages/plugin-grid/src/ObjectGrid.tsx +++ b/packages/plugin-grid/src/ObjectGrid.tsx @@ -799,14 +799,15 @@ export const ObjectGrid: React.FC = ({ const pinnedRightCols = columnsWithActions.filter((c: any) => c.pinned === 'right'); const unpinnedCols = columnsWithActions.filter((c: any) => !c.pinned); const hasPinnedColumns = pinnedLeftCols.length > 0 || pinnedRightCols.length > 0; + const rightPinnedClasses = 'sticky right-0 z-10 bg-background border-l border-border'; const orderedColumns = hasPinnedColumns ? [ ...pinnedLeftCols, ...unpinnedCols, ...pinnedRightCols.map((col: any) => ({ ...col, - className: [col.className, 'sticky right-0 z-10 bg-background border-l border-border'].filter(Boolean).join(' '), - cellClassName: [col.cellClassName, 'sticky right-0 z-10 bg-background border-l border-border'].filter(Boolean).join(' '), + className: [col.className, rightPinnedClasses].filter(Boolean).join(' '), + cellClassName: [col.cellClassName, rightPinnedClasses].filter(Boolean).join(' '), })), ] : columnsWithActions; diff --git a/packages/plugin-grid/src/useColumnSummary.ts b/packages/plugin-grid/src/useColumnSummary.ts index dd0d40f87..7ab4818c1 100644 --- a/packages/plugin-grid/src/useColumnSummary.ts +++ b/packages/plugin-grid/src/useColumnSummary.ts @@ -109,11 +109,13 @@ export function useColumnSummary( } // For 'count', count all non-null values (not just numeric) - const effectiveValues = config.type === 'count' - ? data.filter(row => row[targetField] != null && row[targetField] !== '').map((_, i) => i) - : values; - - const result = computeAggregation(config.type, effectiveValues); + let result: number | null; + if (config.type === 'count') { + const count = data.filter(row => row[targetField] != null && row[targetField] !== '').length; + result = count > 0 ? count : null; + } else { + result = computeAggregation(config.type, values); + } summaries.set(col.field, { field: col.field, value: result, From 20e8bbe41eb97d651573366ca5f787e8e9c4363a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Feb 2026 09:44:48 +0000 Subject: [PATCH 4/6] docs: update ROADMAP with column pinned/summary implementation details Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- ROADMAP.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 0db4ff1ec..054913ec8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -688,8 +688,8 @@ The `FlowDesigner` is a canvas-based flow editor that bridges the gap between th - [x] `quickFilters` structure reconciliation: Auto-normalizes spec `{ field, operator, value }` format into ObjectUI `{ id, label, filters[] }` format. Both formats supported simultaneously. - [x] `conditionalFormatting` expression reconciliation: Supports spec `{ condition, style }` format alongside ObjectUI field/operator/value rules. `condition` is treated as alias for `expression`, `style` object merged into CSS properties. - [x] `exportOptions` schema reconciliation: Accepts both spec `string[]` format (e.g., `['csv', 'xlsx']`) and ObjectUI object format `{ formats, maxRecords, includeHeaders, fileNamePrefix }`. -- [x] Column `pinned`: `pinned` property added to ListViewSchema column type. Bridge passes through to ObjectGrid which supports `frozenColumns`. -- [x] Column `summary`: `summary` property added to ListViewSchema column type. Bridge passes through for aggregation rendering. +- [x] Column `pinned`: `pinned` property added to ListViewSchema column type. Bridge passes through to ObjectGrid which supports `frozenColumns`. ObjectGrid reorders columns (left-pinned first, right-pinned last with sticky CSS). Zod schema updated with `pinned` field. `useColumnSummary` hook created. +- [x] Column `summary`: `summary` property added to ListViewSchema column type. Bridge passes through for aggregation rendering. ObjectGrid renders summary footer with count/sum/avg/min/max aggregations via `useColumnSummary` hook. Zod schema updated with `summary` field. - [x] Column `link`: ObjectGrid renders click-to-navigate buttons on link-type columns with `navigation.handleClick`. Primary field auto-linked. - [x] Column `action`: ObjectGrid renders action dispatch buttons via `executeAction` on action-type columns. From 324bc12feaefee2a23476891c3bbd91048e4a8d8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Feb 2026 09:57:45 +0000 Subject: [PATCH 5/6] fix: improve action column to render proper Button, add data-testid to link/action cells, strengthen tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Action column now renders a Shadcn Button component with formatted label (e.g. 'edit' → 'Edit', 'send_email' → 'Send Email') - Link cells have data-testid="link-cell" for testability - Action cells have data-testid="action-cell" for testability - Tests improved: use data-testid instead of fragile CSS class filtering - Added tests: avg summary footer, auto-linked primary field, multi-word action label, action button click, pinned+action combo, all-four-features combo - Test count: 28 tests (up from 21) Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- packages/plugin-grid/src/ObjectGrid.tsx | 25 ++- .../src/__tests__/column-features.test.tsx | 178 +++++++++++++++--- 2 files changed, 166 insertions(+), 37 deletions(-) diff --git a/packages/plugin-grid/src/ObjectGrid.tsx b/packages/plugin-grid/src/ObjectGrid.tsx index 04a7cfb49..b80053d44 100644 --- a/packages/plugin-grid/src/ObjectGrid.tsx +++ b/packages/plugin-grid/src/ObjectGrid.tsx @@ -485,7 +485,7 @@ export const ObjectGrid: React.FC = ({ + {formatActionLabel(col.action!)} + ); }; } else if (CellRenderer) { diff --git a/packages/plugin-grid/src/__tests__/column-features.test.tsx b/packages/plugin-grid/src/__tests__/column-features.test.tsx index 3e8cdf608..4c3398add 100644 --- a/packages/plugin-grid/src/__tests__/column-features.test.tsx +++ b/packages/plugin-grid/src/__tests__/column-features.test.tsx @@ -27,12 +27,6 @@ const numericData = [ { _id: '3', name: 'Charlie', amount: 300, score: 70 }, ]; -const mixedData = [ - { _id: '1', name: 'Alice', status: 'active', amount: 150.5 }, - { _id: '2', name: 'Bob', status: 'inactive', amount: 250 }, - { _id: '3', name: 'Charlie', status: 'active', amount: 350 }, -]; - // --------------------------------------------------------------------------- // Helper // --------------------------------------------------------------------------- @@ -210,6 +204,24 @@ describe('Summary footer rendering', () => { expect(screen.queryByTestId('column-summary-footer')).not.toBeInTheDocument(); }); + + it('renders avg summary with formatted decimal', async () => { + renderGrid({ + columns: [ + { field: 'name', label: 'Name' }, + { field: 'score', label: 'Score', type: 'number', summary: { type: 'avg' } }, + ], + data: { provider: 'value', items: numericData }, + }); + + await waitFor(() => { + expect(screen.getByText('Name')).toBeInTheDocument(); + }); + + const footer = screen.getByTestId('column-summary-footer'); + expect(footer).toBeInTheDocument(); + expect(screen.getByTestId('summary-score')).toHaveTextContent('Avg: 80'); + }); }); // ========================================================================= @@ -266,13 +278,28 @@ describe('Pinned columns', () => { expect(screen.getByText('Score')).toBeInTheDocument(); expect(screen.getByText('Amount')).toBeInTheDocument(); }); + + it('works with no pinned columns (preserves default frozenColumns)', async () => { + renderGrid({ + columns: [ + { field: 'name', label: 'Name' }, + { field: 'amount', label: 'Amount', type: 'number' }, + ], + }); + + await waitFor(() => { + expect(screen.getByText('Name')).toBeInTheDocument(); + }); + + expect(screen.getByText('Amount')).toBeInTheDocument(); + }); }); // ========================================================================= // Link column rendering // ========================================================================= describe('Link columns', () => { - it('renders link column content as clickable button', async () => { + it('renders link column content as clickable element with data-testid', async () => { renderGrid({ columns: [ { field: 'name', label: 'Name', link: true }, @@ -284,11 +311,31 @@ describe('Link columns', () => { expect(screen.getByText('Name')).toBeInTheDocument(); }); - // Link columns should have clickable buttons with text-primary class - const linkButtons = screen.getAllByRole('button').filter( - btn => btn.classList.contains('text-primary') - ); - expect(linkButtons.length).toBeGreaterThan(0); + // Link cells should have data-testid="link-cell" + const linkCells = screen.getAllByTestId('link-cell'); + expect(linkCells.length).toBeGreaterThan(0); + + // Link cells should have text-primary class (blue clickable) + linkCells.forEach(cell => { + expect(cell).toHaveClass('text-primary'); + }); + }); + + it('renders primary field (first column) as auto-linked', async () => { + renderGrid({ + columns: [ + { field: 'name', label: 'Name' }, + { field: 'amount', label: 'Amount', type: 'number' }, + ], + }); + + await waitFor(() => { + expect(screen.getByText('Name')).toBeInTheDocument(); + }); + + // Primary field should auto-link with data-testid="primary-field-link" + const primaryLinks = screen.getAllByTestId('primary-field-link'); + expect(primaryLinks.length).toBeGreaterThan(0); }); }); @@ -296,9 +343,7 @@ describe('Link columns', () => { // Action column rendering // ========================================================================= describe('Action columns', () => { - it('renders action column with action button', async () => { - const mockExecuteAction = vi.fn(); - + it('renders action column with proper button and formatted label', async () => { renderGrid({ columns: [ { field: 'name', label: 'Name' }, @@ -310,12 +355,50 @@ describe('Action columns', () => { expect(screen.getByText('Name')).toBeInTheDocument(); }); - // Action columns render with clickable buttons - const actionButtons = screen.getAllByRole('button').filter( - btn => btn.classList.contains('text-primary') - ); + // Action cells should have data-testid="action-cell" and render as Button + const actionCells = screen.getAllByTestId('action-cell'); + expect(actionCells.length).toBeGreaterThan(0); + + // Action button should show formatted action label + const editButtons = screen.getAllByText('Edit'); + expect(editButtons.length).toBeGreaterThan(0); + }); + + it('renders formatted action label for multi-word actions', async () => { + renderGrid({ + columns: [ + { field: 'name', label: 'Name' }, + { field: 'amount', label: 'Amount', type: 'number', action: 'send_email' }, + ], + }); + + await waitFor(() => { + expect(screen.getByText('Name')).toBeInTheDocument(); + }); + + // 'send_email' should be formatted as 'Send Email' + const actionButtons = screen.getAllByText('Send Email'); expect(actionButtons.length).toBeGreaterThan(0); }); + + it('action button is clickable', async () => { + renderGrid({ + columns: [ + { field: 'name', label: 'Name' }, + { field: 'amount', label: 'Amount', type: 'number', action: 'edit' }, + ], + }); + + await waitFor(() => { + expect(screen.getByText('Name')).toBeInTheDocument(); + }); + + const actionCells = screen.getAllByTestId('action-cell'); + expect(actionCells.length).toBeGreaterThan(0); + + // Click should not throw + fireEvent.click(actionCells[0]); + }); }); // ========================================================================= @@ -351,10 +434,57 @@ describe('Combined column features', () => { expect(screen.getByText('Name')).toBeInTheDocument(); }); - // Should render as clickable (link takes priority) - const linkButtons = screen.getAllByRole('button').filter( - btn => btn.classList.contains('text-primary') - ); - expect(linkButtons.length).toBeGreaterThan(0); + // Link takes priority — should render as link-cell, not action-cell + const linkCells = screen.getAllByTestId('link-cell'); + expect(linkCells.length).toBeGreaterThan(0); + }); + + it('supports pinned + action on the same column', async () => { + renderGrid({ + columns: [ + { field: 'name', label: 'Name' }, + { field: 'amount', label: 'Amount', type: 'number', pinned: 'right', action: 'approve' }, + ], + }); + + await waitFor(() => { + expect(screen.getByText('Name')).toBeInTheDocument(); + }); + + const actionCells = screen.getAllByTestId('action-cell'); + expect(actionCells.length).toBeGreaterThan(0); + + // Action buttons should show formatted label + const approveButtons = screen.getAllByText('Approve'); + expect(approveButtons.length).toBeGreaterThan(0); + }); + + it('supports all four features together', async () => { + renderGrid({ + columns: [ + { field: 'name', label: 'Name', pinned: 'left', link: true }, + { field: 'score', label: 'Score', type: 'number', summary: { type: 'avg' } }, + { field: 'amount', label: 'Amount', type: 'number', pinned: 'right', action: 'edit', summary: { type: 'sum' } }, + ], + data: { provider: 'value', items: numericData }, + }); + + await waitFor(() => { + expect(screen.getByText('Name')).toBeInTheDocument(); + }); + + // Link cells on name column + const linkCells = screen.getAllByTestId('link-cell'); + expect(linkCells.length).toBeGreaterThan(0); + + // Action cells on amount column + const actionCells = screen.getAllByTestId('action-cell'); + expect(actionCells.length).toBeGreaterThan(0); + + // Summary footer + const footer = screen.getByTestId('column-summary-footer'); + expect(footer).toBeInTheDocument(); + expect(screen.getByTestId('summary-amount')).toHaveTextContent('Sum: 600'); + expect(screen.getByTestId('summary-score')).toHaveTextContent('Avg: 80'); }); }); From 85be0c78fb3c7523350cbe7096768a50589dd2d0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Feb 2026 10:00:40 +0000 Subject: [PATCH 6/6] fix: restore value param in action column executeAction call Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- packages/plugin-grid/src/ObjectGrid.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/plugin-grid/src/ObjectGrid.tsx b/packages/plugin-grid/src/ObjectGrid.tsx index b80053d44..0cd55db1d 100644 --- a/packages/plugin-grid/src/ObjectGrid.tsx +++ b/packages/plugin-grid/src/ObjectGrid.tsx @@ -517,7 +517,7 @@ export const ObjectGrid: React.FC = ({ }; } else if (col.action) { // Action column: render as action button - cellRenderer = (_value: any, row: any) => { + cellRenderer = (value: any, row: any) => { return (