@opencorestack/opengridx 3.4.0 → 3.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +45 -0
- package/README.md +10 -3
- package/dist/ai.cjs +2 -1
- package/dist/ai.cjs.map +1 -1
- package/dist/ai.d.ts +203 -3
- package/dist/ai.es.js +2 -1
- package/dist/ai.es.js.map +1 -1
- package/dist/index.d.ts +248 -4
- package/dist/opengridx.css +1 -1
- package/dist/opengridx.es.js +7334 -5906
- package/dist/opengridx.es.js.map +1 -1
- package/dist/opengridx.umd.js +16 -16
- package/dist/opengridx.umd.js.map +1 -1
- package/docs/API_REFERENCE.md +88 -7
- package/docs/README.md +3 -1
- package/docs/architecture/datagrid-orchestration.md +11 -5
- package/docs/components/filter-panel.md +1 -0
- package/docs/components/header.md +4 -1
- package/docs/components/toolbar.md +7 -0
- package/docs/customization/theming.md +3 -1
- package/docs/features/ai-toolkit.md +99 -5
- package/docs/features/cell-selection.md +8 -2
- package/docs/features/fill-handle.md +125 -0
- package/docs/features/filtering.md +3 -0
- package/docs/features/header-filters.md +138 -0
- package/docs/features/keyboard-navigation.md +20 -2
- package/docs/features/virtualization.md +5 -5
- package/docs/performance.md +212 -1
- package/docs/roadmap.md +3 -4
- package/lib/ai/agentTools.ts +160 -0
- package/lib/ai/index.ts +19 -2
- package/lib/ai/promptHandler.ts +109 -0
- package/lib/ai/types.ts +89 -25
- package/lib/ai/validate.ts +10 -0
- package/lib/components/AiAssistant/GridAiAssistantArea.tsx +26 -0
- package/lib/components/AiAssistant/GridAiAssistantPanel.css +171 -0
- package/lib/components/AiAssistant/GridAiAssistantPanel.tsx +145 -0
- package/lib/components/AiAssistant/useSpeechInput.ts +74 -0
- package/lib/components/Cell/Cell.css +28 -0
- package/lib/components/Cell/Cell.tsx +6 -2
- package/lib/components/DataGrid/DataGrid.tsx +84 -4
- package/lib/components/DataGrid/GridLiveRegion.tsx +5 -2
- package/lib/components/DataGrid/GridPinnedRows.tsx +5 -1
- package/lib/components/DataGrid/GridVirtualRows.tsx +5 -1
- package/lib/components/FilterPanel/FilterPanel.tsx +2 -9
- package/lib/components/Header/Header.tsx +44 -23
- package/lib/components/Header/headerColumnStyle.ts +28 -0
- package/lib/components/HeaderFilter/HeaderFilter.css +160 -0
- package/lib/components/HeaderFilter/HeaderFilterCell.tsx +370 -0
- package/lib/components/HeaderFilter/HeaderFilterOperatorMenu.tsx +124 -0
- package/lib/components/HeaderFilter/HeaderFilterRow.tsx +157 -0
- package/lib/components/Toolbar/GridToolbar.tsx +51 -0
- package/lib/hooks/core/gridApiPlaceholder.ts +8 -0
- package/lib/hooks/core/gridToolbarHostContext.ts +5 -0
- package/lib/hooks/core/useGridApiMethods.ts +39 -0
- package/lib/hooks/core/useGridAriaRows.ts +5 -3
- package/lib/hooks/core/useGridCellSelection.ts +77 -11
- package/lib/hooks/core/useGridColumnsPanel.ts +24 -1
- package/lib/hooks/core/useGridControlledState.ts +27 -0
- package/lib/hooks/core/useGridDevWarnings.ts +21 -6
- package/lib/hooks/core/useGridFillHandle.ts +323 -0
- package/lib/hooks/core/useGridKeyboardNavigation.ts +29 -10
- package/lib/hooks/core/useGridMaxContentHeight.ts +16 -0
- package/lib/hooks/core/useGridToolbarProps.ts +19 -0
- package/lib/hooks/core/useGridVirtualization.ts +19 -0
- package/lib/hooks/features/useGridAiAssistant.ts +284 -0
- package/lib/hooks/features/useGridBatchEdit.ts +2 -2
- package/lib/index.ts +3 -0
- package/lib/styles/opengridx.css +2 -0
- package/lib/theme/DataGridThemeProvider.tsx +2 -0
- package/lib/theme/types.ts +2 -0
- package/lib/types/index.ts +219 -1
- package/lib/utils/aiAssistant.ts +205 -0
- package/lib/utils/aiBrand.ts +17 -0
- package/lib/utils/cellSelection.ts +25 -5
- package/lib/utils/editing/fill.ts +238 -0
- package/lib/utils/filtering/index.ts +14 -0
- package/lib/utils/focus/index.ts +9 -3
- package/lib/utils/headerFilters.ts +189 -0
- package/lib/utils/maxElementHeight.ts +53 -0
- package/llms.txt +44 -4
- package/package.json +3 -2
|
@@ -100,6 +100,11 @@ export interface UseGridKeyboardNavigationParams<R extends GridRowModel> {
|
|
|
100
100
|
* Ctrl/Cmd+A selects every data cell instead of every row.
|
|
101
101
|
*/
|
|
102
102
|
cellSelection?: GridKeyboardCellSelection;
|
|
103
|
+
/**
|
|
104
|
+
* Whether the header filter row (`headerFilters`) is rendered: ArrowDown on a column header
|
|
105
|
+
* moves to its filter cell, and ArrowDown there into the first row.
|
|
106
|
+
*/
|
|
107
|
+
headerFilters?: boolean;
|
|
103
108
|
}
|
|
104
109
|
|
|
105
110
|
export interface GridKeyboardCellSelection {
|
|
@@ -134,14 +139,18 @@ function resolveFocus<R extends GridRowModel>(
|
|
|
134
139
|
cell: FocusedCell | null,
|
|
135
140
|
rows: R[],
|
|
136
141
|
columns: ReadonlyArray<NavigationColumn<R>>,
|
|
137
|
-
getRowId: (row: R) => GridRowId
|
|
142
|
+
getRowId: (row: R) => GridRowId,
|
|
143
|
+
headerFilters: boolean
|
|
138
144
|
): { cell: FocusedCell | null; rowIndex: number } {
|
|
139
145
|
if (!cell || columns.length === 0) return { cell: null, rowIndex: -1 };
|
|
140
146
|
const fallbackField = (columns.find(c => !isSystemField(c.field)) ?? columns[0]).field;
|
|
141
147
|
const field = columns.some(c => c.field === cell.field) ? cell.field : fallbackField;
|
|
142
148
|
|
|
143
149
|
if (cell.id === null) {
|
|
144
|
-
|
|
150
|
+
// A filter cell whose row was turned off falls back to its column header.
|
|
151
|
+
const headerFilter = Boolean(cell.headerFilter) && headerFilters;
|
|
152
|
+
if (field === cell.field && headerFilter === Boolean(cell.headerFilter)) return { cell, rowIndex: -1 };
|
|
153
|
+
return { cell: headerFilter ? { id: null, field, headerFilter } : { id: null, field }, rowIndex: -1 };
|
|
145
154
|
}
|
|
146
155
|
const hint = cell.rowIndex;
|
|
147
156
|
let rowIndex = hint !== undefined && rows[hint] && getRowId(rows[hint]) === cell.id ? hint : rows.findIndex(r => getRowId(r) === cell.id);
|
|
@@ -187,14 +196,15 @@ export function useGridKeyboardNavigation<R extends GridRowModel>(
|
|
|
187
196
|
rowHeight,
|
|
188
197
|
getSpanOrigin,
|
|
189
198
|
cellSelection,
|
|
199
|
+
headerFilters = false,
|
|
190
200
|
} = params;
|
|
191
201
|
|
|
192
202
|
const [storedFocus, setFocusedCell] = useState<FocusedCell | null>(null);
|
|
193
203
|
const [focusWithin, setFocusWithin] = useState(false);
|
|
194
204
|
|
|
195
205
|
const resolved = useMemo(
|
|
196
|
-
() => resolveFocus(storedFocus, allRenderableRows, navigationColumns, getRowId),
|
|
197
|
-
[storedFocus, allRenderableRows, navigationColumns, getRowId]
|
|
206
|
+
() => resolveFocus(storedFocus, allRenderableRows, navigationColumns, getRowId, headerFilters),
|
|
207
|
+
[storedFocus, allRenderableRows, navigationColumns, getRowId, headerFilters]
|
|
198
208
|
);
|
|
199
209
|
const focusedCell = resolved.cell;
|
|
200
210
|
const focusedRowIndex = resolved.rowIndex;
|
|
@@ -359,10 +369,15 @@ export function useGridKeyboardNavigation<R extends GridRowModel>(
|
|
|
359
369
|
const isSpace = key === ' ' || key === 'Spacebar';
|
|
360
370
|
const isEditing = Boolean(editingHandlers.editingCell);
|
|
361
371
|
|
|
372
|
+
// Row positions: rows from 0, the column header row at `headerRow`, and the header filter
|
|
373
|
+
// row (when rendered) at -1, between the header and the rows.
|
|
374
|
+
const headerRow = headerFilters ? -2 : -1;
|
|
362
375
|
const moveTo = (r: number, c: number) => {
|
|
363
376
|
const col = navigationColumns[c];
|
|
364
|
-
if (!col || r <
|
|
365
|
-
|
|
377
|
+
if (!col || r < headerRow || r >= allRenderableRows.length) return;
|
|
378
|
+
if (r === headerRow) setFocusedCell({ id: null, field: col.field });
|
|
379
|
+
else if (r < 0) setFocusedCell({ id: null, field: col.field, headerFilter: true });
|
|
380
|
+
else setFocusedCell({ id: getRowId(allRenderableRows[r]), field: col.field, rowIndex: r });
|
|
366
381
|
scrollCellIntoView(r, col.field);
|
|
367
382
|
};
|
|
368
383
|
|
|
@@ -377,7 +392,8 @@ export function useGridKeyboardNavigation<R extends GridRowModel>(
|
|
|
377
392
|
}
|
|
378
393
|
|
|
379
394
|
const { id, field } = focusedCell;
|
|
380
|
-
const
|
|
395
|
+
const onHeaderFilter = id === null && Boolean(focusedCell.headerFilter);
|
|
396
|
+
const rowIndex = id !== null ? focusedRowIndex : onHeaderFilter ? -1 : headerRow;
|
|
381
397
|
const colIndex = navigationColumns.findIndex(c => c.field === field);
|
|
382
398
|
const systemColumnCount = navigationColumns.filter(c => isSystemField(c.field)).length;
|
|
383
399
|
const isSyntheticRow = (rowId: GridRowId) => isSyntheticRowId(rowId, rowMetaMap);
|
|
@@ -455,6 +471,8 @@ export function useGridKeyboardNavigation<R extends GridRowModel>(
|
|
|
455
471
|
return;
|
|
456
472
|
}
|
|
457
473
|
|
|
474
|
+
// A filter cell handles Enter, Space and typing itself (HeaderFilterCell).
|
|
475
|
+
if (onHeaderFilter && (key === 'Enter' || isSpace)) return;
|
|
458
476
|
if (id === null) {
|
|
459
477
|
if (key === 'Enter' || isSpace) {
|
|
460
478
|
event.preventDefault();
|
|
@@ -533,13 +551,13 @@ export function useGridKeyboardNavigation<R extends GridRowModel>(
|
|
|
533
551
|
case 'ArrowLeft': return c - 1 < 0 ? { r: r - 1, c: lastCol } : { r, c: c - 1 };
|
|
534
552
|
case 'ArrowDown': return { r: r + 1, c };
|
|
535
553
|
case 'ArrowUp': return { r: r - 1, c };
|
|
536
|
-
case 'Home': return { r: withModifier ?
|
|
554
|
+
case 'Home': return { r: withModifier ? headerRow : r, c: 0 };
|
|
537
555
|
case 'End': return { r: withModifier ? lastRow : r, c: lastCol };
|
|
538
|
-
case 'PageUp': return { r: Math.max(
|
|
556
|
+
case 'PageUp': return { r: Math.max(headerRow, r - page), c };
|
|
539
557
|
default: return { r: Math.min(lastRow, r + page), c };
|
|
540
558
|
}
|
|
541
559
|
};
|
|
542
|
-
const inGrid = ({ r, c }: { r: number; c: number }) => r >=
|
|
560
|
+
const inGrid = ({ r, c }: { r: number; c: number }) => r >= headerRow && r <= lastRow && c >= 0 && c <= lastCol;
|
|
543
561
|
const spanOriginAt = ({ r, c }: { r: number; c: number }) =>
|
|
544
562
|
r >= 0 ? getSpanOrigin?.(getRowId(allRenderableRows[r]), navigationColumns[c].field) ?? null : null;
|
|
545
563
|
|
|
@@ -590,6 +608,7 @@ export function useGridKeyboardNavigation<R extends GridRowModel>(
|
|
|
590
608
|
getSpanOrigin,
|
|
591
609
|
getRowId,
|
|
592
610
|
cellSelection,
|
|
611
|
+
headerFilters,
|
|
593
612
|
]);
|
|
594
613
|
|
|
595
614
|
return {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { useLayoutEffect, useState } from 'react';
|
|
2
|
+
import type { RefObject } from 'react';
|
|
3
|
+
import { getMaxElementHeight } from '../../utils/maxElementHeight';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The tallest element this browser lays out (see `getMaxElementHeight`), measured once per document after
|
|
7
|
+
* mount: `undefined` until measured, `Infinity` where there is no layout engine (jsdom, SSR).
|
|
8
|
+
*/
|
|
9
|
+
export function useGridMaxContentHeight(containerRef: RefObject<HTMLElement | null>): number | undefined {
|
|
10
|
+
const [maxHeight, setMaxHeight] = useState<number | undefined>(undefined);
|
|
11
|
+
useLayoutEffect(() => {
|
|
12
|
+
const measured = getMaxElementHeight(containerRef.current?.ownerDocument);
|
|
13
|
+
setMaxHeight(prev => (prev === measured ? prev : measured));
|
|
14
|
+
}, [containerRef]);
|
|
15
|
+
return maxHeight;
|
|
16
|
+
}
|
|
@@ -46,6 +46,14 @@ export interface UseGridToolbarPropsParams<R extends GridRowModel> {
|
|
|
46
46
|
/** `useGridColumnsPanel().toolbarPanelRequested`. */
|
|
47
47
|
forceColumnsOpen: boolean;
|
|
48
48
|
onColumnsPanelClose: () => void;
|
|
49
|
+
/** `aiAssistant` is set: the toolbar shows the Ask AI button. */
|
|
50
|
+
aiAssistantEnabled?: boolean;
|
|
51
|
+
aiAssistantOpen?: boolean;
|
|
52
|
+
onAiAssistantToggle?: (trigger: HTMLElement | null) => void;
|
|
53
|
+
aiAssistantLabel?: string;
|
|
54
|
+
/** `useGridColumnsPanel().toolbarFiltersRequested`. */
|
|
55
|
+
forceFiltersOpen: boolean;
|
|
56
|
+
onFiltersPanelClose: () => void;
|
|
49
57
|
}
|
|
50
58
|
|
|
51
59
|
/** The props the grid passes to `slots.toolbar`, or null when there is no toolbar slot. */
|
|
@@ -57,6 +65,8 @@ export function useGridToolbarProps<R extends GridRowModel>(
|
|
|
57
65
|
pivotMode, pivotModelProp, onPivotModelChangeProp, pivotModel, onPivotModelChange, filterModel,
|
|
58
66
|
onFilterModelChange, columnVisibilityModel, onColumnVisibilityModelChange, disableColumnReorder,
|
|
59
67
|
columnGroupingModel, moveColumn, resetColumnOrder, forceColumnsOpen, onColumnsPanelClose,
|
|
68
|
+
aiAssistantEnabled = false, aiAssistantOpen = false, onAiAssistantToggle, aiAssistantLabel,
|
|
69
|
+
forceFiltersOpen, onFiltersPanelClose,
|
|
60
70
|
} = params;
|
|
61
71
|
|
|
62
72
|
return useMemo(() => {
|
|
@@ -85,6 +95,13 @@ export function useGridToolbarProps<R extends GridRowModel>(
|
|
|
85
95
|
onColumnOrderReset: disableColumnReorder ? undefined : resetColumnOrder,
|
|
86
96
|
forceColumnsOpen,
|
|
87
97
|
onColumnsPanelClose,
|
|
98
|
+
...(aiAssistantEnabled && onAiAssistantToggle ? {
|
|
99
|
+
onAiAssistantToggle,
|
|
100
|
+
aiAssistantOpen,
|
|
101
|
+
...(aiAssistantLabel ? { aiAssistantLabel } : {}),
|
|
102
|
+
} : {}),
|
|
103
|
+
forceFiltersOpen,
|
|
104
|
+
onFiltersPanelClose,
|
|
88
105
|
...toolbarSlotProps,
|
|
89
106
|
};
|
|
90
107
|
}, [
|
|
@@ -93,5 +110,7 @@ export function useGridToolbarProps<R extends GridRowModel>(
|
|
|
93
110
|
pivotModelProp, onPivotModelChangeProp, pivotModel, onPivotModelChange,
|
|
94
111
|
filterModel, onFilterModelChange, columnVisibilityModel,
|
|
95
112
|
onColumnVisibilityModelChange, forceColumnsOpen, onColumnsPanelClose, toolbarSlotProps, columnGroupingModel,
|
|
113
|
+
aiAssistantEnabled, aiAssistantOpen, onAiAssistantToggle, aiAssistantLabel,
|
|
114
|
+
forceFiltersOpen, onFiltersPanelClose,
|
|
96
115
|
]);
|
|
97
116
|
}
|
|
@@ -16,6 +16,11 @@ export interface UseGridVirtualizationParams<R extends GridRowModel> {
|
|
|
16
16
|
pinExpandColumn: boolean;
|
|
17
17
|
/** Adaptive overscan row count from useGridScrollSync. Defaults to 5. */
|
|
18
18
|
overscanRows?: number;
|
|
19
|
+
/**
|
|
20
|
+
* The tallest element the browser lays out (`getMaxElementHeight`); the scrollable content is capped to
|
|
21
|
+
* it. Defaults to no cap.
|
|
22
|
+
*/
|
|
23
|
+
maxContentHeight?: number;
|
|
19
24
|
}
|
|
20
25
|
|
|
21
26
|
export interface GridVirtualizationResult<R extends GridRowModel = GridRowModel> {
|
|
@@ -28,6 +33,11 @@ export interface GridVirtualizationResult<R extends GridRowModel = GridRowModel>
|
|
|
28
33
|
offsetTop: number;
|
|
29
34
|
offsetLeft: number;
|
|
30
35
|
totalHeight: number;
|
|
36
|
+
/**
|
|
37
|
+
* Height of the unpinned rows' container: their full height, capped so the whole content stays under
|
|
38
|
+
* `maxContentHeight`. Rows below the cap cannot be scrolled to (the dev warning names how many can).
|
|
39
|
+
*/
|
|
40
|
+
virtualContainerHeight: number;
|
|
31
41
|
pinnedTopHeight: number;
|
|
32
42
|
pinnedBottomHeight: number;
|
|
33
43
|
totalWidth: number;
|
|
@@ -60,6 +70,7 @@ export function useGridVirtualization<R extends GridRowModel>(
|
|
|
60
70
|
pinCheckboxColumn,
|
|
61
71
|
pinExpandColumn,
|
|
62
72
|
overscanRows = 5,
|
|
73
|
+
maxContentHeight = Infinity,
|
|
63
74
|
} = params;
|
|
64
75
|
|
|
65
76
|
return useMemo<GridVirtualizationResult<R>>(() => {
|
|
@@ -122,6 +133,12 @@ export function useGridVirtualization<R extends GridRowModel>(
|
|
|
122
133
|
const hasHorizontalScroll = viewportWidth > 0 && totalWidth > viewportWidth;
|
|
123
134
|
const scrollbarBuffer = hasHorizontalScroll ? 16 : 0;
|
|
124
135
|
const totalHeight = pinnedTopHeight + unpinnedRowsHeight + pinnedBottomHeight + scrollbarBuffer + 2;
|
|
136
|
+
// Firefox collapses an element taller than its cap (about 17.9M px) instead of clamping it, which
|
|
137
|
+
// made a large grid unscrollable there; keep the rows' container under the browser's cap.
|
|
138
|
+
const fullContainerHeight = totalHeight - pinnedTopHeight - pinnedBottomHeight;
|
|
139
|
+
const virtualContainerHeight = Number.isFinite(maxContentHeight)
|
|
140
|
+
? Math.max(0, Math.min(fullContainerHeight, maxContentHeight - pinnedTopHeight - pinnedBottomHeight))
|
|
141
|
+
: fullContainerHeight;
|
|
125
142
|
|
|
126
143
|
let firstRowIndex = 0;
|
|
127
144
|
let lastRowIndex = unpinnedRowsLength - 1;
|
|
@@ -157,6 +174,7 @@ export function useGridVirtualization<R extends GridRowModel>(
|
|
|
157
174
|
offsetTop,
|
|
158
175
|
offsetLeft: 0,
|
|
159
176
|
totalHeight,
|
|
177
|
+
virtualContainerHeight,
|
|
160
178
|
pinnedTopHeight,
|
|
161
179
|
pinnedBottomHeight,
|
|
162
180
|
totalWidth,
|
|
@@ -185,5 +203,6 @@ export function useGridVirtualization<R extends GridRowModel>(
|
|
|
185
203
|
pinCheckboxColumn,
|
|
186
204
|
pinExpandColumn,
|
|
187
205
|
overscanRows,
|
|
206
|
+
maxContentHeight,
|
|
188
207
|
]);
|
|
189
208
|
}
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import type { MutableRefObject } from 'react';
|
|
3
|
+
import type {
|
|
4
|
+
GridAiAssistantOptions,
|
|
5
|
+
GridAiAssistantPanelProps,
|
|
6
|
+
GridAiAssistantStatus,
|
|
7
|
+
GridAiHistoryEntry,
|
|
8
|
+
GridAiPromptResult,
|
|
9
|
+
GridAiState,
|
|
10
|
+
GridAiValidationError,
|
|
11
|
+
GridApi,
|
|
12
|
+
GridColDef,
|
|
13
|
+
} from '../../types';
|
|
14
|
+
import { attempt } from '../../utils/attempt';
|
|
15
|
+
import { isGridAiValidated } from '../../utils/aiBrand';
|
|
16
|
+
import {
|
|
17
|
+
AI_PARTS,
|
|
18
|
+
applyGridAiState,
|
|
19
|
+
buildAiChips,
|
|
20
|
+
dropEmptiedParts,
|
|
21
|
+
effectiveState,
|
|
22
|
+
pickAllowedParts,
|
|
23
|
+
removeAiChip,
|
|
24
|
+
snapshotParts,
|
|
25
|
+
} from '../../utils/aiAssistant';
|
|
26
|
+
|
|
27
|
+
export interface UseGridAiAssistantParams {
|
|
28
|
+
apiRef: MutableRefObject<GridApi>;
|
|
29
|
+
/** The `aiAssistant` prop. Without it the hook is inert. */
|
|
30
|
+
options: GridAiAssistantOptions | undefined;
|
|
31
|
+
/** Columns, for the chip labels (header names). */
|
|
32
|
+
columns: readonly GridColDef[];
|
|
33
|
+
onApply?: (result: GridAiPromptResult) => void;
|
|
34
|
+
onError?: (error: unknown) => void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface UseGridAiAssistantResult {
|
|
38
|
+
enabled: boolean;
|
|
39
|
+
open: boolean;
|
|
40
|
+
/** Opens the panel; focus returns to `trigger` (default: the focused element) when it closes. */
|
|
41
|
+
openPanel: (trigger?: HTMLElement | null) => void;
|
|
42
|
+
/** Opens the panel, or closes it when it is open. */
|
|
43
|
+
togglePanel: (trigger?: HTMLElement | null) => void;
|
|
44
|
+
closePanel: () => void;
|
|
45
|
+
/** Props of the panel while it is open, else null. */
|
|
46
|
+
panelProps: GridAiAssistantPanelProps | null;
|
|
47
|
+
/** The status line for the grid's live region. */
|
|
48
|
+
announcement: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const DEFAULT_PLACEHOLDER = 'Ask about this table…';
|
|
52
|
+
const NO_SUGGESTIONS: string[] = [];
|
|
53
|
+
const NO_ERRORS: GridAiValidationError[] = [];
|
|
54
|
+
const NOT_VALIDATED = 'The reply was not validated';
|
|
55
|
+
|
|
56
|
+
interface Applied {
|
|
57
|
+
/** The reply's parts the grid applied (chips are built from it). */
|
|
58
|
+
state: GridAiState;
|
|
59
|
+
/** The same parts as they were before: what Undo restores. */
|
|
60
|
+
previous: GridAiState;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Whether this browser has speech recognition. The microphone is never asked for here. */
|
|
64
|
+
export function hasSpeechRecognition(): boolean {
|
|
65
|
+
return typeof window !== 'undefined' && ('SpeechRecognition' in window || 'webkitSpeechRecognition' in window);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function isAbortError(error: unknown): boolean {
|
|
69
|
+
return typeof error === 'object' && error !== null && (error as { name?: unknown }).name === 'AbortError';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function errorText(error: unknown): string {
|
|
73
|
+
return error instanceof Error && error.message ? error.message : 'Unknown error';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function changesText(count: number, ignored: number): string {
|
|
77
|
+
const applied = count === 0 ? 'No changes' : `Applied ${count} ${count === 1 ? 'change' : 'changes'}`;
|
|
78
|
+
return ignored > 0 ? `${applied}, ${ignored} ignored` : applied;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Calls a consumer callback; a throw is warned about (development) and otherwise ignored. */
|
|
82
|
+
function notify<T>(callback: ((value: T) => void) | undefined, value: T, name: string): void {
|
|
83
|
+
if (!callback) return;
|
|
84
|
+
const result = attempt(() => callback(value));
|
|
85
|
+
if (!result.ok && process.env.NODE_ENV !== 'production') console.warn(`[OpenGridX] ${name} threw.`, result.error);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The `aiAssistant` prompt panel: open state, the request (with Stop), applying a validated reply as
|
|
90
|
+
* one undoable step, chips, Undo, the session's prompts and the status line. Results are applied only
|
|
91
|
+
* when their `state` carries the brand of `validateGridAiState`. Installs `apiRef.openAiAssistant` /
|
|
92
|
+
* `closeAiAssistant`.
|
|
93
|
+
*/
|
|
94
|
+
export function useGridAiAssistant(params: UseGridAiAssistantParams): UseGridAiAssistantResult {
|
|
95
|
+
const { apiRef, options, columns } = params;
|
|
96
|
+
const enabled = Boolean(options);
|
|
97
|
+
const [open, setOpen] = useState(false);
|
|
98
|
+
const [status, setStatus] = useState<GridAiAssistantStatus>('idle');
|
|
99
|
+
const [statusText, setStatusText] = useState('');
|
|
100
|
+
const [message, setMessage] = useState<string | null>(null);
|
|
101
|
+
const [errors, setErrors] = useState<GridAiValidationError[]>(NO_ERRORS);
|
|
102
|
+
const [applied, setApplied] = useState<Applied | null>(null);
|
|
103
|
+
const [history, setHistory] = useState<GridAiHistoryEntry[]>([]);
|
|
104
|
+
|
|
105
|
+
const controllerRef = useRef<AbortController | null>(null);
|
|
106
|
+
const returnFocusRef = useRef<HTMLElement | null>(null);
|
|
107
|
+
const latestRef = useRef({ params, applied, history, open });
|
|
108
|
+
useLayoutEffect(() => {
|
|
109
|
+
latestRef.current = { params, applied, history, open };
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const labelOf = useMemo(() => {
|
|
113
|
+
const byField = new Map(columns.map((col) => [col.field, col.headerName || col.field]));
|
|
114
|
+
return (field: string) => byField.get(field) ?? field;
|
|
115
|
+
}, [columns]);
|
|
116
|
+
const chips = useMemo(() => (applied ? buildAiChips(applied.state, labelOf) : []), [applied, labelOf]);
|
|
117
|
+
|
|
118
|
+
const abortRequest = useCallback(() => {
|
|
119
|
+
const controller = controllerRef.current;
|
|
120
|
+
controllerRef.current = null;
|
|
121
|
+
if (controller) controller.abort();
|
|
122
|
+
}, []);
|
|
123
|
+
|
|
124
|
+
// Abort a running request when the grid unmounts.
|
|
125
|
+
useEffect(() => abortRequest, [abortRequest]);
|
|
126
|
+
|
|
127
|
+
const openPanel = useCallback((trigger?: HTMLElement | null) => {
|
|
128
|
+
if (!latestRef.current.params.options) return;
|
|
129
|
+
const active = typeof document === 'undefined' ? null : document.activeElement;
|
|
130
|
+
returnFocusRef.current = trigger ?? (active instanceof HTMLElement ? active : null);
|
|
131
|
+
setOpen(true);
|
|
132
|
+
}, []);
|
|
133
|
+
|
|
134
|
+
const closePanel = useCallback(() => {
|
|
135
|
+
if (controllerRef.current) {
|
|
136
|
+
abortRequest();
|
|
137
|
+
setStatus('stopped');
|
|
138
|
+
setStatusText('Stopped');
|
|
139
|
+
}
|
|
140
|
+
setOpen(false);
|
|
141
|
+
const target = returnFocusRef.current;
|
|
142
|
+
returnFocusRef.current = null;
|
|
143
|
+
if (target && target.isConnected) target.focus();
|
|
144
|
+
}, [abortRequest]);
|
|
145
|
+
|
|
146
|
+
const togglePanel = useCallback((trigger?: HTMLElement | null) => {
|
|
147
|
+
if (latestRef.current.open) closePanel();
|
|
148
|
+
else openPanel(trigger);
|
|
149
|
+
}, [openPanel, closePanel]);
|
|
150
|
+
|
|
151
|
+
const fail = useCallback((text: string, error: unknown) => {
|
|
152
|
+
setStatus('error');
|
|
153
|
+
setStatusText(text);
|
|
154
|
+
setMessage(null);
|
|
155
|
+
setErrors(NO_ERRORS);
|
|
156
|
+
notify(latestRef.current.params.onError, error, 'onAiAssistantError');
|
|
157
|
+
}, []);
|
|
158
|
+
|
|
159
|
+
const applyResult = useCallback((prompt: string, result: GridAiPromptResult) => {
|
|
160
|
+
const { params: current } = latestRef.current;
|
|
161
|
+
const isObject = typeof result === 'object' && result !== null;
|
|
162
|
+
if (!isObject || !isGridAiValidated(result.state)) {
|
|
163
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
164
|
+
console.warn('[OpenGridX] aiAssistant: the onPrompt result was not applied because its state was not validated. Return the result of createGridAiPromptHandler, or pass the model reply through validateGridAiState (from @opencorestack/opengridx/ai) and return its state.');
|
|
165
|
+
}
|
|
166
|
+
fail(NOT_VALIDATED, new Error(NOT_VALIDATED));
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const allowed = pickAllowedParts(result.state, current.options?.parts ?? AI_PARTS);
|
|
170
|
+
const ignored = [...(Array.isArray(result.errors) ? result.errors : []), ...allowed.errors];
|
|
171
|
+
const state = dropEmptiedParts(allowed.state, ignored);
|
|
172
|
+
const api = apiRef.current;
|
|
173
|
+
const previous = snapshotParts(state, api.getGridAiState());
|
|
174
|
+
applyGridAiState(api, effectiveState(state, previous));
|
|
175
|
+
setApplied({ state, previous });
|
|
176
|
+
setErrors(ignored);
|
|
177
|
+
setMessage(typeof result.message === 'string' && result.message ? result.message : null);
|
|
178
|
+
setHistory((list) => [...list, { prompt, applied: state }]);
|
|
179
|
+
setStatus('applied');
|
|
180
|
+
setStatusText(changesText(buildAiChips(state, (f) => f).length, ignored.length));
|
|
181
|
+
notify(current.onApply, result, 'onAiAssistantApply');
|
|
182
|
+
}, [apiRef, fail]);
|
|
183
|
+
|
|
184
|
+
const submit = useCallback((text: string) => {
|
|
185
|
+
const prompt = text.trim();
|
|
186
|
+
const { params: current, history: earlier } = latestRef.current;
|
|
187
|
+
const onPrompt = current.options?.onPrompt;
|
|
188
|
+
if (!prompt || !onPrompt) return;
|
|
189
|
+
abortRequest();
|
|
190
|
+
const controller = new AbortController();
|
|
191
|
+
controllerRef.current = controller;
|
|
192
|
+
setStatus('running');
|
|
193
|
+
setStatusText('Thinking…');
|
|
194
|
+
setMessage(null);
|
|
195
|
+
setErrors(NO_ERRORS);
|
|
196
|
+
const context = { currentState: apiRef.current.getGridAiState(), history: earlier, signal: controller.signal };
|
|
197
|
+
const call = attempt(() => onPrompt(prompt, context));
|
|
198
|
+
const pending = call.ok ? Promise.resolve(call.value) : Promise.reject(call.error);
|
|
199
|
+
pending.then(
|
|
200
|
+
(result) => {
|
|
201
|
+
if (controllerRef.current !== controller) return;
|
|
202
|
+
controllerRef.current = null;
|
|
203
|
+
applyResult(prompt, result);
|
|
204
|
+
},
|
|
205
|
+
(error: unknown) => {
|
|
206
|
+
if (controllerRef.current !== controller) return;
|
|
207
|
+
controllerRef.current = null;
|
|
208
|
+
if (controller.signal.aborted || isAbortError(error)) {
|
|
209
|
+
setStatus('stopped');
|
|
210
|
+
setStatusText('Stopped');
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
fail(`The assistant could not answer: ${errorText(error)}`, error);
|
|
214
|
+
},
|
|
215
|
+
);
|
|
216
|
+
}, [apiRef, abortRequest, applyResult, fail]);
|
|
217
|
+
|
|
218
|
+
const stop = useCallback(() => {
|
|
219
|
+
if (!controllerRef.current) return;
|
|
220
|
+
abortRequest();
|
|
221
|
+
setStatus('stopped');
|
|
222
|
+
setStatusText('Stopped');
|
|
223
|
+
}, [abortRequest]);
|
|
224
|
+
|
|
225
|
+
const undo = useCallback(() => {
|
|
226
|
+
const last = latestRef.current.applied;
|
|
227
|
+
if (!last) return;
|
|
228
|
+
applyGridAiState(apiRef.current, last.previous);
|
|
229
|
+
setApplied(null);
|
|
230
|
+
setErrors(NO_ERRORS);
|
|
231
|
+
setMessage(null);
|
|
232
|
+
setStatus('idle');
|
|
233
|
+
setStatusText('Undone');
|
|
234
|
+
}, [apiRef]);
|
|
235
|
+
|
|
236
|
+
const removeChip = useCallback((id: string) => {
|
|
237
|
+
const last = latestRef.current.applied;
|
|
238
|
+
if (!last) return;
|
|
239
|
+
const state = removeAiChip(last.state, id);
|
|
240
|
+
applyGridAiState(apiRef.current, effectiveState(state, last.previous));
|
|
241
|
+
setApplied({ state, previous: last.previous });
|
|
242
|
+
setStatusText('Change removed');
|
|
243
|
+
}, [apiRef]);
|
|
244
|
+
|
|
245
|
+
useLayoutEffect(() => {
|
|
246
|
+
const api = apiRef.current;
|
|
247
|
+
api.openAiAssistant = () => openPanel();
|
|
248
|
+
api.closeAiAssistant = () => {
|
|
249
|
+
if (latestRef.current.open) closePanel();
|
|
250
|
+
};
|
|
251
|
+
}, [apiRef, openPanel, closePanel]);
|
|
252
|
+
|
|
253
|
+
const isOpen = enabled && open;
|
|
254
|
+
const panelProps = useMemo<GridAiAssistantPanelProps | null>(() => {
|
|
255
|
+
if (!isOpen || !options) return null;
|
|
256
|
+
return {
|
|
257
|
+
status,
|
|
258
|
+
statusText,
|
|
259
|
+
message,
|
|
260
|
+
errors,
|
|
261
|
+
chips,
|
|
262
|
+
history,
|
|
263
|
+
suggestions: options.suggestions ?? NO_SUGGESTIONS,
|
|
264
|
+
placeholder: options.placeholder ?? DEFAULT_PLACEHOLDER,
|
|
265
|
+
voiceAvailable: options.voice !== false && hasSpeechRecognition(),
|
|
266
|
+
canUndo: applied !== null,
|
|
267
|
+
submit,
|
|
268
|
+
stop,
|
|
269
|
+
undo,
|
|
270
|
+
removeChip,
|
|
271
|
+
close: closePanel,
|
|
272
|
+
};
|
|
273
|
+
}, [isOpen, options, status, statusText, message, errors, chips, history, applied, submit, stop, undo, removeChip, closePanel]);
|
|
274
|
+
|
|
275
|
+
return {
|
|
276
|
+
enabled,
|
|
277
|
+
open: isOpen,
|
|
278
|
+
openPanel,
|
|
279
|
+
togglePanel,
|
|
280
|
+
closePanel,
|
|
281
|
+
panelProps,
|
|
282
|
+
announcement: enabled ? statusText : '',
|
|
283
|
+
};
|
|
284
|
+
}
|
|
@@ -11,8 +11,8 @@ import {
|
|
|
11
11
|
} from '../../utils/editing/commit';
|
|
12
12
|
import type { GridCellChange } from '../../utils/editing/commit';
|
|
13
13
|
|
|
14
|
-
/** What made a commit: history records `edit`, `paste` and `
|
|
15
|
-
export type GridEditSource = 'edit' | 'paste' | 'clear' | 'undo' | 'redo';
|
|
14
|
+
/** What made a commit: history records `edit`, `paste`, `clear` and `fill`; `undo` / `redo` replay it. */
|
|
15
|
+
export type GridEditSource = 'edit' | 'paste' | 'clear' | 'fill' | 'undo' | 'redo';
|
|
16
16
|
|
|
17
17
|
/** One cell to write in a batch edit. */
|
|
18
18
|
export interface GridBatchCellEdit {
|
package/lib/index.ts
CHANGED
|
@@ -112,6 +112,9 @@ export type {
|
|
|
112
112
|
GridEditSkipReason,
|
|
113
113
|
GridBatchEditResult,
|
|
114
114
|
GridClipboardPasteResult,
|
|
115
|
+
GridFillDirection,
|
|
116
|
+
GridFillResult,
|
|
117
|
+
GridFillValueParams,
|
|
115
118
|
GridBeforeClipboardPasteParams,
|
|
116
119
|
GridHistoryChangeParams,
|
|
117
120
|
GridUndoRedoOptions,
|
package/lib/styles/opengridx.css
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
@import '../components/ui/Input.css';
|
|
5
5
|
@import '../components/DataGrid/DataGrid.css';
|
|
6
6
|
@import '../components/Header/Header.css';
|
|
7
|
+
@import '../components/HeaderFilter/HeaderFilter.css';
|
|
7
8
|
@import '../components/Row/Row.css';
|
|
8
9
|
@import '../components/Cell/Cell.css';
|
|
9
10
|
@import '../components/Cell/GridEditInputCell.css';
|
|
@@ -27,3 +28,4 @@
|
|
|
27
28
|
@import '../components/ColumnGroupHeader/ColumnGroupHeader.css';
|
|
28
29
|
@import '../components/Tooltip/Tooltip.css';
|
|
29
30
|
@import '../components/CellSelectionStats/CellSelectionStats.css';
|
|
31
|
+
@import '../components/AiAssistant/GridAiAssistantPanel.css';
|
|
@@ -11,6 +11,7 @@ const primaryDerived: Record<string, string> = {
|
|
|
11
11
|
'--ogx-grid-cell-focus-border': 'var(--ogx-color-primary)',
|
|
12
12
|
'--ogx-range-background': 'color-mix(in srgb, var(--ogx-color-primary) 12%, transparent)',
|
|
13
13
|
'--ogx-range-border': 'var(--ogx-color-primary)',
|
|
14
|
+
'--ogx-fill-handle-color': 'var(--ogx-range-border)',
|
|
14
15
|
'--ogx-toolbar-btn-primary-bg': 'var(--ogx-color-primary)',
|
|
15
16
|
'--ogx-toolbar-btn-primary-hover': 'var(--ogx-color-primary-dark)',
|
|
16
17
|
'--ogx-toolbar-input-focus-border': 'var(--ogx-color-primary)',
|
|
@@ -285,6 +286,7 @@ function themeToCSS(theme: GridTheme): Record<string, string> {
|
|
|
285
286
|
set('--ogx-grid-cell-focus-border', g.cellFocusBorder);
|
|
286
287
|
set('--ogx-range-background', g.rangeBackground);
|
|
287
288
|
set('--ogx-range-border', g.rangeBorder);
|
|
289
|
+
set('--ogx-fill-handle-color', g.fillHandleColor);
|
|
288
290
|
set('--ogx-grid-pinned-left-shadow', g.pinnedLeftShadow);
|
|
289
291
|
set('--ogx-grid-pinned-right-shadow', g.pinnedRightShadow);
|
|
290
292
|
set('--ogx-checkbox-bg', g.checkboxBg);
|
package/lib/theme/types.ts
CHANGED
|
@@ -163,6 +163,8 @@ export interface GridThemeGrid {
|
|
|
163
163
|
rangeBackground?: string;
|
|
164
164
|
/** Outline of a selected cell range (`--ogx-range-border`). Default: the primary colour. @since v3.3 */
|
|
165
165
|
rangeBorder?: string;
|
|
166
|
+
/** The fill handle on the range's corner (`--ogx-fill-handle-color`). Default: the range border colour. @since v3.5 */
|
|
167
|
+
fillHandleColor?: string;
|
|
166
168
|
pinnedLeftShadow?: string;
|
|
167
169
|
pinnedRightShadow?: string;
|
|
168
170
|
checkboxBg?: string;
|