Skip to content

Commit 6eb06b6

Browse files
committed
feat: Refactor filtering system to use discriminated unions for filter values
- Updated filter value structure to use a discriminated union for better type safety. - Consolidated filter handling into a single onFilterChange callback. - Removed deprecated multiSelect, text, and people filter props from IOGridDataGridProps. - Introduced a new date filter type and updated related documentation. - Enhanced client-side data processing to support new filter structure. - Removed the title prop from OGrid components, encouraging external rendering. - Updated all relevant components and utilities to align with the new filtering approach. - Bumped package versions to 1.6.0 across all implementations.
1 parent 76e6ca0 commit 6eb06b6

38 files changed

Lines changed: 605 additions & 833 deletions

CHANGELOG.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,49 @@
22

33
All notable changes to OGrid will be documented in this file.
44

5+
## [1.6.0] – 2026-02-09
6+
7+
### BREAKING CHANGES
8+
9+
- **FilterValue discriminated union**`IFilters` values are now typed discriminated unions instead of raw values. All filter values must specify their `type`:
10+
```typescript
11+
// Before (1.5.x)
12+
{ status: ['Active', 'Closed'], name: 'Alice' }
13+
// After (1.6.0)
14+
{ status: { type: 'multiSelect', value: ['Active', 'Closed'] }, name: { type: 'text', value: 'Alice' } }
15+
```
16+
Supported types: `{ type: 'text', value: string }`, `{ type: 'multiSelect', value: string[] }`, `{ type: 'people', value: UserLike }`, `{ type: 'date', value: IDateFilterValue }`.
17+
18+
- **Unified filter API on DataGridTable**`IOGridDataGridProps` now uses `filters: IFilters` + `onFilterChange: (key, value) => void` instead of the 8 split filter props (`multiSelectFilters`, `textFilters`, `peopleFilters`, `dateFilters` and their onChange handlers). This does NOT affect `OGrid` consumers — only direct `DataGridTable` users.
19+
20+
- **Grouped `useDataGridState` returns** — The hook's return object is now organized into 6 logical groups instead of 42 flat properties:
21+
- `layout` — column structure, sizing, container dimensions
22+
- `rowSelection` — selected rows, selection handlers
23+
- `editing` — cell editing state, commit/cancel
24+
- `interaction` — active cell, selection range, keyboard, clipboard, fill handle, undo/redo
25+
- `contextMenu` — menu position, handlers (note: `contextMenu` position renamed to `menuPosition`)
26+
- `viewModels` — headerFilterInput, cellDescriptorInput, statusBarConfig, showEmptyInGrid
27+
28+
- **Removed deprecated props**`title`, `gap`, and `columnChooser` removed from `OGridLayout`. Consumers should render titles outside `<OGrid>` and use `toolbarEnd` for column chooser placement.
29+
30+
- **Removed `toDataGridFilterProps`** — This helper was replaced by the unified filter API; use `filters`/`onFilterChange` directly.
31+
32+
### Added
33+
34+
- **`processClientSideData` utility** — Pure function extracted from `useOGrid` for client-side filtering and sorting. Can be used independently for custom data processing pipelines.
35+
- **Wildcard re-exports** — All three UI packages now use `export * from '@alaarab/ogrid-core'` instead of cherry-picked re-export lists. Every core type is automatically available from any UI package import.
36+
- **Grouped state sub-interfaces**`DataGridLayoutState`, `DataGridRowSelectionState`, `DataGridEditingState`, `DataGridCellInteractionState`, `DataGridContextMenuState`, `DataGridViewModelState` are exported for consumers building custom grid wrappers.
37+
38+
### Fixed
39+
40+
- **Material InlineCellEditor auto-focus** — Added `useEffect` auto-focus matching Radix/Fluent behavior.
41+
42+
### Improved
43+
44+
- **Phase 2: Descriptor-to-component pattern** — All three UI packages now use the full suite of 6 core helpers (`getCellRenderDescriptor`, `buildInlineEditorProps`, `buildPopoverEditorProps`, `getCellInteractionProps`, `resolveCellDisplayContent`, `resolveCellStyle`). Each package's `renderCellContent` is now a thin ~50-line mapping from descriptors to framework-specific JSX.
45+
46+
---
47+
548
## [1.5.0] – 2026-02-09
649

750
### Added

