Skip to content

Commit 78946eb

Browse files
authored
Merge pull request #782 from objectstack-ai/copilot/implement-column-features
2 parents d5a0ede + 85be0c7 commit 78946eb

File tree

6 files changed

+700
-16
lines changed

6 files changed

+700
-16
lines changed

ROADMAP.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -688,8 +688,8 @@ The `FlowDesigner` is a canvas-based flow editor that bridges the gap between th
688688
- [x] `quickFilters` structure reconciliation: Auto-normalizes spec `{ field, operator, value }` format into ObjectUI `{ id, label, filters[] }` format. Both formats supported simultaneously. Dual-format type union (`QuickFilterItem = ObjectUIQuickFilterItem | SpecQuickFilterItem`) exported from `@object-ui/types`. Standalone `normalizeQuickFilter()` / `normalizeQuickFilters()` adapter functions in `@object-ui/core`. Bridge (`list-view.ts`) normalizes at spec→SchemaNode transform time. Spec shorthand operators (`eq`, `ne`, `gt`, `gte`, `lt`, `lte`) mapped to ObjectStack AST operators. Mixed-format arrays handled transparently.
689689
- [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.
690690
- [x] `exportOptions` schema reconciliation: Accepts both spec `string[]` format (e.g., `['csv', 'xlsx']`) and ObjectUI object format `{ formats, maxRecords, includeHeaders, fileNamePrefix }`.
691-
- [x] Column `pinned`: `pinned` property added to ListViewSchema column type. Bridge passes through to ObjectGrid which supports `frozenColumns`.
692-
- [x] Column `summary`: `summary` property added to ListViewSchema column type. Bridge passes through for aggregation rendering.
691+
- [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.
692+
- [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.
693693
- [x] Column `link`: ObjectGrid renders click-to-navigate buttons on link-type columns with `navigation.handleClick`. Primary field auto-linked.
694694
- [x] Column `action`: ObjectGrid renders action dispatch buttons via `executeAction` on action-type columns.
695695

packages/plugin-grid/src/ObjectGrid.tsx

Lines changed: 70 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import { Edit, Trash2, MoreVertical, ChevronRight, ChevronDown, Download, Rows2,
3535
import { useRowColor } from './useRowColor';
3636
import { useGroupedData } from './useGroupedData';
3737
import { GroupRow } from './GroupRow';
38+
import { useColumnSummary } from './useColumnSummary';
3839

3940
export interface ObjectGridProps {
4041
schema: ObjectGridSchema;
@@ -352,6 +353,16 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
352353
// --- Grouping support ---
353354
const { groups, isGrouped, toggleGroup } = useGroupedData(schema.grouping, data);
354355

356+
// --- Column summary support ---
357+
const summaryColumns = React.useMemo(() => {
358+
const cols = normalizeColumns(schema.columns);
359+
if (cols && cols.length > 0 && typeof cols[0] === 'object') {
360+
return cols as ListColumn[];
361+
}
362+
return undefined;
363+
}, [schema.columns]);
364+
const { summaries, hasSummary } = useColumnSummary(summaryColumns, data);
365+
355366
const generateColumns = useCallback(() => {
356367
// Map field type to column header icon (Airtable-style)
357368
const getTypeIcon = (fieldType: string | null): React.ReactNode => {
@@ -474,7 +485,7 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
474485
<button
475486
type="button"
476487
className="text-primary font-medium underline-offset-4 hover:underline cursor-pointer bg-transparent border-none p-0 text-left font-inherit"
477-
data-testid={isPrimaryField ? 'primary-field-link' : undefined}
488+
data-testid={isPrimaryField ? 'primary-field-link' : 'link-cell'}
478489
onClick={(e) => {
479490
e.stopPropagation();
480491
navigation.handleClick(row);
@@ -494,7 +505,7 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
494505
<button
495506
type="button"
496507
className="text-primary font-medium underline-offset-4 hover:underline cursor-pointer bg-transparent border-none p-0 text-left font-inherit"
497-
data-testid={isPrimaryField ? 'primary-field-link' : undefined}
508+
data-testid={isPrimaryField ? 'primary-field-link' : 'link-cell'}
498509
onClick={(e) => {
499510
e.stopPropagation();
500511
navigation.handleClick(row);
@@ -505,15 +516,14 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
505516
);
506517
};
507518
} else if (col.action) {
508-
// Action column: clicking executes the registered action
519+
// Action column: render as action button
509520
cellRenderer = (value: any, row: any) => {
510-
const displayContent = CellRenderer
511-
? <CellRenderer value={value} field={{ name: col.field, type: inferredType || 'text' } as any} />
512-
: (value != null && value !== '' ? String(value) : <span className="text-muted-foreground/50 text-xs italic"></span>);
513521
return (
514-
<button
515-
type="button"
516-
className="text-primary underline-offset-4 hover:underline cursor-pointer bg-transparent border-none p-0 text-left font-inherit"
522+
<Button
523+
variant="outline"
524+
size="sm"
525+
className="h-7 text-xs"
526+
data-testid="action-cell"
517527
onClick={(e) => {
518528
e.stopPropagation();
519529
executeAction({
@@ -522,8 +532,8 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
522532
});
523533
}}
524534
>
525-
{displayContent}
526-
</button>
535+
{formatActionLabel(col.action!)}
536+
</Button>
527537
);
528538
};
529539
} else if (CellRenderer) {
@@ -580,6 +590,7 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
580590
...(col.resizable !== undefined && { resizable: col.resizable }),
581591
...(col.wrap !== undefined && { wrap: col.wrap }),
582592
...(cellRenderer && { cell: cellRenderer }),
593+
...(col.pinned && { pinned: col.pinned }),
583594
};
584595
});
585596
}
@@ -781,6 +792,30 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
781792
},
782793
] : persistedColumns;
783794

795+
// --- Pinned column reordering ---
796+
// Reorder: pinned:'left' first, unpinned middle, pinned:'right' last
797+
const pinnedLeftCols = columnsWithActions.filter((c: any) => c.pinned === 'left');
798+
const pinnedRightCols = columnsWithActions.filter((c: any) => c.pinned === 'right');
799+
const unpinnedCols = columnsWithActions.filter((c: any) => !c.pinned);
800+
const hasPinnedColumns = pinnedLeftCols.length > 0 || pinnedRightCols.length > 0;
801+
const rightPinnedClasses = 'sticky right-0 z-10 bg-background border-l border-border';
802+
const orderedColumns = hasPinnedColumns
803+
? [
804+
...pinnedLeftCols,
805+
...unpinnedCols,
806+
...pinnedRightCols.map((col: any) => ({
807+
...col,
808+
className: [col.className, rightPinnedClasses].filter(Boolean).join(' '),
809+
cellClassName: [col.cellClassName, rightPinnedClasses].filter(Boolean).join(' '),
810+
})),
811+
]
812+
: columnsWithActions;
813+
814+
// Calculate frozenColumns: if pinned columns exist, use left-pinned count; otherwise use schema default
815+
const effectiveFrozenColumns = hasPinnedColumns
816+
? pinnedLeftCols.length
817+
: (schema.frozenColumns ?? 1);
818+
784819
// Determine selection mode (support both new and legacy formats)
785820
let selectionMode: 'none' | 'single' | 'multiple' | boolean = false;
786821
if (schema.selection?.type) {
@@ -807,7 +842,7 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
807842
const dataTableSchema: any = {
808843
type: 'data-table',
809844
caption: schema.label || schema.title,
810-
columns: columnsWithActions,
845+
columns: orderedColumns,
811846
data,
812847
pagination: paginationEnabled,
813848
pageSize: pageSize,
@@ -833,7 +868,7 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
833868
showAddRow: !!operations?.create,
834869
onAddRecord: onAddRecord,
835870
rowClassName: schema.rowColor ? (row: any, _idx: number) => getRowClassName(row) : undefined,
836-
frozenColumns: schema.frozenColumns ?? 1,
871+
frozenColumns: effectiveFrozenColumns,
837872
onSelectionChange: onRowSelect,
838873
onRowClick: navigation.handleClick,
839874
onCellChange: onCellChange,
@@ -1197,6 +1232,24 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
11971232
);
11981233
};
11991234

1235+
// Summary footer row
1236+
const summaryFooter = hasSummary ? (
1237+
<div className="border-t bg-muted/30 px-2 py-1.5" data-testid="column-summary-footer">
1238+
<div className="flex gap-4 text-xs text-muted-foreground font-medium">
1239+
{orderedColumns
1240+
.filter((col: any) => summaries.has(col.accessorKey))
1241+
.map((col: any) => {
1242+
const summary = summaries.get(col.accessorKey)!;
1243+
return (
1244+
<span key={col.accessorKey} data-testid={`summary-${col.accessorKey}`}>
1245+
{col.header}: {summary.label}
1246+
</span>
1247+
);
1248+
})}
1249+
</div>
1250+
</div>
1251+
) : null;
1252+
12001253
// Render grid content: grouped (multiple tables with headers) or flat (single table)
12011254
const gridContent = isGrouped ? (
12021255
<div className="space-y-2">
@@ -1215,7 +1268,10 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
12151268
))}
12161269
</div>
12171270
) : (
1218-
<SchemaRenderer schema={dataTableSchema} />
1271+
<>
1272+
<SchemaRenderer schema={dataTableSchema} />
1273+
{summaryFooter}
1274+
</>
12191275
);
12201276

12211277
// For split mode, wrap the grid in the ResizablePanelGroup

0 commit comments

Comments
 (0)