@opencorestack/opengridx 3.3.0 → 3.5.0
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 +64 -0
- package/README.md +10 -1
- package/dist/ai.cjs +2 -0
- package/dist/ai.cjs.map +1 -0
- package/dist/ai.d.ts +366 -0
- package/dist/ai.es.js +2 -0
- package/dist/ai.es.js.map +1 -0
- package/dist/index.d.ts +396 -6
- package/dist/opengridx.css +1 -1
- package/dist/opengridx.es.js +8029 -6090
- package/dist/opengridx.es.js.map +1 -1
- package/dist/opengridx.umd.js +16 -14
- package/dist/opengridx.umd.js.map +1 -1
- package/docs/API_REFERENCE.md +182 -4
- package/docs/README.md +5 -1
- package/docs/architecture/datagrid-orchestration.md +19 -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 +12 -1
- package/docs/features/ai-toolkit.md +213 -0
- package/docs/features/cell-selection.md +8 -2
- package/docs/features/clipboard.md +88 -0
- package/docs/features/editing-reordering.md +50 -0
- 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 +31 -2
- package/docs/features/undo-redo.md +79 -0
- package/docs/roadmap.md +3 -4
- package/lib/ai/agentTools.ts +160 -0
- package/lib/ai/coerce.ts +90 -0
- package/lib/ai/columns.ts +90 -0
- package/lib/ai/index.ts +33 -0
- package/lib/ai/promptHandler.ts +109 -0
- package/lib/ai/schema.ts +214 -0
- package/lib/ai/types.ts +152 -0
- package/lib/ai/validate.ts +296 -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 +33 -0
- package/lib/components/Cell/Cell.tsx +6 -2
- package/lib/components/Cell/GridEditInputCell.css +12 -4
- package/lib/components/Cell/GridEditInputCell.tsx +19 -12
- package/lib/components/ColumnVisibilityPanel/ColumnVisibilityPanel.css +14 -0
- package/lib/components/ColumnVisibilityPanel/ColumnVisibilityPanel.tsx +3 -2
- package/lib/components/DataGrid/DataGrid.tsx +138 -5
- 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.css +14 -0
- package/lib/components/FilterPanel/FilterPanel.tsx +5 -11
- 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/ListView/ListView.css +2 -4
- package/lib/components/ListView/ListViewRow.tsx +2 -2
- package/lib/components/Toolbar/GlobalSearch.css +16 -0
- package/lib/components/Toolbar/GlobalSearch.tsx +3 -2
- package/lib/components/Toolbar/GridToolbar.tsx +51 -0
- package/lib/components/ui/Checkbox.tsx +16 -5
- package/lib/components/ui/Input.css +43 -0
- package/lib/components/ui/Input.tsx +24 -5
- package/lib/hooks/core/gridApiPlaceholder.ts +16 -1
- package/lib/hooks/core/gridToolbarHostContext.ts +5 -0
- package/lib/hooks/core/useDataGrid.ts +27 -0
- package/lib/hooks/core/useGridApiMethods.ts +39 -0
- package/lib/hooks/core/useGridAriaRows.ts +5 -3
- package/lib/hooks/core/useGridCellSelection.ts +107 -13
- package/lib/hooks/core/useGridClipboardPaste.ts +283 -0
- package/lib/hooks/core/useGridColumnsPanel.ts +24 -1
- package/lib/hooks/core/useGridControlledState.ts +27 -0
- package/lib/hooks/core/useGridFillHandle.ts +323 -0
- package/lib/hooks/core/useGridKeyboardNavigation.ts +29 -10
- package/lib/hooks/core/useGridToolbarProps.ts +19 -0
- package/lib/hooks/core/useGridUndoRedo.ts +220 -0
- package/lib/hooks/features/useGridAiAssistant.ts +284 -0
- package/lib/hooks/features/useGridBatchEdit.ts +217 -0
- package/lib/hooks/features/useGridEditing.ts +24 -45
- package/lib/index.ts +10 -0
- package/lib/styles/opengridx.css +4 -1
- package/lib/theme/DataGridThemeProvider.tsx +2 -0
- package/lib/theme/types.ts +2 -0
- package/lib/types/index.ts +343 -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/commit.ts +69 -0
- package/lib/utils/editing/fill.ts +238 -0
- package/lib/utils/editing/targets.ts +109 -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/parsing.ts +252 -0
- package/llms.txt +97 -3
- package/package.json +8 -2
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react';
|
|
2
|
+
import type { MutableRefObject, RefObject } from 'react';
|
|
3
|
+
import type {
|
|
4
|
+
GridApi,
|
|
5
|
+
GridBatchEditResult,
|
|
6
|
+
GridColDef,
|
|
7
|
+
GridHistoryChangeParams,
|
|
8
|
+
GridRowId,
|
|
9
|
+
GridRowModel,
|
|
10
|
+
} from '../../types';
|
|
11
|
+
import { classifyKeyTarget } from '../../utils/focus';
|
|
12
|
+
import type { FocusedCell } from '../../utils/focus';
|
|
13
|
+
import { scrollColumnIntoView, scrollRowIntoView } from '../../utils/scroll';
|
|
14
|
+
import { boundingTargetRect, buildEditTargetContext, rectCorners } from '../../utils/editing/targets';
|
|
15
|
+
import type { GridCellChange } from '../../utils/editing/commit';
|
|
16
|
+
import type { GridEditSource, UseGridBatchEditReturn } from '../features/useGridBatchEdit';
|
|
17
|
+
import type { UseGridCellSelectionReturn } from './useGridCellSelection';
|
|
18
|
+
import type { LayoutResult } from './useLayout';
|
|
19
|
+
|
|
20
|
+
/** Default number of actions `undoRedo` keeps. */
|
|
21
|
+
export const UNDO_HISTORY_LIMIT = 100;
|
|
22
|
+
|
|
23
|
+
type CommitListener = (changes: GridCellChange[], source: GridEditSource) => void;
|
|
24
|
+
|
|
25
|
+
export interface GridEditCommitChannel {
|
|
26
|
+
/** Passed to the commit paths (`useGridEditing`, `useGridBatchEdit`) as `onCommitted`. Stable. */
|
|
27
|
+
emit: CommitListener;
|
|
28
|
+
/** Set by `useGridUndoRedo`, which runs later in the hook order. Stable. */
|
|
29
|
+
setListener: (listener: CommitListener | null) => void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Connects the commit paths, which run early in the hook order, to the history, which runs late
|
|
34
|
+
* (it needs the focus and selection hooks). Created before `useGridBatchEdit`.
|
|
35
|
+
*/
|
|
36
|
+
export function useGridEditCommitChannel(): GridEditCommitChannel {
|
|
37
|
+
const listenerRef = useRef<CommitListener | null>(null);
|
|
38
|
+
const emit = useCallback<CommitListener>((changes, source) => {
|
|
39
|
+
const listener = listenerRef.current;
|
|
40
|
+
if (listener) listener(changes, source);
|
|
41
|
+
}, []);
|
|
42
|
+
const setListener = useCallback((listener: CommitListener | null) => {
|
|
43
|
+
listenerRef.current = listener;
|
|
44
|
+
}, []);
|
|
45
|
+
return useMemo(() => ({ emit, setListener }), [emit, setListener]);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface UseGridUndoRedoParams<R extends GridRowModel> {
|
|
49
|
+
apiRef: MutableRefObject<GridApi>;
|
|
50
|
+
enabled: boolean;
|
|
51
|
+
limit: number;
|
|
52
|
+
onHistoryChange?: (params: GridHistoryChangeParams) => void;
|
|
53
|
+
channel: GridEditCommitChannel;
|
|
54
|
+
batchEdit: UseGridBatchEditReturn<R>;
|
|
55
|
+
isEditing: boolean;
|
|
56
|
+
containerRef: RefObject<HTMLDivElement | null>;
|
|
57
|
+
viewportRef: RefObject<HTMLDivElement | null>;
|
|
58
|
+
selection: UseGridCellSelectionReturn;
|
|
59
|
+
setFocusedCell: React.Dispatch<React.SetStateAction<FocusedCell | null>>;
|
|
60
|
+
allRenderableRows: R[];
|
|
61
|
+
getRowId: (row: R) => GridRowId;
|
|
62
|
+
navigationColumns: ReadonlyArray<GridColDef<R>>;
|
|
63
|
+
layout: LayoutResult<R>;
|
|
64
|
+
rowHeight: number;
|
|
65
|
+
pinnedTopRowCount: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const emptyResult = (): GridBatchEditResult => ({ updated: [], failed: [], skipped: [] });
|
|
69
|
+
|
|
70
|
+
/** 'undo', 'redo' or null for a keydown. Ctrl/Cmd+Z, Ctrl/Cmd+Shift+Z, Ctrl+Y (also on non-Latin layouts). */
|
|
71
|
+
function historyShortcut(event: KeyboardEvent): 'undo' | 'redo' | null {
|
|
72
|
+
if (event.altKey || !(event.ctrlKey || event.metaKey)) return null;
|
|
73
|
+
const key = (event.key ?? '').toLowerCase();
|
|
74
|
+
const latin = /^[a-z]$/.test(key);
|
|
75
|
+
const isZ = key === 'z' || (!latin && event.code === 'KeyZ');
|
|
76
|
+
const isY = key === 'y' || (!latin && event.code === 'KeyY');
|
|
77
|
+
if (isZ) return event.shiftKey ? 'redo' : 'undo';
|
|
78
|
+
if (isY && event.ctrlKey && !event.shiftKey) return 'redo';
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Undo / redo of cell edits, pastes and range clears (`undoRedo`, v3.4). An action is recorded only
|
|
84
|
+
* after `processRowUpdate` succeeded, as the changed cells with their value before and the stored
|
|
85
|
+
* value after. Undo writes the `before` values as a batch edit (so `valueSetter` and
|
|
86
|
+
* `processRowUpdate` run); a cell that no longer holds its `after` value is skipped (`'changed'`), a
|
|
87
|
+
* row that is gone too (`'missing'`). Redo is the reverse. Runs after useGridClipboardPaste.
|
|
88
|
+
*/
|
|
89
|
+
export function useGridUndoRedo<R extends GridRowModel>(params: UseGridUndoRedoParams<R>): void {
|
|
90
|
+
const { apiRef, enabled, channel, containerRef } = params;
|
|
91
|
+
const latestRef = useRef(params);
|
|
92
|
+
useLayoutEffect(() => {
|
|
93
|
+
latestRef.current = params;
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const undoStackRef = useRef<GridCellChange[][]>([]);
|
|
97
|
+
const redoStackRef = useRef<GridCellChange[][]>([]);
|
|
98
|
+
const lastEmittedRef = useRef<GridHistoryChangeParams>({ canUndo: false, canRedo: false, size: 0 });
|
|
99
|
+
// Undo and redo run one after the other, each on the rows the previous one stored.
|
|
100
|
+
const queueRef = useRef<Promise<unknown>>(Promise.resolve());
|
|
101
|
+
|
|
102
|
+
const notify = useCallback(() => {
|
|
103
|
+
const next = {
|
|
104
|
+
canUndo: undoStackRef.current.length > 0,
|
|
105
|
+
canRedo: redoStackRef.current.length > 0,
|
|
106
|
+
size: undoStackRef.current.length,
|
|
107
|
+
};
|
|
108
|
+
const last = lastEmittedRef.current;
|
|
109
|
+
if (last.canUndo === next.canUndo && last.canRedo === next.canRedo && last.size === next.size) return;
|
|
110
|
+
lastEmittedRef.current = next;
|
|
111
|
+
latestRef.current.onHistoryChange?.(next);
|
|
112
|
+
}, []);
|
|
113
|
+
|
|
114
|
+
const record = useCallback((changes: GridCellChange[]) => {
|
|
115
|
+
if (!latestRef.current.enabled || changes.length === 0) return;
|
|
116
|
+
const stack = undoStackRef.current;
|
|
117
|
+
stack.push(changes);
|
|
118
|
+
const limit = Math.max(1, latestRef.current.limit);
|
|
119
|
+
if (stack.length > limit) stack.splice(0, stack.length - limit);
|
|
120
|
+
redoStackRef.current = [];
|
|
121
|
+
notify();
|
|
122
|
+
}, [notify]);
|
|
123
|
+
|
|
124
|
+
useLayoutEffect(() => {
|
|
125
|
+
channel.setListener(enabled ? record : null);
|
|
126
|
+
return () => channel.setListener(null);
|
|
127
|
+
}, [channel, enabled, record]);
|
|
128
|
+
|
|
129
|
+
const clearHistory = useCallback(() => {
|
|
130
|
+
undoStackRef.current = [];
|
|
131
|
+
redoStackRef.current = [];
|
|
132
|
+
notify();
|
|
133
|
+
}, [notify]);
|
|
134
|
+
|
|
135
|
+
// Turning undoRedo off forgets the history.
|
|
136
|
+
useEffect(() => {
|
|
137
|
+
if (!enabled) clearHistory();
|
|
138
|
+
}, [enabled, clearHistory]);
|
|
139
|
+
|
|
140
|
+
/** Selects the affected cells (their bounding rectangle) and scrolls its first cell into view. */
|
|
141
|
+
const reveal = useCallback((cells: GridCellChange[]) => {
|
|
142
|
+
const p = latestRef.current;
|
|
143
|
+
const ctx = buildEditTargetContext(p.allRenderableRows, p.getRowId, p.navigationColumns);
|
|
144
|
+
const rect = boundingTargetRect(ctx, cells);
|
|
145
|
+
if (!rect) return;
|
|
146
|
+
const { anchor, head } = rectCorners(ctx, rect);
|
|
147
|
+
if (p.selection.enabled) p.selection.setModel([{ anchor, head }], 'keyboard');
|
|
148
|
+
else p.setFocusedCell({ id: anchor.id, field: anchor.field, rowIndex: rect.top });
|
|
149
|
+
const viewport = p.viewportRef.current;
|
|
150
|
+
if (!viewport) return;
|
|
151
|
+
scrollRowIntoView(viewport, rect.top - p.pinnedTopRowCount, p.layout.cumulativeHeights, p.layout.pinnedBottomHeight, p.rowHeight);
|
|
152
|
+
scrollColumnIntoView(viewport, p.layout.unpinnedColsWithWidth.findIndex(c => c.field === anchor.field), p.layout);
|
|
153
|
+
}, []);
|
|
154
|
+
|
|
155
|
+
/** Replays the top action of one stack onto the other. */
|
|
156
|
+
const replay = useCallback((direction: 'undo' | 'redo'): Promise<GridBatchEditResult> => {
|
|
157
|
+
const run = async (): Promise<GridBatchEditResult> => {
|
|
158
|
+
if (!latestRef.current.enabled) return emptyResult();
|
|
159
|
+
const from = direction === 'undo' ? undoStackRef : redoStackRef;
|
|
160
|
+
const to = direction === 'undo' ? redoStackRef : undoStackRef;
|
|
161
|
+
const action = from.current.pop();
|
|
162
|
+
if (!action) return emptyResult();
|
|
163
|
+
notify();
|
|
164
|
+
// Undo writes `before` where `after` still holds; redo writes `after` where `before` holds.
|
|
165
|
+
const edits = action.map(change => direction === 'undo'
|
|
166
|
+
? { id: change.id, field: change.field, value: change.before, expect: { value: change.after } }
|
|
167
|
+
: { id: change.id, field: change.field, value: change.after, expect: { value: change.before } });
|
|
168
|
+
const outcome = await latestRef.current.batchEdit.applyEdits(edits, direction);
|
|
169
|
+
if (outcome.committed.length > 0) {
|
|
170
|
+
// The opposite action: for an undo, `before` is what it stored and `after` what it replaced.
|
|
171
|
+
to.current.push(direction === 'undo'
|
|
172
|
+
? outcome.committed.map(c => ({ id: c.id, field: c.field, before: c.after, after: c.before }))
|
|
173
|
+
: outcome.committed);
|
|
174
|
+
}
|
|
175
|
+
// Rows whose processRowUpdate failed keep their part of the action, to try again.
|
|
176
|
+
const failedIds = new Set<GridRowId>(outcome.result.failed.map(f => f.id));
|
|
177
|
+
if (failedIds.size > 0) {
|
|
178
|
+
const remainder = action.filter(change => failedIds.has(change.id));
|
|
179
|
+
if (remainder.length > 0) from.current.push(remainder);
|
|
180
|
+
}
|
|
181
|
+
notify();
|
|
182
|
+
if (outcome.committed.length > 0) reveal(outcome.committed);
|
|
183
|
+
return outcome.result;
|
|
184
|
+
};
|
|
185
|
+
const next = queueRef.current.then(run, run);
|
|
186
|
+
queueRef.current = next;
|
|
187
|
+
return next;
|
|
188
|
+
}, [notify, reveal]);
|
|
189
|
+
|
|
190
|
+
const undo = useCallback(() => replay('undo'), [replay]);
|
|
191
|
+
const redo = useCallback(() => replay('redo'), [replay]);
|
|
192
|
+
|
|
193
|
+
useEffect(() => {
|
|
194
|
+
const root = containerRef.current;
|
|
195
|
+
if (!root || !enabled) return;
|
|
196
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
197
|
+
if (event.defaultPrevented) return;
|
|
198
|
+
const shortcut = historyShortcut(event);
|
|
199
|
+
if (!shortcut) return;
|
|
200
|
+
const viewport = latestRef.current.viewportRef.current;
|
|
201
|
+
if (!viewport || latestRef.current.isEditing) return;
|
|
202
|
+
// Inside an editor or a text field the input's own undo wins.
|
|
203
|
+
const kind = classifyKeyTarget(event.target, viewport);
|
|
204
|
+
if (kind !== 'grid' && kind !== 'control') return;
|
|
205
|
+
event.preventDefault();
|
|
206
|
+
void (shortcut === 'undo' ? undo() : redo());
|
|
207
|
+
};
|
|
208
|
+
root.addEventListener('keydown', onKeyDown);
|
|
209
|
+
return () => root.removeEventListener('keydown', onKeyDown);
|
|
210
|
+
}, [containerRef, enabled, undo, redo]);
|
|
211
|
+
|
|
212
|
+
useLayoutEffect(() => {
|
|
213
|
+
const api = apiRef.current;
|
|
214
|
+
api.undo = undo;
|
|
215
|
+
api.redo = redo;
|
|
216
|
+
api.canUndo = () => latestRef.current.enabled && undoStackRef.current.length > 0;
|
|
217
|
+
api.canRedo = () => latestRef.current.enabled && redoStackRef.current.length > 0;
|
|
218
|
+
api.clearHistory = clearHistory;
|
|
219
|
+
}, [apiRef, undo, redo, clearHistory]);
|
|
220
|
+
}
|
|
@@ -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
|
+
}
|