CLAUDE.md

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,11 @@ npm run docs:build # Build docs site
4141

4242
### Core (`packages/core/src/`)
4343

44-
**Types**`IColumnDef`, `IColumnGroupDef`, `IDataSource`, `IFilters`, `IDateFilterValue`, `UserLike`, `IOGridApi`, `IOGridProps`, `ICellEditorProps`, etc. in `types/`. Column types: `'text' | 'numeric' | 'date' | 'boolean'`. Filter types: `'none' | 'text' | 'multiSelect' | 'people' | 'date'`.
44+
**Types**`IColumnDef`, `IColumnGroupDef`, `IDataSource`, `IFilters`, `IDateFilterValue`, `UserLike`, `IOGridApi`, `IOGridProps`, `ICellEditorProps`, `FilterValue`, etc. in `types/`. Column types: `'text' | 'numeric' | 'date' | 'boolean'`. Filter types: `'none' | 'text' | 'multiSelect' | 'people' | 'date'`. `FilterValue` is a discriminated union: `{ type: 'text', value: string } | { type: 'multiSelect', value: string[] } | { type: 'people', value: UserLike } | { type: 'date', value: IDateFilterValue }`.
4545

4646
**Orchestration hooks:**
4747
- `useOGrid` — Pagination, sorting, filtering, visibility, editing, row selection, status bar. Exposes `IOGridApi` ref.
48-
- `useDataGridState` — All DataGridTable state (layout, selection, editing, keyboard, clipboard, context menu).
48+
- `useDataGridState` — All DataGridTable state, grouped into 6 sub-objects: `layout`, `rowSelection`, `editing`, `interaction`, `contextMenu`, `viewModels`.
4949

5050
**Headless state hooks** (consumed by UI packages):
5151
- `useColumnHeaderFilterState` — Filter popover state (open, temp values, apply/clear, people search debounce)
@@ -56,7 +56,7 @@ npm run docs:build # Build docs site
5656

5757
**Feature hooks:** `useActiveCell`, `useCellEditing`, `useCellSelection`, `useRowSelection`, `useKeyboardNavigation`, `useClipboard`, `useFillHandle`, `useUndoRedo`, `useContextMenu`, `useColumnResize`, `useFilterOptions`, `useDebounce`
5858

59-
**Utilities:** `exportToCsv`, `getCellValue`, `flattenColumns`, `buildHeaderRows`, `getPaginationViewModel`, `getHeaderFilterConfig`, `getCellRenderDescriptor`, `getStatusBarParts`, `getDataGridStatusBarConfig`, `computeAggregations`, `GRID_CONTEXT_MENU_ITEMS`, `getContextMenuHandlers`, `formatShortcut`
59+
**Utilities:** `exportToCsv`, `getCellValue`, `flattenColumns`, `buildHeaderRows`, `getPaginationViewModel`, `getHeaderFilterConfig`, `getCellRenderDescriptor`, `resolveCellDisplayContent`, `resolveCellStyle`, `buildInlineEditorProps`, `buildPopoverEditorProps`, `getCellInteractionProps`, `getStatusBarParts`, `getDataGridStatusBarConfig`, `computeAggregations`, `processClientSideData`, `GRID_CONTEXT_MENU_ITEMS`, `getContextMenuHandlers`, `formatShortcut`
6060

6161
**Headless components:** `OGridLayout`, `StatusBar`, `GridContextMenu`, `SideBar`
6262

@@ -104,7 +104,7 @@ Pure React hooks. No external state libraries. Supports uncontrolled (internal)
104104

105105
## Testing
106106

107-
**504 tests** across 4 packages (Core: 228, Radix: 92, Fluent: 92, Material: 92).
107+
**521 tests** across 4 packages (Core: 245, Radix: 92, Fluent: 92, Material: 92).
108108

109109
- Jest 29 + React Testing Library 16 + ts-jest, jsdom environment, 10s timeout
110110
- Core tests: `packages/core/src/*/__tests__/**/*.test.ts(x)`
@@ -214,15 +214,14 @@ Opus is the **orchestrator**. For multi-step tasks, break the work into well-sco
214214
- [ ] State logic stays in core hooks — UI packages should only add view-layer code.
215215
- [ ] If the same pattern appears in 2+ UI packages, consider a shared factory or headless component.
216216

217-
## Remaining Duplication (Future Work)
217+
## View Layer Architecture (Phase 2 Complete)
218218

