@calliopelabs/ui 0.14.0 → 0.18.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/README.md +235 -1
- package/dist/agent/AgentChat.d.ts +8 -1
- package/dist/agent/AgentChat.js +43 -2
- package/dist/agent/WorkflowRenderers.d.ts +61 -0
- package/dist/agent/WorkflowRenderers.js +102 -0
- package/dist/agent/controller.d.ts +19 -0
- package/dist/agent/controller.js +33 -0
- package/dist/agent/copilotkit/CopilotKitAgentProvider.js +2 -2
- package/dist/agent/index.d.ts +4 -2
- package/dist/agent/index.js +3 -1
- package/dist/agent/mock.d.ts +26 -4
- package/dist/agent/mock.js +68 -37
- package/dist/agent/renderers.d.ts +49 -0
- package/dist/agent/renderers.js +27 -0
- package/dist/appearance/brands.d.ts +17 -0
- package/dist/appearance/brands.js +14 -0
- package/dist/chat/MessageBubble.js +1 -1
- package/dist/chat/MessageList.d.ts +9 -2
- package/dist/chat/MessageList.js +11 -8
- package/dist/css/appearance.js +1 -1
- package/dist/css/calliope.css +5 -0
- package/dist/css/tokens.css +5 -0
- package/dist/index.d.ts +9 -1
- package/dist/index.js +8 -1
- package/dist/primitives/Badge.d.ts +14 -0
- package/dist/primitives/Badge.js +25 -0
- package/dist/primitives/ContextMenu.d.ts +18 -0
- package/dist/primitives/ContextMenu.js +34 -0
- package/dist/primitives/Menubar.d.ts +21 -0
- package/dist/primitives/Menubar.js +43 -0
- package/dist/primitives/ScrollArea.d.ts +16 -0
- package/dist/primitives/ScrollArea.js +11 -0
- package/dist/primitives/Toast.d.ts +37 -0
- package/dist/primitives/Toast.js +43 -0
- package/dist/primitives/Tooltip.d.ts +16 -0
- package/dist/primitives/Tooltip.js +26 -0
- package/dist/provider/messages.d.ts +217 -0
- package/dist/provider/messages.js +238 -0
- package/dist/shell/RailPanel.js +1 -1
- package/dist/theme/css-layer.js +2 -1
- package/dist/theme/tokens.generated.d.ts +3 -0
- package/dist/theme/tokens.generated.js +3 -0
- package/dist/workbench/ActivityBar.d.ts +37 -0
- package/dist/workbench/ActivityBar.js +57 -0
- package/dist/workbench/BottomPanel.d.ts +30 -0
- package/dist/workbench/BottomPanel.js +20 -0
- package/dist/workbench/CodeEditor.d.ts +48 -0
- package/dist/workbench/CodeEditor.js +153 -0
- package/dist/workbench/CommandPalette.d.ts +49 -0
- package/dist/workbench/CommandPalette.js +114 -0
- package/dist/workbench/DataChart.d.ts +45 -0
- package/dist/workbench/DataChart.js +146 -0
- package/dist/workbench/DataGrid.d.ts +89 -0
- package/dist/workbench/DataGrid.js +590 -0
- package/dist/workbench/DataProfile.d.ts +17 -0
- package/dist/workbench/DataProfile.js +51 -0
- package/dist/workbench/EditorTabs.d.ts +62 -0
- package/dist/workbench/EditorTabs.js +115 -0
- package/dist/workbench/FileBrowser.d.ts +62 -0
- package/dist/workbench/FileBrowser.js +117 -0
- package/dist/workbench/FileGrid.d.ts +13 -0
- package/dist/workbench/FileGrid.js +114 -0
- package/dist/workbench/FileIcon.d.ts +10 -0
- package/dist/workbench/FileIcon.js +24 -0
- package/dist/workbench/FileList.d.ts +35 -0
- package/dist/workbench/FileList.js +160 -0
- package/dist/workbench/FilePreview.d.ts +66 -0
- package/dist/workbench/FilePreview.js +108 -0
- package/dist/workbench/FileTree.d.ts +37 -0
- package/dist/workbench/FileTree.js +218 -0
- package/dist/workbench/GridCell.d.ts +19 -0
- package/dist/workbench/GridCell.js +50 -0
- package/dist/workbench/GridDetails.d.ts +31 -0
- package/dist/workbench/GridDetails.js +78 -0
- package/dist/workbench/MenuBar.d.ts +49 -0
- package/dist/workbench/MenuBar.js +25 -0
- package/dist/workbench/PaneGroup.d.ts +50 -0
- package/dist/workbench/PaneGroup.js +146 -0
- package/dist/workbench/PendingChangesBar.d.ts +20 -0
- package/dist/workbench/PendingChangesBar.js +19 -0
- package/dist/workbench/Shortcuts.d.ts +50 -0
- package/dist/workbench/Shortcuts.js +109 -0
- package/dist/workbench/SqlEditor.d.ts +21 -0
- package/dist/workbench/SqlEditor.js +79 -0
- package/dist/workbench/StatusBar.d.ts +28 -0
- package/dist/workbench/StatusBar.js +26 -0
- package/dist/workbench/TruncationBanner.d.ts +11 -0
- package/dist/workbench/TruncationBanner.js +12 -0
- package/dist/workbench/UploadDropzone.d.ts +52 -0
- package/dist/workbench/UploadDropzone.js +115 -0
- package/dist/workbench/WorkbenchShell.d.ts +36 -0
- package/dist/workbench/WorkbenchShell.js +20 -0
- package/dist/workbench/dnd.d.ts +17 -0
- package/dist/workbench/dnd.js +77 -0
- package/dist/workbench/download.d.ts +11 -0
- package/dist/workbench/download.js +28 -0
- package/dist/workbench/editor-theme.d.ts +42 -0
- package/dist/workbench/editor-theme.js +113 -0
- package/dist/workbench/grid-format.d.ts +59 -0
- package/dist/workbench/grid-format.js +169 -0
- package/dist/workbench/grid-model.d.ts +75 -0
- package/dist/workbench/grid-model.js +259 -0
- package/dist/workbench/grid-selection.d.ts +69 -0
- package/dist/workbench/grid-selection.js +183 -0
- package/dist/workbench/grid-stats.d.ts +58 -0
- package/dist/workbench/grid-stats.js +185 -0
- package/dist/workbench/index.d.ts +44 -0
- package/dist/workbench/index.js +47 -0
- package/dist/workbench/keys.d.ts +23 -0
- package/dist/workbench/keys.js +105 -0
- package/dist/workbench/model.d.ts +72 -0
- package/dist/workbench/model.js +214 -0
- package/dist/workbench/palette.d.ts +17 -0
- package/dist/workbench/palette.js +45 -0
- package/dist/workbench/pending.d.ts +92 -0
- package/dist/workbench/pending.js +207 -0
- package/dist/workbench/selection.d.ts +16 -0
- package/dist/workbench/selection.js +46 -0
- package/dist/workbench/sql-completion.d.ts +107 -0
- package/dist/workbench/sql-completion.js +475 -0
- package/dist/workbench/typeahead.d.ts +15 -0
- package/dist/workbench/typeahead.js +22 -0
- package/dist/workflow/WorkflowRuntimeControls.d.ts +21 -0
- package/dist/workflow/WorkflowRuntimeControls.js +54 -0
- package/dist/workflow/runtime-controls.d.ts +29 -0
- package/dist/workflow/runtime-controls.js +10 -0
- package/package.json +17 -2
|
@@ -0,0 +1,590 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { useVirtualizer } from "@tanstack/react-virtual";
|
|
4
|
+
import { ArrowDownIcon, ArrowUpIcon, ChevronDownIcon, ChevronsUpDownIcon, CopyIcon, DownloadIcon, EllipsisVerticalIcon, EyeIcon, EyeOffIcon, FunnelIcon, Maximize2Icon, PlusIcon, RotateCcwIcon, SearchIcon, Trash2Icon, XIcon, } from "lucide-react";
|
|
5
|
+
import * as React from "react";
|
|
6
|
+
import { cn } from "../lib/cn.js";
|
|
7
|
+
import { readLocal, subscribeLocal, writeLocal } from "../lib/local-store.js";
|
|
8
|
+
import { Button } from "../primitives/Button.js";
|
|
9
|
+
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuLabel, ContextMenuSeparator, ContextMenuShortcut, ContextMenuTrigger, } from "../primitives/ContextMenu.js";
|
|
10
|
+
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from "../primitives/DropdownMenu.js";
|
|
11
|
+
import { useUI } from "../provider/UIProvider.js";
|
|
12
|
+
import { ErrorState } from "../states/ErrorState.js";
|
|
13
|
+
import { LoadingState } from "../states/LoadingState.js";
|
|
14
|
+
import { downloadExport } from "./download.js";
|
|
15
|
+
import { ColumnKeyIcons, ColumnTypeIcon, GridCellValue } from "./GridCell.js";
|
|
16
|
+
import { CellDetailDialog, RowDetailDrawer } from "./GridDetails.js";
|
|
17
|
+
import { EXPORT_FORMATS, exportMatrix, formatMatrix, selectionMatrix, } from "./grid-format.js";
|
|
18
|
+
import { buildColumnMeta, cellText, columnSignature, isNumericType, viewRowIndexes, } from "./grid-model.js";
|
|
19
|
+
import { EMPTY_SELECTION, extendRowSelection, extendSelection, isColumnSelected, isRowSelected, isSelected, moveActive, selectAll, selectCell, selectColumn, selectRow, selectionBounds, selectionSize, toggleCell, } from "./grid-selection.js";
|
|
20
|
+
import { computeStats, formatStatValue, numericSummary } from "./grid-stats.js";
|
|
21
|
+
import { PendingChangesBar } from "./PendingChangesBar.js";
|
|
22
|
+
import { NO_CHANGES, addRow, deleteRows, editCell, insertedKeys, isCellChanged, parseCellInput, pendingCounts, pendingValue, revertRow, toChangeSet, } from "./pending.js";
|
|
23
|
+
import { formatShortcut } from "./keys.js";
|
|
24
|
+
import { TruncationBanner } from "./TruncationBanner.js";
|
|
25
|
+
const ROW_H = 28;
|
|
26
|
+
const HEADER_H = 32;
|
|
27
|
+
const GUTTER_W = 64;
|
|
28
|
+
const DEFAULT_COL_W = 160;
|
|
29
|
+
const MIN_COL_W = 60;
|
|
30
|
+
const MAX_COL_W = 800;
|
|
31
|
+
const RESIZE_STEP = 16;
|
|
32
|
+
/** Rows sampled when fitting a column to its content. */
|
|
33
|
+
const AUTOFIT_SAMPLE = 500;
|
|
34
|
+
const WIDTHS_PREFIX = "calliope-ui:grid-widths:";
|
|
35
|
+
function parseWidths(raw) {
|
|
36
|
+
if (!raw)
|
|
37
|
+
return {};
|
|
38
|
+
try {
|
|
39
|
+
const parsed = JSON.parse(raw);
|
|
40
|
+
if (!parsed || typeof parsed !== "object")
|
|
41
|
+
return {};
|
|
42
|
+
const out = {};
|
|
43
|
+
for (const [name, width] of Object.entries(parsed)) {
|
|
44
|
+
if (typeof width === "number" && Number.isFinite(width))
|
|
45
|
+
out[name] = width;
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return {};
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const clampWidth = (w) => Math.min(MAX_COL_W, Math.max(MIN_COL_W, Math.round(w)));
|
|
54
|
+
/**
|
|
55
|
+
* A data grid for query results and table data: rows and columns both
|
|
56
|
+
* virtualized (50,000 rows by 30 columns is a few hundred nodes), the
|
|
57
|
+
* WAI-ARIA grid pattern for the keyboard, cell, row and column selection,
|
|
58
|
+
* sort (NULLs last), filter, hidden and resizable columns, copy and export
|
|
59
|
+
* in six text formats, selection statistics, cell and row detail, and
|
|
60
|
+
* optional editing with pending changes the host commits. Host-driven: the
|
|
61
|
+
* rows come in, changes go out through callbacks, nothing fetches.
|
|
62
|
+
*/
|
|
63
|
+
export function DataGrid(props) {
|
|
64
|
+
const key = `${props.storageKey ?? ""}\u0000${columnSignature(props.columns)}`;
|
|
65
|
+
return _jsx(GridSurface, { ...props }, key);
|
|
66
|
+
}
|
|
67
|
+
function GridSurface({ columns: columnsIn, rows, label, loading = false, error, onRetry, totalRows, hasMore = false, storageKey, tableName, exportName, onCopy, onExport, exportFormats, editable = false, onCommit, pending: pendingIn, onPendingChange, toolbar, className, }) {
|
|
68
|
+
const { messages } = useUI();
|
|
69
|
+
const t = messages.workbench;
|
|
70
|
+
const gridId = React.useId();
|
|
71
|
+
const scrollRef = React.useRef(null);
|
|
72
|
+
const [sort, setSort] = React.useState(null);
|
|
73
|
+
const [filterInput, setFilterInput] = React.useState("");
|
|
74
|
+
const [filter, setFilter] = React.useState("");
|
|
75
|
+
const [hidden, setHidden] = React.useState([]);
|
|
76
|
+
const [selection, setSelection] = React.useState(EMPTY_SELECTION);
|
|
77
|
+
const [scrollLeft, setScrollLeft] = React.useState(0);
|
|
78
|
+
const [cellDetail, setCellDetail] = React.useState(null);
|
|
79
|
+
const [rowDetail, setRowDetail] = React.useState(null);
|
|
80
|
+
const [editing, setEditing] = React.useState(null);
|
|
81
|
+
const [ownPending, setOwnPending] = React.useState(NO_CHANGES);
|
|
82
|
+
const [committing, setCommitting] = React.useState(false);
|
|
83
|
+
const [commitError, setCommitError] = React.useState(null);
|
|
84
|
+
const pending = pendingIn ?? ownPending;
|
|
85
|
+
const setPending = (next) => {
|
|
86
|
+
if (pendingIn === undefined)
|
|
87
|
+
setOwnPending(next);
|
|
88
|
+
onPendingChange?.(next);
|
|
89
|
+
};
|
|
90
|
+
// Widths: the stored ones, with a drag in progress on top.
|
|
91
|
+
const storeKey = storageKey ? WIDTHS_PREFIX + storageKey : null;
|
|
92
|
+
const stored = React.useSyncExternalStore(subscribeLocal, () => (storeKey ? readLocal(storeKey) : null), () => null);
|
|
93
|
+
const [localWidths, setLocalWidths] = React.useState({});
|
|
94
|
+
const widths = React.useMemo(() => ({ ...parseWidths(stored), ...localWidths }), [stored, localWidths]);
|
|
95
|
+
const saveWidth = (name, width) => {
|
|
96
|
+
const next = { ...widths, [name]: clampWidth(width) };
|
|
97
|
+
setLocalWidths(next);
|
|
98
|
+
if (storeKey)
|
|
99
|
+
writeLocal(storeKey, JSON.stringify(next));
|
|
100
|
+
};
|
|
101
|
+
const meta = React.useMemo(() => buildColumnMeta(columnsIn, rows), [columnsIn, rows]);
|
|
102
|
+
const columns = React.useMemo(() => meta.filter((c) => !hidden.includes(c.name)), [meta, hidden]);
|
|
103
|
+
const names = React.useMemo(() => columns.map((c) => c.name), [columns]);
|
|
104
|
+
const primaryKeys = React.useMemo(() => meta.filter((c) => c.primaryKey).map((c) => c.name), [meta]);
|
|
105
|
+
React.useEffect(() => {
|
|
106
|
+
if (filterInput === filter)
|
|
107
|
+
return;
|
|
108
|
+
// Scanning every cell of a 50k-row result per keystroke is the one thing that can make it slow.
|
|
109
|
+
const timer = window.setTimeout(() => setFilter(filterInput), 150);
|
|
110
|
+
return () => window.clearTimeout(timer);
|
|
111
|
+
}, [filterInput, filter]);
|
|
112
|
+
/** Row keys in view order: source rows filtered and sorted, then rows inserted while editing. */
|
|
113
|
+
const viewKeys = React.useMemo(() => {
|
|
114
|
+
const keys = viewRowIndexes(rows, names, filter, sort);
|
|
115
|
+
return editable ? keys.concat(insertedKeys(pending)) : keys;
|
|
116
|
+
}, [rows, names, filter, sort, editable, pending]);
|
|
117
|
+
const rowCount = viewKeys.length;
|
|
118
|
+
const colCount = columns.length;
|
|
119
|
+
const widthOf = React.useCallback((name) => widths[name] ?? DEFAULT_COL_W, [widths]);
|
|
120
|
+
const totalWidth = columns.reduce((sum, c) => sum + widthOf(c.name), 0);
|
|
121
|
+
// eslint-disable-next-line react-hooks/incompatible-library -- the virtualizer is read during render by design
|
|
122
|
+
const rowVirtualizer = useVirtualizer({
|
|
123
|
+
count: rowCount,
|
|
124
|
+
getScrollElement: () => scrollRef.current,
|
|
125
|
+
estimateSize: () => ROW_H,
|
|
126
|
+
overscan: 12,
|
|
127
|
+
paddingStart: HEADER_H,
|
|
128
|
+
});
|
|
129
|
+
const columnVirtualizer = useVirtualizer({
|
|
130
|
+
horizontal: true,
|
|
131
|
+
count: colCount,
|
|
132
|
+
getScrollElement: () => scrollRef.current,
|
|
133
|
+
estimateSize: (index) => widthOf(columns[index]?.name ?? ""),
|
|
134
|
+
overscan: 3,
|
|
135
|
+
paddingStart: GUTTER_W,
|
|
136
|
+
});
|
|
137
|
+
React.useEffect(() => {
|
|
138
|
+
columnVirtualizer.measure();
|
|
139
|
+
}, [columnVirtualizer, widths, columns]);
|
|
140
|
+
const sourceOf = React.useCallback((key) => (key >= 0 ? rows[key] : undefined), [rows]);
|
|
141
|
+
const valueAt = React.useCallback((row, col) => {
|
|
142
|
+
const key = viewKeys[row];
|
|
143
|
+
const column = names[col];
|
|
144
|
+
if (key === undefined || column === undefined)
|
|
145
|
+
return undefined;
|
|
146
|
+
return pendingValue(pending, key, column, sourceOf(key));
|
|
147
|
+
}, [viewKeys, names, pending, sourceOf]);
|
|
148
|
+
const rowAt = (row) => {
|
|
149
|
+
const key = viewKeys[row];
|
|
150
|
+
if (key === undefined)
|
|
151
|
+
return {};
|
|
152
|
+
return pending.rows.get(key)?.values ?? sourceOf(key) ?? {};
|
|
153
|
+
};
|
|
154
|
+
function canEdit(key) {
|
|
155
|
+
if (!editable || key === undefined)
|
|
156
|
+
return false;
|
|
157
|
+
const state = pending.rows.get(key)?.state;
|
|
158
|
+
if (state === "deleted")
|
|
159
|
+
return false;
|
|
160
|
+
return state === "added" || primaryKeys.length > 0;
|
|
161
|
+
}
|
|
162
|
+
function reveal(cell) {
|
|
163
|
+
rowVirtualizer.scrollToIndex(cell.row, { align: "auto" });
|
|
164
|
+
columnVirtualizer.scrollToIndex(cell.col, { align: "auto" });
|
|
165
|
+
}
|
|
166
|
+
function focusGrid() {
|
|
167
|
+
scrollRef.current?.focus({ preventScroll: true });
|
|
168
|
+
}
|
|
169
|
+
const copyMatrix = () => {
|
|
170
|
+
if (selection.ranges.length > 0)
|
|
171
|
+
return selectionMatrix(selection, names, valueAt);
|
|
172
|
+
return {
|
|
173
|
+
headers: names,
|
|
174
|
+
rows: viewKeys.map((_, row) => names.map((_, col) => valueAt(row, col))),
|
|
175
|
+
};
|
|
176
|
+
};
|
|
177
|
+
function copyAs(format) {
|
|
178
|
+
const matrix = copyMatrix();
|
|
179
|
+
if (matrix.rows.length === 0)
|
|
180
|
+
return;
|
|
181
|
+
const text = formatMatrix(format, matrix, { tableName });
|
|
182
|
+
const cells = matrix.rows.length * matrix.headers.length;
|
|
183
|
+
void navigator.clipboard?.writeText(text).then(() => onCopy?.({ format, text, cells }), () => { });
|
|
184
|
+
}
|
|
185
|
+
function runExport(format) {
|
|
186
|
+
const matrix = {
|
|
187
|
+
headers: names,
|
|
188
|
+
rows: viewKeys.map((_, row) => names.map((_, col) => valueAt(row, col))),
|
|
189
|
+
};
|
|
190
|
+
const own = EXPORT_FORMATS.includes(format);
|
|
191
|
+
const file = own
|
|
192
|
+
? exportMatrix(format, matrix, { tableName, baseName: exportName })
|
|
193
|
+
: null;
|
|
194
|
+
if (onExport)
|
|
195
|
+
onExport({ format, matrix, file });
|
|
196
|
+
else if (file)
|
|
197
|
+
downloadExport(file);
|
|
198
|
+
}
|
|
199
|
+
function startEdit(cell, seed) {
|
|
200
|
+
const key = viewKeys[cell.row];
|
|
201
|
+
if (key === undefined || !canEdit(key) || !names[cell.col])
|
|
202
|
+
return false;
|
|
203
|
+
reveal(cell);
|
|
204
|
+
setEditing({ key, col: cell.col, seed });
|
|
205
|
+
return true;
|
|
206
|
+
}
|
|
207
|
+
function finishEdit(text, move) {
|
|
208
|
+
if (!editing)
|
|
209
|
+
return;
|
|
210
|
+
const column = columns[editing.col];
|
|
211
|
+
if (text !== null && column) {
|
|
212
|
+
const original = pendingValue(pending, editing.key, column.name, sourceOf(editing.key));
|
|
213
|
+
const value = parseCellInput(text, original, column.type);
|
|
214
|
+
if (!Object.is(value, original)) {
|
|
215
|
+
setPending(editCell(pending, editing.key, column.name, value, sourceOf(editing.key)));
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
setEditing(null);
|
|
219
|
+
const active = selection.active;
|
|
220
|
+
if (move && active) {
|
|
221
|
+
const next = moveActive(selection, { row: active.row + move.row, col: active.col + move.col }, rowCount, colCount, false);
|
|
222
|
+
setSelection(next);
|
|
223
|
+
if (next.active)
|
|
224
|
+
reveal(next.active);
|
|
225
|
+
}
|
|
226
|
+
focusGrid();
|
|
227
|
+
}
|
|
228
|
+
/** Rows covered by the selection (or the active row), as keys. */
|
|
229
|
+
function selectedKeys() {
|
|
230
|
+
const bounds = selectionBounds(selection);
|
|
231
|
+
if (!bounds)
|
|
232
|
+
return [];
|
|
233
|
+
const keys = [];
|
|
234
|
+
for (let row = bounds.minRow; row <= bounds.maxRow; row++) {
|
|
235
|
+
const key = viewKeys[row];
|
|
236
|
+
if (key !== undefined)
|
|
237
|
+
keys.push(key);
|
|
238
|
+
}
|
|
239
|
+
return keys;
|
|
240
|
+
}
|
|
241
|
+
const deletable = (editable ? selectedKeys() : []).filter((key) => pending.rows.get(key)?.state === "added" || (primaryKeys.length > 0 && key >= 0));
|
|
242
|
+
function addNewRow(from) {
|
|
243
|
+
const { changes } = addRow(pending, meta.map((c) => c.name), from);
|
|
244
|
+
setPending(changes);
|
|
245
|
+
// The new row is last in view.
|
|
246
|
+
const cell = { row: rowCount, col: 0 };
|
|
247
|
+
setSelection(selectCell(cell));
|
|
248
|
+
requestAnimationFrame(() => reveal(cell));
|
|
249
|
+
}
|
|
250
|
+
async function commit() {
|
|
251
|
+
if (!onCommit || committing)
|
|
252
|
+
return;
|
|
253
|
+
const changeSet = toChangeSet(pending, { columns: meta.map((c) => c.name), primaryKeys });
|
|
254
|
+
setCommitting(true);
|
|
255
|
+
setCommitError(null);
|
|
256
|
+
try {
|
|
257
|
+
await onCommit(changeSet);
|
|
258
|
+
setPending(NO_CHANGES);
|
|
259
|
+
}
|
|
260
|
+
catch (reason) {
|
|
261
|
+
const failure = reason;
|
|
262
|
+
const message = typeof failure?.message === "string" && failure.message !== ""
|
|
263
|
+
? failure.message
|
|
264
|
+
: String(reason);
|
|
265
|
+
const row = typeof failure?.key === "number" ? viewKeys.indexOf(failure.key) : -1;
|
|
266
|
+
setCommitError({ message, row: row >= 0 ? row + 1 : null });
|
|
267
|
+
}
|
|
268
|
+
finally {
|
|
269
|
+
setCommitting(false);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
function handleKeyDown(event) {
|
|
273
|
+
// Keys typed into a header control or the cell editor are theirs.
|
|
274
|
+
if (event.target !== event.currentTarget)
|
|
275
|
+
return;
|
|
276
|
+
if (rowCount === 0 || colCount === 0)
|
|
277
|
+
return;
|
|
278
|
+
const mod = event.metaKey || event.ctrlKey;
|
|
279
|
+
const active = selection.active ?? { row: 0, col: 0 };
|
|
280
|
+
const viewport = scrollRef.current?.clientHeight ?? ROW_H * 20;
|
|
281
|
+
const page = Math.max(1, Math.floor((viewport - HEADER_H) / ROW_H) - 1);
|
|
282
|
+
const go = (row, col) => {
|
|
283
|
+
event.preventDefault();
|
|
284
|
+
// The first move lands on the first cell rather than skipping it.
|
|
285
|
+
const to = selection.active ? { row, col } : { row: 0, col: 0 };
|
|
286
|
+
const next = moveActive(selection, to, rowCount, colCount, event.shiftKey);
|
|
287
|
+
setSelection(next);
|
|
288
|
+
if (next.active)
|
|
289
|
+
reveal(next.active);
|
|
290
|
+
};
|
|
291
|
+
switch (event.key) {
|
|
292
|
+
case "ArrowDown":
|
|
293
|
+
return go(active.row + 1, active.col);
|
|
294
|
+
case "ArrowUp":
|
|
295
|
+
return go(active.row - 1, active.col);
|
|
296
|
+
case "ArrowRight":
|
|
297
|
+
return go(active.row, active.col + 1);
|
|
298
|
+
case "ArrowLeft":
|
|
299
|
+
return go(active.row, active.col - 1);
|
|
300
|
+
case "PageDown":
|
|
301
|
+
return go(active.row + page, active.col);
|
|
302
|
+
case "PageUp":
|
|
303
|
+
return go(active.row - page, active.col);
|
|
304
|
+
case "Home":
|
|
305
|
+
return go(mod ? 0 : active.row, 0);
|
|
306
|
+
case "End":
|
|
307
|
+
return go(mod ? rowCount - 1 : active.row, colCount - 1);
|
|
308
|
+
case "Enter":
|
|
309
|
+
event.preventDefault();
|
|
310
|
+
if (event.shiftKey)
|
|
311
|
+
setRowDetail(active.row);
|
|
312
|
+
else if (!startEdit(active))
|
|
313
|
+
setCellDetail(active);
|
|
314
|
+
return;
|
|
315
|
+
case "F2":
|
|
316
|
+
event.preventDefault();
|
|
317
|
+
startEdit(active);
|
|
318
|
+
return;
|
|
319
|
+
case "Escape":
|
|
320
|
+
if (selection.ranges.length === 0)
|
|
321
|
+
return;
|
|
322
|
+
event.preventDefault();
|
|
323
|
+
setSelection(EMPTY_SELECTION);
|
|
324
|
+
return;
|
|
325
|
+
default:
|
|
326
|
+
break;
|
|
327
|
+
}
|
|
328
|
+
if (mod && (event.key === "a" || event.key === "A")) {
|
|
329
|
+
event.preventDefault();
|
|
330
|
+
setSelection(selectAll(rowCount, colCount));
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
if (mod && (event.key === "c" || event.key === "C")) {
|
|
334
|
+
event.preventDefault();
|
|
335
|
+
copyAs(event.shiftKey ? "tsv-headers" : "tsv");
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
// Typing a character starts an edit with it, as a spreadsheet does.
|
|
339
|
+
if (!mod && !event.altKey && event.key.length === 1 && editable && selection.active) {
|
|
340
|
+
if (startEdit(selection.active, event.key))
|
|
341
|
+
event.preventDefault();
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
function handleCellMouseDown(event, cell) {
|
|
345
|
+
if (event.button !== 0)
|
|
346
|
+
return;
|
|
347
|
+
event.preventDefault();
|
|
348
|
+
focusGrid();
|
|
349
|
+
if (event.shiftKey)
|
|
350
|
+
setSelection((prev) => extendSelection(prev, cell));
|
|
351
|
+
else if (event.metaKey || event.ctrlKey)
|
|
352
|
+
setSelection((prev) => toggleCell(prev, cell));
|
|
353
|
+
else
|
|
354
|
+
setSelection(selectCell(cell));
|
|
355
|
+
}
|
|
356
|
+
function autoFit(name) {
|
|
357
|
+
let longest = name.length;
|
|
358
|
+
const sample = Math.min(viewKeys.length, AUTOFIT_SAMPLE);
|
|
359
|
+
const col = names.indexOf(name);
|
|
360
|
+
for (let row = 0; row < sample; row++) {
|
|
361
|
+
longest = Math.max(longest, cellText(valueAt(row, col)).length);
|
|
362
|
+
}
|
|
363
|
+
// Monospace text-xs is a shade over 7px a character; 48px is the padding and the controls.
|
|
364
|
+
saveWidth(name, longest * 7.3 + 48);
|
|
365
|
+
}
|
|
366
|
+
const selectedCount = selectionSize(selection);
|
|
367
|
+
const activeColumn = selection.active ? names[selection.active.col] : undefined;
|
|
368
|
+
const activeValue = selection.active
|
|
369
|
+
? cellText(valueAt(selection.active.row, selection.active.col))
|
|
370
|
+
: undefined;
|
|
371
|
+
const footer = React.useMemo(() => {
|
|
372
|
+
const bounds = selectionBounds(selection);
|
|
373
|
+
if (!bounds)
|
|
374
|
+
return null;
|
|
375
|
+
const wholeColumn = rowCount > 0 &&
|
|
376
|
+
bounds.minCol === bounds.maxCol &&
|
|
377
|
+
bounds.minRow === 0 &&
|
|
378
|
+
bounds.maxRow === rowCount - 1;
|
|
379
|
+
if (wholeColumn) {
|
|
380
|
+
const column = names[bounds.minCol];
|
|
381
|
+
if (column !== undefined) {
|
|
382
|
+
return {
|
|
383
|
+
kind: "column",
|
|
384
|
+
name: column,
|
|
385
|
+
stats: computeStats(viewKeys.map((_, row) => valueAt(row, bounds.minCol))),
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
const values = [];
|
|
390
|
+
for (let row = bounds.minRow; row <= bounds.maxRow; row++) {
|
|
391
|
+
for (let col = bounds.minCol; col <= bounds.maxCol; col++) {
|
|
392
|
+
if (isSelected(selection, row, col))
|
|
393
|
+
values.push(valueAt(row, col));
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
return { kind: "selection", summary: numericSummary(values) };
|
|
397
|
+
}, [selection, rowCount, names, viewKeys, valueAt]);
|
|
398
|
+
const counts = pendingCounts(pending);
|
|
399
|
+
const truncated = rows.length > 0 && totalRows != null && totalRows > rows.length
|
|
400
|
+
? t.gridTruncated(rows.length, totalRows)
|
|
401
|
+
: rows.length > 0 && hasMore
|
|
402
|
+
? t.gridTruncated(rows.length, null)
|
|
403
|
+
: null;
|
|
404
|
+
const errorMessage = typeof error === "string" ? error : error?.message;
|
|
405
|
+
const virtualRows = rowVirtualizer.getVirtualItems();
|
|
406
|
+
const virtualColumns = columnVirtualizer.getVirtualItems();
|
|
407
|
+
const activeId = selection.active &&
|
|
408
|
+
virtualRows.some((v) => v.index === selection.active?.row) &&
|
|
409
|
+
virtualColumns.some((v) => v.index === selection.active?.col)
|
|
410
|
+
? `${gridId}-${selection.active.row}-${selection.active.col}`
|
|
411
|
+
: undefined;
|
|
412
|
+
const extraFormats = onExport ? (exportFormats ?? []) : [];
|
|
413
|
+
const detailCell = cellDetail && names[cellDetail.col] !== undefined ? cellDetail : null;
|
|
414
|
+
const detailRow = rowDetail !== null && rowDetail < rowCount ? rowDetail : null;
|
|
415
|
+
let body;
|
|
416
|
+
if (error) {
|
|
417
|
+
body = (_jsx(ErrorState, { title: t.gridLoadFailed, error: errorMessage || t.gridLoadFailed, onRetry: onRetry, retryLabel: t.retry }));
|
|
418
|
+
}
|
|
419
|
+
else if (loading && rows.length === 0) {
|
|
420
|
+
body = _jsx(LoadingState, { label: t.gridLoading, rows: 8 });
|
|
421
|
+
}
|
|
422
|
+
else {
|
|
423
|
+
body = (_jsxs(ContextMenu, { children: [_jsx(ContextMenuTrigger, { asChild: true, children: _jsxs("div", { ref: scrollRef, role: "grid", tabIndex: 0, "aria-label": label ?? t.gridLabel, "aria-rowcount": rowCount + 1, "aria-colcount": colCount + 1, "aria-multiselectable": true, "aria-busy": loading || undefined, "aria-activedescendant": activeId, onKeyDown: handleKeyDown, onScroll: (event) => {
|
|
424
|
+
const left = event.currentTarget.scrollLeft;
|
|
425
|
+
setScrollLeft((prev) => (prev === left ? prev : left));
|
|
426
|
+
}, className: "relative min-h-0 flex-1 overflow-auto font-mono text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-inset", children: [_jsxs("div", { role: "rowgroup", className: "relative", style: {
|
|
427
|
+
width: GUTTER_W + totalWidth,
|
|
428
|
+
height: Math.max(rowVirtualizer.getTotalSize(), HEADER_H),
|
|
429
|
+
}, children: [_jsxs("div", { role: "row", "aria-rowindex": 1, className: "sticky top-0 z-20 bg-muted", style: { height: HEADER_H, width: GUTTER_W + totalWidth }, children: [_jsx("div", { role: "columnheader", "aria-colindex": 1, className: "absolute top-0 left-0 z-10 flex h-full border-r border-b bg-muted", style: { width: GUTTER_W, transform: `translateX(${scrollLeft}px)` }, children: _jsx("button", { type: "button", "aria-label": t.gridSelectAll, className: "flex flex-1 items-center justify-center text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50", onClick: () => {
|
|
430
|
+
setSelection(selectAll(rowCount, colCount));
|
|
431
|
+
focusGrid();
|
|
432
|
+
}, children: "#" }) }), virtualColumns.map((vc) => {
|
|
433
|
+
const column = columns[vc.index];
|
|
434
|
+
if (!column)
|
|
435
|
+
return null;
|
|
436
|
+
return (_jsx(HeaderCell, { column: column, index: vc.index, left: vc.start, width: vc.size, sort: sort?.column === column.name ? sort.direction : null, selected: isColumnSelected(selection, vc.index, rowCount), hiddenCount: hidden.length, activeValue: activeColumn === column.name ? activeValue : undefined, onSort: (direction) => setSort(direction ? { column: column.name, direction } : null), onSelect: (additive) => {
|
|
437
|
+
setSelection((prev) => selectColumn(prev, vc.index, rowCount, additive));
|
|
438
|
+
focusGrid();
|
|
439
|
+
}, onResize: (width) => saveWidth(column.name, width), onResizing: (width) => setLocalWidths((prev) => ({ ...prev, [column.name]: clampWidth(width) })), onAutoFit: () => autoFit(column.name), onHide: () => {
|
|
440
|
+
setHidden((prev) => [...prev, column.name]);
|
|
441
|
+
setSelection(EMPTY_SELECTION);
|
|
442
|
+
}, onShowAll: () => setHidden([]), onFilterByValue: (value) => {
|
|
443
|
+
setFilterInput(value);
|
|
444
|
+
setFilter(value);
|
|
445
|
+
}, onCopyName: () => void navigator.clipboard?.writeText(column.name) }, column.name));
|
|
446
|
+
})] }), virtualRows.map((vr) => {
|
|
447
|
+
const key = viewKeys[vr.index];
|
|
448
|
+
if (key === undefined)
|
|
449
|
+
return null;
|
|
450
|
+
const state = pending.rows.get(key)?.state;
|
|
451
|
+
const rowSelected = isRowSelected(selection, vr.index, colCount);
|
|
452
|
+
const failed = commitError?.row === vr.index + 1;
|
|
453
|
+
return (_jsxs("div", { role: "row", "aria-rowindex": vr.index + 2, "aria-selected": rowSelected, "data-state": state, className: cn("absolute top-0 left-0", vr.index % 2 === 1 && "bg-muted/30", state === "deleted" && "text-muted-foreground", failed && "ring-1 ring-danger ring-inset"), style: {
|
|
454
|
+
height: vr.size,
|
|
455
|
+
width: GUTTER_W + totalWidth,
|
|
456
|
+
transform: `translateY(${vr.start}px)`,
|
|
457
|
+
}, children: [_jsxs("div", { role: "rowheader", "aria-colindex": 1, className: cn("absolute top-0 left-0 z-10 flex h-full cursor-pointer items-center justify-end gap-1 border-r border-b bg-muted px-2 text-muted-foreground tabular-nums select-none", state && "border-l-2", state === "added" && "border-l-success", state === "modified" && "border-l-warning", state === "deleted" && "border-l-danger", rowSelected && "bg-primary/15 text-foreground"), style: { width: GUTTER_W, transform: `translateX(${scrollLeft}px)` }, onMouseDown: (event) => {
|
|
458
|
+
if (event.button !== 0)
|
|
459
|
+
return;
|
|
460
|
+
event.preventDefault();
|
|
461
|
+
if (event.shiftKey)
|
|
462
|
+
setSelection((prev) => extendRowSelection(prev, vr.index, colCount));
|
|
463
|
+
else
|
|
464
|
+
setSelection((prev) => selectRow(prev, vr.index, colCount, event.metaKey || event.ctrlKey));
|
|
465
|
+
focusGrid();
|
|
466
|
+
}, children: [key < 0 ? "+" : (vr.index + 1).toLocaleString(), state && _jsx("span", { className: "sr-only", children: ` ${t.gridRowState(state)}` })] }), virtualColumns.map((vc) => {
|
|
467
|
+
const column = columns[vc.index];
|
|
468
|
+
if (!column)
|
|
469
|
+
return null;
|
|
470
|
+
const value = pendingValue(pending, key, column.name, sourceOf(key));
|
|
471
|
+
const cell = { row: vr.index, col: vc.index };
|
|
472
|
+
const selected = isSelected(selection, cell.row, cell.col);
|
|
473
|
+
const active = selection.active?.row === cell.row && selection.active.col === cell.col;
|
|
474
|
+
const isEditing = editing?.key === key && editing.col === vc.index;
|
|
475
|
+
const changed = isCellChanged(pending, key, column.name);
|
|
476
|
+
return (_jsx("div", { id: `${gridId}-${cell.row}-${cell.col}`, role: "gridcell", "aria-colindex": vc.index + 2, "aria-selected": selected, "aria-readonly": editable ? !canEdit(key) : undefined, title: isEditing ? undefined : cellText(value), className: cn("absolute top-0 flex h-full items-center overflow-hidden border-r border-b px-2", isNumericType(column.type) || typeof value === "number"
|
|
477
|
+
? "justify-end tabular-nums"
|
|
478
|
+
: "justify-start", state === "deleted" && "line-through", changed && "bg-warning/15", state === "added" && "bg-success/10", selected && "bg-primary/15", active && "ring-2 ring-ring ring-inset", isEditing && "p-0"), style: { left: vc.start, width: vc.size }, onMouseDown: (event) => {
|
|
479
|
+
if (!isEditing)
|
|
480
|
+
handleCellMouseDown(event, cell);
|
|
481
|
+
}, onDoubleClick: () => {
|
|
482
|
+
if (!startEdit(cell))
|
|
483
|
+
setCellDetail(cell);
|
|
484
|
+
}, onContextMenu: () => {
|
|
485
|
+
if (!isSelected(selection, cell.row, cell.col))
|
|
486
|
+
setSelection(selectCell(cell));
|
|
487
|
+
}, children: isEditing ? (_jsx(CellEditor, { label: t.gridEditCell(column.name), initial: editing.seed ?? cellText(value), numeric: isNumericType(column.type), onDone: finishEdit })) : (_jsx(GridCellValue, { value: value })) }, column.name));
|
|
488
|
+
})] }, key));
|
|
489
|
+
})] }), rowCount === 0 && (_jsx("p", { className: "absolute inset-x-0 py-8 text-center font-sans text-sm text-muted-foreground", style: { top: HEADER_H }, children: rows.length === 0 ? t.gridNoRows : t.gridNoMatches }))] }) }), _jsxs(ContextMenuContent, { className: "w-60", children: [_jsxs(ContextMenuItem, { onSelect: () => copyAs("tsv"), children: [_jsx(CopyIcon, { "aria-hidden": true }), t.gridCopy, _jsx(ContextMenuShortcut, { children: formatShortcut("Mod+C") })] }), _jsxs(ContextMenuItem, { onSelect: () => copyAs("tsv-headers"), children: [_jsx(CopyIcon, { "aria-hidden": true }), t.gridCopyWithHeaders, _jsx(ContextMenuShortcut, { children: formatShortcut("Mod+Shift+C") })] }), _jsx(ContextMenuLabel, { children: t.gridCopyAs }), ["csv", "json", "insert", "markdown"].map((format) => (_jsx(ContextMenuItem, { onSelect: () => copyAs(format), children: t.gridCopyFormat(format) }, format))), _jsx(ContextMenuSeparator, {}), _jsxs(ContextMenuItem, { disabled: selection.active === null, onSelect: () => selection.active && setRowDetail(selection.active.row), children: [_jsx(Maximize2Icon, { "aria-hidden": true }), t.gridRowDetail, _jsx(ContextMenuShortcut, { children: formatShortcut("Shift+Enter") })] }), _jsxs(ContextMenuItem, { disabled: selection.active === null, onSelect: () => selection.active && setCellDetail(selection.active), children: [_jsx(Maximize2Icon, { "aria-hidden": true }), t.gridCellDetail] }), editable && (_jsxs(_Fragment, { children: [_jsx(ContextMenuSeparator, {}), _jsxs(ContextMenuItem, { onSelect: () => addNewRow(), children: [_jsx(PlusIcon, { "aria-hidden": true }), t.gridAddRow] }), _jsxs(ContextMenuItem, { disabled: selection.active === null, onSelect: () => selection.active && addNewRow(rowAt(selection.active.row)), children: [_jsx(CopyIcon, { "aria-hidden": true }), t.gridDuplicateRow] }), _jsxs(ContextMenuItem, { variant: "destructive", disabled: deletable.length === 0, onSelect: () => setPending(deleteRows(pending, deletable, sourceOf)), children: [_jsx(Trash2Icon, { "aria-hidden": true }), t.gridDeleteRows(deletable.length)] }), _jsxs(ContextMenuItem, { disabled: !selection.active || !pending.rows.has(viewKeys[selection.active.row] ?? NaN), onSelect: () => {
|
|
490
|
+
const key = selection.active ? viewKeys[selection.active.row] : undefined;
|
|
491
|
+
if (key !== undefined)
|
|
492
|
+
setPending(revertRow(pending, key));
|
|
493
|
+
}, children: [_jsx(RotateCcwIcon, { "aria-hidden": true }), t.gridRevertRow] })] })), _jsx(ContextMenuSeparator, {}), _jsxs(ContextMenuItem, { onSelect: () => setSelection(EMPTY_SELECTION), children: [t.gridClearSelection, _jsx(ContextMenuShortcut, { children: formatShortcut("Escape") })] })] })] }));
|
|
494
|
+
}
|
|
495
|
+
return (_jsxs("div", { "data-slot": "data-grid", className: cn("flex min-h-0 min-w-0 flex-col bg-background text-sm", className), children: [_jsxs("div", { className: "flex min-h-9 shrink-0 flex-wrap items-center gap-2 border-b bg-muted/40 px-2 py-1 text-xs", children: [_jsxs("span", { className: "text-muted-foreground tabular-nums", children: [t.gridRows(rowCount, rows.length + (editable ? insertedKeys(pending).length : 0)), ", ", t.gridColumns(colCount)] }), _jsxs("label", { className: "flex h-7 max-w-xs min-w-32 flex-1 items-center gap-1 rounded-lg border bg-background px-2 focus-within:ring-2 focus-within:ring-ring/50", children: [_jsx(SearchIcon, { "aria-hidden": true, className: "size-3 shrink-0 text-muted-foreground" }), _jsx("span", { className: "sr-only", children: t.gridFilter }), _jsx("input", { type: "search", value: filterInput, onChange: (event) => setFilterInput(event.target.value), placeholder: t.gridFilter, className: "w-full min-w-0 bg-transparent outline-none placeholder:text-muted-foreground" }), filterInput && (_jsx("button", { type: "button", "aria-label": t.gridClearFilter, className: "text-muted-foreground hover:text-foreground", onClick: () => {
|
|
496
|
+
setFilterInput("");
|
|
497
|
+
setFilter("");
|
|
498
|
+
}, children: _jsx(XIcon, { "aria-hidden": true, className: "size-3" }) }))] }), hidden.length > 0 && (_jsxs(Button, { variant: "ghost", size: "xs", onClick: () => setHidden([]), children: [_jsx(EyeIcon, { "aria-hidden": true }), t.gridHidden(hidden.length)] })), _jsxs("span", { className: "ml-auto flex items-center gap-1", children: [editable && (_jsxs(_Fragment, { children: [_jsxs(Button, { variant: "ghost", size: "xs", onClick: () => addNewRow(), children: [_jsx(PlusIcon, { "aria-hidden": true }), t.gridAddRow] }), _jsxs(Button, { variant: "ghost", size: "xs", disabled: deletable.length === 0, onClick: () => setPending(deleteRows(pending, deletable, sourceOf)), children: [_jsx(Trash2Icon, { "aria-hidden": true }), t.gridDeleteRows(Math.max(deletable.length, 1))] })] })), _jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsxs(Button, { variant: "ghost", size: "xs", disabled: rowCount === 0, children: [_jsx(DownloadIcon, { "aria-hidden": true }), t.gridExport, _jsx(ChevronDownIcon, { "aria-hidden": true })] }) }), _jsxs(DropdownMenuContent, { align: "end", className: "w-56", children: [_jsx(DropdownMenuLabel, { className: "text-xs font-normal text-muted-foreground", children: t.gridExportScope(rowCount, colCount) }), _jsx(DropdownMenuSeparator, {}), EXPORT_FORMATS.map((format) => (_jsx(DropdownMenuItem, { onSelect: () => runExport(format), children: t.gridExportFormat(format) }, format))), extraFormats.length > 0 && _jsx(DropdownMenuSeparator, {}), extraFormats.map((format) => (_jsx(DropdownMenuItem, { onSelect: () => runExport(format.id), children: format.label }, format.id)))] })] }), toolbar] })] }), truncated && _jsx(TruncationBanner, { children: truncated }), editable && (_jsx(PendingChangesBar, { counts: counts, committing: committing, error: commitError?.message, errorRow: commitError?.row, note: primaryKeys.length === 0 ? t.gridNoPrimaryKey : null, onCommit: () => void commit(), onDiscard: () => {
|
|
499
|
+
setPending(NO_CHANGES);
|
|
500
|
+
setCommitError(null);
|
|
501
|
+
} })), body, footer && (_jsxs("section", { "aria-label": t.gridStatistics, className: "flex min-h-8 shrink-0 flex-wrap items-center gap-x-4 gap-y-1 border-t bg-muted/40 px-3 py-1 text-xs text-muted-foreground", children: [footer.kind === "column" ? (_jsxs(_Fragment, { children: [_jsx("span", { className: "font-medium text-foreground", children: footer.name }), _jsx(Stat, { label: t.gridStat("rows"), value: footer.stats.count.toLocaleString() }), _jsx(Stat, { label: t.gridStat("nulls"), value: footer.stats.nulls.toLocaleString() }), _jsx(Stat, { label: t.gridStat("distinct"), value: footer.stats.distinct.toLocaleString() }), footer.stats.min !== undefined && (_jsx(Stat, { label: t.gridStat("min"), value: formatStatValue(footer.stats.min) })), footer.stats.max !== undefined && (_jsx(Stat, { label: t.gridStat("max"), value: formatStatValue(footer.stats.max) })), footer.stats.numeric && (_jsxs(_Fragment, { children: [_jsx(Stat, { label: t.gridStat("avg"), value: formatStatValue(footer.stats.avg) }), _jsx(Stat, { label: t.gridStat("sum"), value: formatStatValue(footer.stats.sum) })] }))] })) : (_jsxs(_Fragment, { children: [_jsx("span", { className: "text-foreground tabular-nums", children: t.gridSelected(selectedCount) }), footer.summary ? (_jsxs(_Fragment, { children: [_jsx(Stat, { label: t.gridStat("sum"), value: formatStatValue(footer.summary.sum) }), _jsx(Stat, { label: t.gridStat("avg"), value: formatStatValue(footer.summary.avg) }), _jsx(Stat, { label: t.gridStat("min"), value: formatStatValue(footer.summary.min) }), _jsx(Stat, { label: t.gridStat("max"), value: formatStatValue(footer.summary.max) })] })) : (_jsx("span", { children: t.gridNoNumeric }))] })), _jsx(Button, { variant: "ghost", size: "icon-xs", className: "ml-auto", "aria-label": t.gridClearSelection, onClick: () => setSelection(EMPTY_SELECTION), children: _jsx(XIcon, { "aria-hidden": true }) })] })), detailCell && (_jsx(CellDetailDialog, { open: true, onOpenChange: (open) => !open && setCellDetail(null), column: names[detailCell.col] ?? "", value: valueAt(detailCell.row, detailCell.col) })), detailRow !== null && (_jsx(RowDetailDrawer, { open: true, onOpenChange: (open) => !open && setRowDetail(null), row: rowAt(detailRow), columns: columns, rowNumber: detailRow + 1, rowTotal: rowCount, onNavigate: (delta) => setRowDetail((prev) => prev === null ? prev : Math.min(Math.max(prev + delta, 0), rowCount - 1)) }))] }));
|
|
502
|
+
}
|
|
503
|
+
function Stat({ label, value }) {
|
|
504
|
+
return (_jsxs("span", { children: [label, ": ", _jsx("span", { className: "text-foreground tabular-nums", children: value })] }));
|
|
505
|
+
}
|
|
506
|
+
/** The input a cell edits in. Enter keeps and moves down, Tab keeps and moves across, Escape cancels. */
|
|
507
|
+
function CellEditor({ label, initial, numeric, onDone, }) {
|
|
508
|
+
const [text, setText] = React.useState(initial);
|
|
509
|
+
const done = React.useRef(false);
|
|
510
|
+
const finish = (value, move) => {
|
|
511
|
+
if (done.current)
|
|
512
|
+
return;
|
|
513
|
+
done.current = true;
|
|
514
|
+
onDone(value, move);
|
|
515
|
+
};
|
|
516
|
+
return (_jsx("input", {
|
|
517
|
+
// The editor exists because the person asked to edit: it takes focus.
|
|
518
|
+
// eslint-disable-next-line jsx-a11y/no-autofocus
|
|
519
|
+
autoFocus: true, "aria-label": label, value: text, inputMode: numeric ? "decimal" : undefined, onFocus: (event) => {
|
|
520
|
+
if (text === initial && initial.length > 1)
|
|
521
|
+
event.currentTarget.select();
|
|
522
|
+
}, onChange: (event) => setText(event.target.value), onKeyDown: (event) => {
|
|
523
|
+
if (event.key === "Enter") {
|
|
524
|
+
event.preventDefault();
|
|
525
|
+
finish(text, { row: event.shiftKey ? -1 : 1, col: 0 });
|
|
526
|
+
}
|
|
527
|
+
else if (event.key === "Escape") {
|
|
528
|
+
event.preventDefault();
|
|
529
|
+
finish(null, null);
|
|
530
|
+
}
|
|
531
|
+
else if (event.key === "Tab") {
|
|
532
|
+
event.preventDefault();
|
|
533
|
+
finish(text, { row: 0, col: event.shiftKey ? -1 : 1 });
|
|
534
|
+
}
|
|
535
|
+
}, onBlur: () => finish(text, null), className: "size-full bg-background px-2 font-mono text-xs ring-2 ring-ring outline-none ring-inset" }));
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* One column header: what the column is, how it sorts, and what can be done
|
|
539
|
+
* to it. The name selects the column; sorting is its own button, so a click
|
|
540
|
+
* never does both.
|
|
541
|
+
*/
|
|
542
|
+
function HeaderCell({ column, index, left, width, sort, selected, hiddenCount, activeValue, onSort, onSelect, onResize, onResizing, onAutoFit, onHide, onShowAll, onFilterByValue, onCopyName, }) {
|
|
543
|
+
const { messages } = useUI();
|
|
544
|
+
const t = messages.workbench;
|
|
545
|
+
const typeLabel = column.dataType ?? t.gridType(column.type);
|
|
546
|
+
function startDrag(event) {
|
|
547
|
+
if (event.button !== 0)
|
|
548
|
+
return;
|
|
549
|
+
event.preventDefault();
|
|
550
|
+
event.stopPropagation();
|
|
551
|
+
const handle = event.currentTarget;
|
|
552
|
+
handle.setPointerCapture(event.pointerId);
|
|
553
|
+
const startX = event.clientX;
|
|
554
|
+
let latest = width;
|
|
555
|
+
const move = (e) => {
|
|
556
|
+
latest = clampWidth(width + e.clientX - startX);
|
|
557
|
+
onResizing(latest);
|
|
558
|
+
};
|
|
559
|
+
const up = () => {
|
|
560
|
+
handle.removeEventListener("pointermove", move);
|
|
561
|
+
handle.removeEventListener("pointerup", up);
|
|
562
|
+
handle.removeEventListener("pointercancel", up);
|
|
563
|
+
onResize(latest);
|
|
564
|
+
};
|
|
565
|
+
handle.addEventListener("pointermove", move);
|
|
566
|
+
handle.addEventListener("pointerup", up);
|
|
567
|
+
handle.addEventListener("pointercancel", up);
|
|
568
|
+
}
|
|
569
|
+
return (_jsxs("div", { role: "columnheader", "aria-colindex": index + 2, "aria-sort": sort === "asc" ? "ascending" : sort === "desc" ? "descending" : undefined, "aria-selected": selected, className: cn("group absolute top-0 flex h-full items-center gap-1 overflow-hidden border-r border-b pr-2 pl-2 font-sans font-medium", selected ? "bg-primary/15 text-foreground" : "text-muted-foreground"), style: { left, width }, children: [_jsx(ColumnTypeIcon, { type: column.type }), _jsx("button", { type: "button", className: "min-w-0 flex-1 truncate rounded-sm text-left outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50", title: `${column.name} (${typeLabel}${column.nullable === false ? `, ${t.gridNotNull}` : ""})${column.references ? ` → ${column.references}` : ""}`, onClick: (event) => onSelect(event.metaKey || event.ctrlKey), onDoubleClick: onAutoFit, children: column.name }), column.nullable === true && (_jsx("span", { className: "shrink-0 text-muted-foreground", title: t.gridNullable, "aria-hidden": true, children: "?" })), _jsx(ColumnKeyIcons, { column: column }), _jsx("button", { type: "button", "aria-label": t.gridSortBy(column.name), className: cn("shrink-0 rounded-sm outline-none hover:text-foreground focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring/50", sort ? "text-foreground" : "opacity-40 group-hover:opacity-100"), onClick: () => onSort(sort === "asc" ? "desc" : sort === "desc" ? null : "asc"), children: sort === "asc" ? (_jsx(ArrowUpIcon, { "aria-hidden": true, className: "size-3" })) : sort === "desc" ? (_jsx(ArrowDownIcon, { "aria-hidden": true, className: "size-3" })) : (_jsx(ChevronsUpDownIcon, { "aria-hidden": true, className: "size-3" })) }), _jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { "aria-label": t.gridColumnMenu(column.name), className: "shrink-0 rounded-sm opacity-40 outline-none group-hover:opacity-100 hover:text-foreground focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring/50 aria-expanded:opacity-100", children: _jsx(EllipsisVerticalIcon, { "aria-hidden": true, className: "size-3" }) }), _jsxs(DropdownMenuContent, { className: "w-56", children: [_jsxs(DropdownMenuLabel, { className: "flex items-center gap-1.5 text-xs", children: [_jsx("span", { className: "truncate font-mono", children: column.name }), _jsx("span", { className: "ml-auto font-normal text-muted-foreground", children: typeLabel })] }), _jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuItem, { onSelect: () => onSort("asc"), children: [_jsx(ArrowUpIcon, { "aria-hidden": true }), t.gridSortAscending] }), _jsxs(DropdownMenuItem, { onSelect: () => onSort("desc"), children: [_jsx(ArrowDownIcon, { "aria-hidden": true }), t.gridSortDescending] }), sort && (_jsxs(DropdownMenuItem, { onSelect: () => onSort(null), children: [_jsx(XIcon, { "aria-hidden": true }), t.gridClearSort] })), _jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuItem, { disabled: activeValue === undefined, onSelect: () => activeValue !== undefined && onFilterByValue(activeValue), children: [_jsx(FunnelIcon, { "aria-hidden": true }), _jsx("span", { className: "truncate", children: t.gridFilterByValue(activeValue ?? null) })] }), _jsxs(DropdownMenuItem, { onSelect: onHide, children: [_jsx(EyeOffIcon, { "aria-hidden": true }), t.gridHideColumn] }), hiddenCount > 0 && (_jsxs(DropdownMenuItem, { onSelect: onShowAll, children: [_jsx(EyeIcon, { "aria-hidden": true }), t.gridShowAllColumns(hiddenCount)] })), _jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuItem, { onSelect: onCopyName, children: [_jsx(CopyIcon, { "aria-hidden": true }), t.gridCopyColumnName] })] })] }), _jsx("div", { role: "separator", tabIndex: 0, "aria-orientation": "vertical", "aria-label": t.gridResizeColumn(column.name), "aria-valuenow": width, "aria-valuemin": MIN_COL_W, "aria-valuemax": MAX_COL_W, onPointerDown: startDrag, onDoubleClick: onAutoFit, onKeyDown: (event) => {
|
|
570
|
+
const step = event.shiftKey ? RESIZE_STEP * 4 : RESIZE_STEP;
|
|
571
|
+
const next = event.key === "ArrowRight"
|
|
572
|
+
? width + step
|
|
573
|
+
: event.key === "ArrowLeft"
|
|
574
|
+
? width - step
|
|
575
|
+
: event.key === "Home"
|
|
576
|
+
? MIN_COL_W
|
|
577
|
+
: event.key === "End"
|
|
578
|
+
? MAX_COL_W
|
|
579
|
+
: event.key === "Enter"
|
|
580
|
+
? null
|
|
581
|
+
: undefined;
|
|
582
|
+
if (next === undefined)
|
|
583
|
+
return;
|
|
584
|
+
event.preventDefault();
|
|
585
|
+
if (next === null)
|
|
586
|
+
onAutoFit();
|
|
587
|
+
else
|
|
588
|
+
onResize(next);
|
|
589
|
+
}, className: "absolute top-0 right-0 h-full w-1.5 cursor-col-resize touch-none outline-none select-none hover:bg-primary/50 focus-visible:bg-ring" })] }));
|
|
590
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { type GridColumn, type GridRow } from "./grid-model.js";
|
|
3
|
+
export interface DataProfileProps {
|
|
4
|
+
columns: readonly (string | GridColumn)[];
|
|
5
|
+
rows: readonly GridRow[];
|
|
6
|
+
/** A note that the rows themselves are capped (the grid's truncation sentence). */
|
|
7
|
+
truncated?: string | null;
|
|
8
|
+
/** Rows profiled at most; the rest are left out and the banner says so. */
|
|
9
|
+
rowCap?: number;
|
|
10
|
+
className?: string;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* A card per column: completeness, distinct values, range, mean and median
|
|
14
|
+
* for numbers, and the most frequent values. Honest about its sample: a
|
|
15
|
+
* profile of the fetched rows is not a profile of the table.
|
|
16
|
+
*/
|
|
17
|
+
export declare function DataProfile(props: DataProfileProps): React.JSX.Element;
|