219-
State and behavior are centralized in core. The remaining triplication is in the **view layer** of DataGridTable:
219+
State and behavior are centralized in core. Each UI package's `renderCellContent` is a thin ~50-line mapping from core-computed descriptors to framework-specific JSX, using 6 core helpers: `getCellRenderDescriptor`, `buildInlineEditorProps`, `buildPopoverEditorProps`, `getCellInteractionProps`, `resolveCellDisplayContent`, `resolveCellStyle`.
220220

221-
| What's shared | What's triplicated |
221+
| What's in core | What's per-framework |
222222
|---|---|
223223
| `useDataGridState`, all sub-hooks, types, utils | Table primitives (Fluent DataGrid vs MUI Table vs native `<table>`) |
224-
| `getCellRenderDescriptor` (cell mode, flags) | Cell rendering (editing vs display, active/range styling) |
225-
| `getHeaderFilterConfig` | Header filter rendering (Fluent/MUI/Radix popovers) |
224+
| `getCellRenderDescriptor` + 5 builder helpers | Popover rendering (Radix/Fluent/MUI popover APIs) |
225+
| `getHeaderFilterConfig` | Header filter rendering (framework-specific popovers) |
226226
| `getPaginationViewModel` | Pagination rendering (Fluent/MUI/native buttons) |
227-
228-
**Next step (Phase 2):** Use `getCellRenderDescriptor` in all three DataGridTable implementations so cell rendering is "map descriptor to component" instead of reimplementing the booleans and callbacks. This would make each DataGridTable a thin mapping from descriptors to framework-specific primitives.
227+
| `processClientSideData` | CSS/styling (CSS modules vs MUI sx) |

packages/core/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@alaarab/ogrid-core",
3-
"version": "1.5.0",
3+
"version": "1.6.0",
44
"description": "OGrid core – framework-agnostic types, hooks, and utilities for OGrid data tables.",
55
"main": "dist/esm/index.js",
66
"module": "dist/esm/index.js",

packages/core/src/components/OGridLayout.tsx

Lines changed: 1 addition & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,9 @@ export interface OGridLayoutProps {
1313
containerComponent?: React.ElementType;
1414
/** Extra props for the root container (e.g. sx for MUI Box). */
1515
containerProps?: Record<string, unknown>;
16-
/** Gap between deprecated title and the bordered container in px (default: 8). */
17-
gap?: number | string;
1816
className?: string;
19-
/** @deprecated Render title outside OGrid. Renders above the bordered container during transition. */
20-
title?: React.ReactNode;
2117
/** Custom toolbar content (left-aligned in toolbar strip). */
2218
toolbar?: React.ReactNode;
23-
/** @deprecated Use toolbarEnd instead. */
24-
columnChooser?: React.ReactNode;
2519
/** Built-in toolbar items rendered on the right side (column chooser, etc.). */
2620
toolbarEnd?: React.ReactNode;
2721
/** Grid content (DataGridTable). */
@@ -95,7 +89,6 @@ const gridChildStyle: React.CSSProperties = {
9589

9690
/**
9791
* Renders OGrid layout as a unified bordered container:
98-
* [deprecated title above]
9992
* ┌────────────────────────────────────┐
10093
* │ [toolbar strip] │
10194
* ├────────────────────────────────────┤
@@ -108,30 +101,22 @@ export function OGridLayout(props: OGridLayoutProps): React.ReactElement {
108101
const {
109102
containerComponent: Container = 'div',
110103
containerProps = {},
111-
gap = 8,
112104
className,
113-
title,
114105
toolbar,
115-
columnChooser,
116-
toolbarEnd: toolbarEndProp,
106+
toolbarEnd,
117107
children,
118108
pagination,
119109
sideBar,
120110
} = props;
121111

122112
const hasSideBar = sideBar != null;
123113
const sideBarPosition = sideBar?.position ?? 'right';
124-
125-
// Backward compat: columnChooser prop → toolbarEnd
126-
const toolbarEnd = toolbarEndProp ?? columnChooser;
127114
const hasToolbar = toolbar != null || toolbarEnd != null;
128115

129-
// Root styles: flex column, fill parent height, gap for deprecated title spacing
130116
const rootStyle: React.CSSProperties = {
131117
display: 'flex',
132118
flexDirection: 'column',
133119
height: '100%',
134-
gap: title != null ? (typeof gap === 'number' ? `${gap}px` : gap) : undefined,
135120
};
136121

137122
return (
@@ -140,9 +125,6 @@ export function OGridLayout(props: OGridLayoutProps): React.ReactElement {
140125
style={rootStyle}
141126
{...containerProps}
142127
>
143-
{/* Deprecated: title renders ABOVE the bordered container */}
144-
{title != null && <div style={{ margin: 0 }}>{title}</div>}
145-
146128
{/* === Bordered container === */}
147129
<div style={borderedContainerStyle}>
148130
{/* Toolbar strip */}

packages/core/src/components/SideBar.tsx

Lines changed: 23 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* Uses inline styles for framework-agnostic rendering.
55
*/
66
import * as React from 'react';
7-
import type { IColumnDefinition, IDateFilterValue, SideBarPanelId } from '../types';
7+
import type { IColumnDefinition, IDateFilterValue, SideBarPanelId, IFilters, FilterValue } from '../types';
88

99
/** Describes a filterable column for the sidebar filters panel. */
1010
export interface SideBarFilterColumn {
@@ -27,12 +27,8 @@ export interface SideBarProps {
2727
onSetVisibleColumns: (columns: Set<string>) => void;
2828
// Filters panel
2929
filterableColumns: SideBarFilterColumn[];
30-
multiSelectFilters: Record<string, string[]>;
31-
textFilters: Record<string, string>;
32-
onMultiSelectFilterChange: (key: string, values: string[]) => void;
33-
onTextFilterChange: (key: string, value: string) => void;
34-
dateFilters: Record<string, IDateFilterValue>;
35-
onDateFilterChange: (key: string, value: IDateFilterValue | undefined) => void;
30+
filters: IFilters;
31+
onFilterChange: (key: string, value: FilterValue | undefined) => void;
3632
filterOptions: Record<string, string[]>;
3733
}
3834

@@ -55,12 +51,8 @@ export function SideBar(props: SideBarProps): React.ReactElement {
5551
onVisibilityChange,
5652
onSetVisibleColumns,
5753
filterableColumns,
58-
multiSelectFilters,
59-
textFilters,
60-
onMultiSelectFilterChange,
61-
onTextFilterChange,
62-
dateFilters,
63-
onDateFilterChange,
54+
filters,
55+
onFilterChange,
6456
filterOptions,
6557
} = props;
6658

@@ -157,12 +149,8 @@ export function SideBar(props: SideBarProps): React.ReactElement {
157149
{activePanel === 'filters' && (
158150
<FiltersPanel
159151
filterableColumns={filterableColumns}
160-
multiSelectFilters={multiSelectFilters}
161-
textFilters={textFilters}
162-
onMultiSelectFilterChange={onMultiSelectFilterChange}
163-
onTextFilterChange={onTextFilterChange}
164-
dateFilters={dateFilters}
165-
onDateFilterChange={onDateFilterChange}
152+
filters={filters}
153+
onFilterChange={onFilterChange}
166154
filterOptions={filterOptions}
167155
/>
168156
)}
@@ -238,15 +226,11 @@ function ColumnsPanel(props: {
238226

239227
function FiltersPanel(props: {
240228
filterableColumns: SideBarFilterColumn[];
241-
multiSelectFilters: Record<string, string[]>;
242-
textFilters: Record<string, string>;
243-
onMultiSelectFilterChange: (key: string, values: string[]) => void;
244-
onTextFilterChange: (key: string, value: string) => void;
245-
dateFilters: Record<string, IDateFilterValue>;
246-
onDateFilterChange: (key: string, value: IDateFilterValue | undefined) => void;
229+
filters: IFilters;
230+
onFilterChange: (key: string, value: FilterValue | undefined) => void;
247231
filterOptions: Record<string, string[]>;
248232
}): React.ReactElement {
249-
const { filterableColumns, multiSelectFilters, textFilters, onMultiSelectFilterChange, onTextFilterChange, dateFilters, onDateFilterChange, filterOptions } = props;
233+
const { filterableColumns, filters, onFilterChange, filterOptions } = props;
250234

251235
if (filterableColumns.length === 0) {
252236
return <div style={{ color: 'var(--ogrid-muted, #999)', fontStyle: 'italic' }}>No filterable columns</div>;
@@ -262,8 +246,8 @@ function FiltersPanel(props: {
262246
{col.filterType === 'text' && (
263247
<input
264248
type="text"
265-
value={textFilters[filterKey] ?? ''}
266-
onChange={(e) => onTextFilterChange(filterKey, e.target.value)}
249+
value={filters[filterKey]?.type === 'text' ? filters[filterKey]!.value : ''}
250+
onChange={(e) => onFilterChange(filterKey, e.target.value ? { type: 'text', value: e.target.value } : undefined)}
267251
placeholder={`Filter ${col.name}...`}
268252
aria-label={`Filter ${col.name}`}
269253
style={{ width: '100%', boxSizing: 'border-box', padding: '4px 6px', background: 'var(--ogrid-bg, #fff)', color: 'var(--ogrid-fg, #242424)', border: '1px solid var(--ogrid-border, #e0e0e0)', borderRadius: 4 }}
@@ -275,11 +259,12 @@ function FiltersPanel(props: {
275259
From:
276260
<input
277261
type="date"
278-
value={dateFilters[filterKey]?.from ?? ''}
262+
value={filters[filterKey]?.type === 'date' ? (filters[filterKey]!.value.from ?? '') : ''}
279263
onChange={(e) => {
280264
const from = e.target.value || undefined;
281-
const to = dateFilters[filterKey]?.to;
282-
onDateFilterChange(filterKey, from || to ? { from, to } : undefined);
265+
const existingValue = filters[filterKey]?.type === 'date' ? filters[filterKey]!.value : {};
266+
const to = existingValue.to;
267+
onFilterChange(filterKey, from || to ? { type: 'date', value: { from, to } } : undefined);
283268
}}
284269
aria-label={`${col.name} from date`}
285270
style={{ flex: 1, padding: '2px 4px', background: 'var(--ogrid-bg, #fff)', color: 'var(--ogrid-fg, #242424)', border: '1px solid var(--ogrid-border, #e0e0e0)', borderRadius: 4 }}
@@ -289,11 +274,12 @@ function FiltersPanel(props: {
289274
To:
290275
<input
291276
type="date"
292-
value={dateFilters[filterKey]?.to ?? ''}
277+
value={filters[filterKey]?.type === 'date' ? (filters[filterKey]!.value.to ?? '') : ''}
293278
onChange={(e) => {
294279
const to = e.target.value || undefined;
295-
const from = dateFilters[filterKey]?.from;
296-
onDateFilterChange(filterKey, from || to ? { from, to } : undefined);
280+
const existingValue = filters[filterKey]?.type === 'date' ? filters[filterKey]!.value : {};
281+
const from = existingValue.from;
282+
onFilterChange(filterKey, from || to ? { type: 'date', value: { from, to } } : undefined);
297283
}}
298284
aria-label={`${col.name} to date`}
299285
style={{ flex: 1, padding: '2px 4px', background: 'var(--ogrid-bg, #fff)', color: 'var(--ogrid-fg, #242424)', border: '1px solid var(--ogrid-border, #e0e0e0)', borderRadius: 4 }}
@@ -304,7 +290,7 @@ function FiltersPanel(props: {
304290
{col.filterType === 'multiSelect' && (
305291
<div style={{ maxHeight: 120, overflowY: 'auto' }} role="group" aria-label={`${col.name} options`}>
306292
{(filterOptions[filterKey] ?? []).map((opt) => {
307-
const selected = (multiSelectFilters[filterKey] ?? []).includes(opt);
293+
const selected = filters[filterKey]?.type === 'multiSelect' ? filters[filterKey]!.value.includes(opt) : false;
308294
return (
309295
<label
310296
key={opt}
@@ -314,11 +300,11 @@ function FiltersPanel(props: {
314300
type="checkbox"
315301
checked={selected}
316302
onChange={(e) => {
317-
const current = multiSelectFilters[filterKey] ?? [];
303+
const current = filters[filterKey]?.type === 'multiSelect' ? filters[filterKey]!.value : [];
318304
const next = e.target.checked
319305
? [...current, opt]
320306
: current.filter((v) => v !== opt);
321-
onMultiSelectFilterChange(filterKey, next);
307+
onFilterChange(filterKey, next.length > 0 ? { type: 'multiSelect', value: next } : undefined);
322308
}}
323309
/>
324310
<span>{opt}</span>

0 commit comments

Comments
 (0)