@adecore/database 0.17.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 +1 -1
- package/dist/DatabaseProvider.d.ts +4 -3
- package/dist/DatabaseProvider.js +15 -3
- package/dist/KeyIcon.d.ts +4 -0
- package/dist/KeyIcon.js +14 -0
- package/dist/actions.d.ts +12 -0
- package/dist/client-context.d.ts +3 -1
- package/dist/client-context.js +2 -0
- package/dist/code-text.d.ts +1 -0
- package/dist/code-text.js +2 -0
- package/dist/column-keys.d.ts +5 -0
- package/dist/column-keys.js +2 -0
- package/dist/connections/ConnectionManager.d.ts +3 -2
- package/dist/connections/ConnectionManager.js +27 -16
- package/dist/connections/connection-config.js +2 -0
- package/dist/console/QueryConsole.d.ts +8 -3
- package/dist/console/QueryConsole.js +110 -50
- package/dist/console/SqlEditor.d.ts +2 -12
- package/dist/console/SqlEditor.js +29 -18
- package/dist/console/StatementResultView.d.ts +5 -1
- package/dist/console/StatementResultView.js +18 -5
- package/dist/console/TransactionControls.js +3 -6
- package/dist/console/editor-slot.d.ts +18 -0
- package/dist/console/editor-slot.js +1 -0
- package/dist/console/result-keys.d.ts +15 -0
- package/dist/console/result-keys.js +79 -0
- package/dist/designer/ColumnsEditor.js +2 -2
- package/dist/designer/DesignerView.d.ts +3 -2
- package/dist/designer/DesignerView.js +5 -4
- package/dist/designer/SqlPreview.js +2 -1
- package/dist/designer/TableDesigner.d.ts +3 -2
- package/dist/designer/TableDesigner.js +2 -2
- package/dist/explorer/DatabaseExplorer.d.ts +4 -1
- package/dist/explorer/DatabaseExplorer.js +79 -23
- package/dist/explorer/RowMenu.js +2 -2
- package/dist/explorer/expansion.js +2 -2
- package/dist/explorer/folders.d.ts +16 -0
- package/dist/explorer/folders.js +1 -0
- package/dist/explorer/tree.d.ts +55 -4
- package/dist/explorer/tree.js +137 -23
- package/dist/grid/CellEditor.js +3 -1
- package/dist/grid/DataGrid.d.ts +2 -2
- package/dist/grid/DataGrid.js +61 -59
- package/dist/grid/EnumPicker.js +2 -1
- package/dist/grid/GridHeaderCell.d.ts +1 -2
- package/dist/grid/GridHeaderCell.js +12 -5
- package/dist/grid/GridRowView.d.ts +49 -0
- package/dist/grid/GridRowView.js +51 -0
- package/dist/grid/RecordView.d.ts +4 -2
- package/dist/grid/RecordView.js +71 -22
- package/dist/grid/ValueDock.d.ts +3 -1
- package/dist/grid/ValueDock.js +9 -3
- package/dist/grid/cell-id.d.ts +1 -0
- package/dist/grid/cell-id.js +2 -0
- package/dist/grid/layout.d.ts +3 -2
- package/dist/grid/layout.js +15 -6
- package/dist/grid/types.d.ts +8 -1
- package/dist/grid/types.js +4 -3
- package/dist/index.d.ts +5 -1
- package/dist/index.js +2 -0
- package/dist/locales/en.json +32 -15
- package/dist/locales/nl.json +32 -15
- package/dist/protocol/index.d.ts +1 -1
- package/dist/protocol/messages.d.ts +1 -1
- package/dist/protocol/messages.js +1 -1
- package/dist/protocol/schema.d.ts +17 -0
- package/dist/structure/StructureView.d.ts +5 -3
- package/dist/structure/StructureView.js +8 -6
- package/dist/table/CommandField.js +2 -2
- package/dist/table/TableStatusBar.js +2 -2
- package/dist/table/TableToolbar.d.ts +3 -4
- package/dist/table/TableToolbar.js +4 -3
- package/dist/table/TableView.d.ts +4 -2
- package/dist/table/TableView.js +63 -91
- package/dist/table/grid-rows.js +4 -1
- package/dist/table/useTableTransfer.d.ts +3 -1
- package/dist/table/useTableTransfer.js +19 -5
- package/dist/testing/fake.d.ts +3 -1
- package/dist/testing/fake.js +7 -1
- package/dist/value/ValueEditor.d.ts +9 -0
- package/dist/value/ValueEditor.js +102 -0
- package/dist/value/ValuePanel.d.ts +1 -1
- package/dist/value/ValuePanel.js +3 -94
- package/dist/workbench/DatabaseWorkbench.js +4 -7
- package/package.json +6 -6
- package/src/DatabaseProvider.tsx +24 -5
- package/src/KeyIcon.tsx +15 -0
- package/src/actions.ts +28 -2
- package/src/client-context.ts +5 -1
- package/src/code-text.ts +2 -0
- package/src/column-keys.ts +8 -0
- package/src/connections/ConnectionManager.tsx +98 -68
- package/src/connections/connection-config.ts +2 -0
- package/src/console/QueryConsole.tsx +251 -150
- package/src/console/SqlEditor.tsx +33 -37
- package/src/console/StatementResultView.tsx +61 -27
- package/src/console/TransactionControls.tsx +8 -11
- package/src/console/editor-slot.ts +27 -0
- package/src/console/result-keys.ts +109 -0
- package/src/designer/ColumnsEditor.tsx +3 -3
- package/src/designer/DesignerView.tsx +14 -3
- package/src/designer/SqlPreview.tsx +2 -1
- package/src/designer/TableDesigner.tsx +5 -2
- package/src/explorer/DatabaseExplorer.tsx +170 -26
- package/src/explorer/RowMenu.tsx +2 -2
- package/src/explorer/expansion.ts +4 -4
- package/src/explorer/folders.ts +25 -0
- package/src/explorer/tree.ts +219 -28
- package/src/grid/CellEditor.tsx +3 -2
- package/src/grid/DataGrid.tsx +114 -176
- package/src/grid/EnumPicker.tsx +2 -1
- package/src/grid/GridHeaderCell.tsx +14 -9
- package/src/grid/GridRowView.tsx +184 -0
- package/src/grid/RecordView.tsx +188 -75
- package/src/grid/ValueDock.tsx +12 -3
- package/src/grid/cell-id.ts +2 -0
- package/src/grid/layout.ts +16 -6
- package/src/grid/types.ts +15 -3
- package/src/index.ts +5 -1
- package/src/locales/en.json +32 -15
- package/src/locales/nl.json +32 -15
- package/src/protocol/index.ts +13 -1
- package/src/protocol/messages.ts +1 -1
- package/src/protocol/schema.ts +28 -0
- package/src/structure/StructureView.tsx +16 -6
- package/src/table/CommandField.tsx +2 -2
- package/src/table/TableStatusBar.tsx +3 -3
- package/src/table/TableToolbar.tsx +107 -92
- package/src/table/TableView.tsx +95 -132
- package/src/table/grid-rows.ts +5 -3
- package/src/table/useTableTransfer.ts +21 -6
- package/src/testing/fake.ts +18 -1
- package/src/value/ValueEditor.tsx +213 -0
- package/src/value/ValuePanel.tsx +4 -206
- package/src/workbench/DatabaseWorkbench.tsx +9 -10
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { memo, type MouseEvent, type PointerEvent } from 'react';
|
|
2
|
+
import clsx from 'clsx';
|
|
3
|
+
import { Spinner } from '@adecore/ui';
|
|
4
|
+
import { formatNumber } from '@adecore/ui/format';
|
|
5
|
+
import { CODE_TEXT } from '../code-text.ts';
|
|
6
|
+
import { CellEditor, type EditMove } from './CellEditor.tsx';
|
|
7
|
+
import { cellIdOf } from './cell-id.ts';
|
|
8
|
+
import { cellView, type CellView, type NumberNotation } from './display.ts';
|
|
9
|
+
import type { EnumType } from './enum-type.ts';
|
|
10
|
+
import { EnumPicker } from './EnumPicker.tsx';
|
|
11
|
+
import { ROW_HEIGHT } from './layout.ts';
|
|
12
|
+
import type { CellPosition } from './navigation.ts';
|
|
13
|
+
import type { GridColumn, GridRow } from './types.ts';
|
|
14
|
+
|
|
15
|
+
/*
|
|
16
|
+
* The gutter and the pinned columns stick as one block, so the edge of one can never drift from the next, at any
|
|
17
|
+
* zoom. Each part paints its background under its own line as well: the line is an alpha, and what scrolls beneath
|
|
18
|
+
* the block would otherwise show through it.
|
|
19
|
+
*/
|
|
20
|
+
export const PINNED_GROUP = 'sticky left-0 z-10 flex shrink-0';
|
|
21
|
+
|
|
22
|
+
const TONE: Record<CellView['tone'], string> = {
|
|
23
|
+
value: '',
|
|
24
|
+
null: 'text-text-faint',
|
|
25
|
+
default: 'text-text-faint italic'
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const ROW_STATE = {
|
|
29
|
+
inserted: 'bg-positive/10',
|
|
30
|
+
deleted: 'bg-status-error/10 text-text-faint'
|
|
31
|
+
} as const;
|
|
32
|
+
|
|
33
|
+
export interface CellEdit extends CellPosition {
|
|
34
|
+
draft: string;
|
|
35
|
+
initial: string;
|
|
36
|
+
loading: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/* What every row draws the same way. It changes when the columns or their layout do, never on a scroll. */
|
|
40
|
+
export interface RowLook {
|
|
41
|
+
readonly gridId: string;
|
|
42
|
+
readonly columns: readonly GridColumn[];
|
|
43
|
+
/* Column indexes in the order they are drawn. */
|
|
44
|
+
readonly order: readonly number[];
|
|
45
|
+
/* By drawn position. */
|
|
46
|
+
readonly widths: readonly number[];
|
|
47
|
+
readonly pinnedShown: number;
|
|
48
|
+
readonly gutter: number;
|
|
49
|
+
readonly notation: NumberNotation;
|
|
50
|
+
readonly enumTypes: readonly (EnumType | null)[];
|
|
51
|
+
/* Column indexes picked whole. */
|
|
52
|
+
readonly picked: ReadonlySet<number>;
|
|
53
|
+
readonly loadingLabel: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/* What a row hands back to the grid; every function keeps its identity, so a row that did not change is not drawn again. */
|
|
57
|
+
export interface RowActions {
|
|
58
|
+
pressCell(event: PointerEvent, position: CellPosition, isEditing: boolean): void;
|
|
59
|
+
dragTo(position: CellPosition): void;
|
|
60
|
+
beginEdit(position: CellPosition): void;
|
|
61
|
+
openCellMenu(position: CellPosition): void;
|
|
62
|
+
pressRowNumber(index: number, event: MouseEvent): void;
|
|
63
|
+
openRowMenu(index: number): void;
|
|
64
|
+
finishPick(result: { readonly value: string | null } | undefined): void;
|
|
65
|
+
finishEdit(commit: boolean, move?: EditMove): void;
|
|
66
|
+
changeDraft(draft: string): void;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface GridRowViewProps {
|
|
70
|
+
row: GridRow;
|
|
71
|
+
index: number;
|
|
72
|
+
selected: boolean;
|
|
73
|
+
/* The column index of the focused cell when it is in this row, else -1. */
|
|
74
|
+
focusedColumn: number;
|
|
75
|
+
/* The drawn positions a cell range covers in this row, or -1 for both when it covers none. */
|
|
76
|
+
rangeStart: number;
|
|
77
|
+
rangeEnd: number;
|
|
78
|
+
/* The edit under way when it is in this row. */
|
|
79
|
+
editing: CellEdit | null;
|
|
80
|
+
look: RowLook;
|
|
81
|
+
actions: RowActions;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/* One row of the grid. Memoized on props that only change for this row, so a scroll draws the rows that came into view and none other. */
|
|
85
|
+
export const GridRowView = memo(function GridRowView({ row, index, selected, focusedColumn, rangeStart, rangeEnd, editing, look, actions }: GridRowViewProps) {
|
|
86
|
+
const { columns, order, widths, pinnedShown, gutter, notation, enumTypes, picked } = look;
|
|
87
|
+
const tint = row.state === undefined ? (selected ? 'bg-accent-soft/40' : '') : ROW_STATE[row.state];
|
|
88
|
+
const cells = order.map((columnIndex, position) => {
|
|
89
|
+
const column = columns[columnIndex]!;
|
|
90
|
+
const cell = row.cells[columnIndex] ?? null;
|
|
91
|
+
const view = cellView(cell, column.kind, notation);
|
|
92
|
+
const cellPosition = { row: index, column: columnIndex };
|
|
93
|
+
const isFocused = focusedColumn === columnIndex;
|
|
94
|
+
const ranged = picked.has(columnIndex) || (position >= rangeStart && position <= rangeEnd && rangeStart >= 0);
|
|
95
|
+
const edited = row.edited?.has(columnIndex) === true;
|
|
96
|
+
const isEditing = editing !== null && editing.column === columnIndex;
|
|
97
|
+
const pinned = position < pinnedShown;
|
|
98
|
+
const enumType = enumTypes[columnIndex] ?? null;
|
|
99
|
+
return (
|
|
100
|
+
<div
|
|
101
|
+
key={columnIndex}
|
|
102
|
+
id={cellIdOf(look.gridId, index, columnIndex)}
|
|
103
|
+
role="gridcell"
|
|
104
|
+
aria-colindex={position + 2}
|
|
105
|
+
aria-selected={ranged}
|
|
106
|
+
data-focused={isFocused ? '' : undefined}
|
|
107
|
+
data-ranged={ranged ? '' : undefined}
|
|
108
|
+
className={clsx(
|
|
109
|
+
CODE_TEXT,
|
|
110
|
+
'flex h-full shrink-0 items-center overflow-hidden px-3 whitespace-nowrap outline-0 select-none data-[focused]:outline-1 data-[focused]:-outline-offset-1 data-[focused]:outline-border-strong group-focus-within/grid:data-[focused]:outline-accent',
|
|
111
|
+
position === order.length - 1 ? 'border-r-0' : 'border-r',
|
|
112
|
+
view.align === 'end' ? 'justify-end tabular-nums' : 'justify-start',
|
|
113
|
+
pinned
|
|
114
|
+
? [
|
|
115
|
+
'relative bg-clip-border',
|
|
116
|
+
position === pinnedShown - 1 ? 'border-border-strong' : 'border-border-soft',
|
|
117
|
+
ranged ? 'bg-accent-soft' : ['bg-surface', tint === '' && 'group-hover/row:bg-surface-hover']
|
|
118
|
+
]
|
|
119
|
+
: ['relative border-border-soft', ranged ? 'bg-accent-soft' : edited && 'bg-accent/10']
|
|
120
|
+
)}
|
|
121
|
+
style={{ width: widths[position] }}
|
|
122
|
+
onPointerDown={(event) => actions.pressCell(event, cellPosition, isEditing)}
|
|
123
|
+
onPointerEnter={() => actions.dragTo(cellPosition)}
|
|
124
|
+
onDoubleClick={() => actions.beginEdit(cellPosition)}
|
|
125
|
+
onContextMenu={() => actions.openCellMenu(cellPosition)}
|
|
126
|
+
>
|
|
127
|
+
{pinned && !ranged && <span aria-hidden className={clsx('pointer-events-none absolute inset-0', tint, edited && 'bg-accent/10')} />}
|
|
128
|
+
{isEditing && editing.loading && <Spinner size={12} label={look.loadingLabel} />}
|
|
129
|
+
{isEditing && !editing.loading && enumType !== null && (
|
|
130
|
+
<EnumPicker
|
|
131
|
+
autoOpen
|
|
132
|
+
type={enumType}
|
|
133
|
+
value={typeof cell === 'string' ? cell : null}
|
|
134
|
+
nullable={column.nullable !== false}
|
|
135
|
+
label={column.name}
|
|
136
|
+
onDone={actions.finishPick}
|
|
137
|
+
/>
|
|
138
|
+
)}
|
|
139
|
+
{isEditing && !editing.loading && enumType === null && (
|
|
140
|
+
<CellEditor
|
|
141
|
+
value={editing.draft}
|
|
142
|
+
label={column.name}
|
|
143
|
+
onValueChange={actions.changeDraft}
|
|
144
|
+
onCommit={(move) => actions.finishEdit(true, move)}
|
|
145
|
+
onCancel={() => actions.finishEdit(false)}
|
|
146
|
+
/>
|
|
147
|
+
)}
|
|
148
|
+
{!isEditing && (
|
|
149
|
+
<>
|
|
150
|
+
<span className={clsx('min-w-0 truncate', TONE[view.tone], row.state === 'deleted' && 'line-through')}>{view.text}</span>
|
|
151
|
+
{view.suffix !== undefined && <span className="ml-2 shrink-0 text-text-faint">{view.suffix}</span>}
|
|
152
|
+
</>
|
|
153
|
+
)}
|
|
154
|
+
</div>
|
|
155
|
+
);
|
|
156
|
+
});
|
|
157
|
+
return (
|
|
158
|
+
<div
|
|
159
|
+
role="row"
|
|
160
|
+
aria-rowindex={index + 2}
|
|
161
|
+
aria-selected={selected}
|
|
162
|
+
className={clsx('group/row absolute inset-x-0 flex border-b border-border-soft', tint, tint === '' && 'hover:bg-surface-hover')}
|
|
163
|
+
style={{ top: index * ROW_HEIGHT, height: ROW_HEIGHT }}
|
|
164
|
+
>
|
|
165
|
+
<div className={PINNED_GROUP}>
|
|
166
|
+
<div
|
|
167
|
+
role="rowheader"
|
|
168
|
+
data-selected={selected ? '' : undefined}
|
|
169
|
+
className={clsx(
|
|
170
|
+
'flex shrink-0 cursor-default items-center justify-end border-r border-border bg-surface bg-clip-border pr-2 pl-1 font-mono text-xs text-text-faint tabular-nums select-none data-[selected]:bg-accent-soft data-[selected]:text-text',
|
|
171
|
+
row.state === 'inserted' && 'text-positive'
|
|
172
|
+
)}
|
|
173
|
+
style={{ width: gutter }}
|
|
174
|
+
onClick={(event) => actions.pressRowNumber(index, event)}
|
|
175
|
+
onContextMenu={() => actions.openRowMenu(index)}
|
|
176
|
+
>
|
|
177
|
+
{row.number === null ? '+' : formatNumber(row.number)}
|
|
178
|
+
</div>
|
|
179
|
+
{cells.slice(0, pinnedShown)}
|
|
180
|
+
</div>
|
|
181
|
+
{cells.slice(pinnedShown)}
|
|
182
|
+
</div>
|
|
183
|
+
);
|
|
184
|
+
});
|
package/src/grid/RecordView.tsx
CHANGED
|
@@ -1,35 +1,41 @@
|
|
|
1
|
-
import { useMemo, useRef, useState, type KeyboardEvent, type Ref } from 'react';
|
|
1
|
+
import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type Ref } from 'react';
|
|
2
2
|
import clsx from 'clsx';
|
|
3
|
-
import { ArrowUpRight, Ban, ChevronDown, ChevronUp, Copy, RotateCcw } from 'lucide-react';
|
|
3
|
+
import { ArrowUpRight, Ban, ChevronDown, ChevronUp, Copy, Maximize2, Minimize2, RotateCcw, X } from 'lucide-react';
|
|
4
4
|
import { useTranslation } from 'react-i18next';
|
|
5
|
-
import { ContextMenu, copyText, EDIT_SHORTCUTS, Icon, IconButton, Input, isApplePlatform, isModHeld, Kbd, shortcut, Spinner } from '@adecore/ui';
|
|
5
|
+
import { ButtonGroup, ContextMenu, copyText, EDIT_SHORTCUTS, Icon, IconButton, Input, isApplePlatform, isModHeld, Kbd, shortcut, Spinner } from '@adecore/ui';
|
|
6
6
|
import { formatNumber } from '@adecore/ui/format';
|
|
7
7
|
import { useNumberNotation } from '../client-context.ts';
|
|
8
|
+
import { CODE_TEXT } from '../code-text.ts';
|
|
8
9
|
import type { EditValue, Value } from '../protocol/index.ts';
|
|
10
|
+
import { ValueEditor } from '../value/ValueEditor.tsx';
|
|
9
11
|
import { cellView, copyTextOf, isNumericKind, isPreview, numeralText, type NumberNotation } from './display.ts';
|
|
10
12
|
import { draftOf, parseDraft } from './edit-value.ts';
|
|
11
13
|
import { parseEnumType } from './enum-type.ts';
|
|
12
14
|
import { EnumPicker } from './EnumPicker.tsx';
|
|
15
|
+
import { wholeValueOf } from './focused-value.ts';
|
|
13
16
|
import type { FocusedCell, GridColumn, GridRow } from './types.ts';
|
|
14
17
|
|
|
15
18
|
export interface RecordViewProps {
|
|
16
19
|
columns: readonly GridColumn[];
|
|
17
20
|
rows: readonly GridRow[];
|
|
18
|
-
/* The row shown, an index into `rows
|
|
21
|
+
/* The row shown, an index into `rows`; -1 shows none. */
|
|
19
22
|
index: number;
|
|
20
23
|
onIndexChange(index: number): void;
|
|
24
|
+
/* The column of the grid's focused cell when it is in this row, which the view marks and keeps in sight. */
|
|
25
|
+
focusedColumn: number | null;
|
|
21
26
|
/* Lets a field be edited; the rows that are `locked` stay as they are. */
|
|
22
27
|
editable?: boolean;
|
|
23
28
|
onCommit?(rowKey: string, column: number, value: EditValue): void;
|
|
24
29
|
/* The whole value of a field that is only a preview. Resolves `undefined` when it cannot be had. */
|
|
25
30
|
loadValue?(rowKey: string, column: number): Promise<Value | undefined>;
|
|
26
|
-
/*
|
|
27
|
-
onFocusedCellChange?(cell: FocusedCell
|
|
31
|
+
/* A field took the focus. */
|
|
32
|
+
onFocusedCellChange?(cell: FocusedCell): void;
|
|
28
33
|
onFollow?(cell: FocusedCell): void;
|
|
29
34
|
canFollow?(cell: FocusedCell): boolean;
|
|
35
|
+
onClose(): void;
|
|
30
36
|
/* The accessible name of the list of fields. */
|
|
31
37
|
label: string;
|
|
32
|
-
/* Drawn while there
|
|
38
|
+
/* Drawn while there is no row. */
|
|
33
39
|
empty?: string;
|
|
34
40
|
className?: string;
|
|
35
41
|
ref?: Ref<HTMLDivElement>;
|
|
@@ -59,22 +65,28 @@ const textOf = (cell: GridRow['cells'][number], kind: GridColumn['kind'], notati
|
|
|
59
65
|
return draftOf(cell);
|
|
60
66
|
};
|
|
61
67
|
|
|
68
|
+
/* A value that a field is too small for: a long text, JSON or binary, which opens up in place to the whole of it. */
|
|
69
|
+
const opensUp = (cell: GridRow['cells'][number], kind: GridColumn['kind']): boolean =>
|
|
70
|
+
kind === 'json' || kind === 'binary' || isPreview(cell) || (typeof cell === 'string' && cell.includes('\n'));
|
|
71
|
+
|
|
62
72
|
/*
|
|
63
|
-
*
|
|
64
|
-
* to the same `onCommit` the grid uses, so they pile up as pending changes there. Up and Down move
|
|
65
|
-
* between the fields; with Mod
|
|
73
|
+
* The row of the grid's focused cell as a list of fields, beside the grid. It follows the grid's focus, and
|
|
74
|
+
* edits go to the same `onCommit` the grid uses, so they pile up as pending changes there. Up and Down move
|
|
75
|
+
* between the fields, Tab too; with Mod, Up and Down move between the rows.
|
|
66
76
|
*/
|
|
67
77
|
export function RecordView({
|
|
68
78
|
columns,
|
|
69
79
|
rows,
|
|
70
80
|
index,
|
|
71
81
|
onIndexChange,
|
|
82
|
+
focusedColumn,
|
|
72
83
|
editable = false,
|
|
73
84
|
onCommit,
|
|
74
85
|
loadValue,
|
|
75
86
|
onFocusedCellChange,
|
|
76
87
|
onFollow,
|
|
77
88
|
canFollow,
|
|
89
|
+
onClose,
|
|
78
90
|
label,
|
|
79
91
|
empty,
|
|
80
92
|
className,
|
|
@@ -83,11 +95,19 @@ export function RecordView({
|
|
|
83
95
|
const { t } = useTranslation('database');
|
|
84
96
|
const notation = useNumberNotation();
|
|
85
97
|
const fields = useRef<(HTMLElement | null)[]>([]);
|
|
98
|
+
const blocks = useRef<(HTMLElement | null)[]>([]);
|
|
86
99
|
const [editing, setEditing] = useState<Editing | null>(null);
|
|
87
|
-
const
|
|
100
|
+
const [opened, setOpened] = useState<ReadonlySet<number>>(() => new Set());
|
|
101
|
+
const row = index >= 0 ? rows[index] : undefined;
|
|
88
102
|
const enumTypes = useMemo(() => columns.map((column) => parseEnumType(column.type)), [columns]);
|
|
89
103
|
const apple = isApplePlatform();
|
|
90
104
|
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
if (focusedColumn !== null) {
|
|
107
|
+
blocks.current[focusedColumn]?.scrollIntoView({ block: 'nearest' });
|
|
108
|
+
}
|
|
109
|
+
}, [focusedColumn, row?.key]);
|
|
110
|
+
|
|
91
111
|
const canEdit = (column: number): boolean =>
|
|
92
112
|
editable && row !== undefined && row.locked !== true && row.state !== 'deleted' && columns[column]?.readOnly !== true;
|
|
93
113
|
|
|
@@ -131,8 +151,19 @@ export function RecordView({
|
|
|
131
151
|
);
|
|
132
152
|
};
|
|
133
153
|
|
|
134
|
-
const
|
|
135
|
-
|
|
154
|
+
const toggleOpened = (column: number): void => {
|
|
155
|
+
setOpened((now) => {
|
|
156
|
+
const next = new Set(now);
|
|
157
|
+
if (!next.delete(column)) {
|
|
158
|
+
next.add(column);
|
|
159
|
+
}
|
|
160
|
+
return next;
|
|
161
|
+
});
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const moveRow = (to: number): void => {
|
|
165
|
+
commitEdit();
|
|
166
|
+
onIndexChange(Math.max(0, Math.min(to, rows.length - 1)));
|
|
136
167
|
};
|
|
137
168
|
|
|
138
169
|
const handleKeyDown = (event: KeyboardEvent<HTMLElement>, column: number): void => {
|
|
@@ -144,10 +175,9 @@ export function RecordView({
|
|
|
144
175
|
event.preventDefault();
|
|
145
176
|
const direction = event.key === 'ArrowUp' ? -1 : 1;
|
|
146
177
|
if (mod) {
|
|
147
|
-
|
|
148
|
-
onIndexChange(Math.max(0, Math.min(index + direction, rows.length - 1)));
|
|
178
|
+
moveRow(index + direction);
|
|
149
179
|
} else {
|
|
150
|
-
|
|
180
|
+
fields.current[Math.max(0, Math.min(column + direction, columns.length - 1))]?.focus();
|
|
151
181
|
}
|
|
152
182
|
} else if (event.key === 'Enter') {
|
|
153
183
|
event.preventDefault();
|
|
@@ -162,34 +192,37 @@ export function RecordView({
|
|
|
162
192
|
const cellOf = (column: number): FocusedCell | null => (row === undefined ? null : { rowKey: row.key, column });
|
|
163
193
|
|
|
164
194
|
return (
|
|
165
|
-
<div ref={ref} className={clsx('flex min-h-0 flex-1 flex-col bg-surface', className)}>
|
|
166
|
-
<div className="flex h-10 shrink-0 items-center gap-
|
|
167
|
-
<
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
195
|
+
<div ref={ref} className={clsx('flex min-h-0 min-w-0 flex-1 flex-col bg-surface', className)}>
|
|
196
|
+
<div className="flex h-10 shrink-0 items-center gap-1 border-b border-border px-2">
|
|
197
|
+
<ButtonGroup>
|
|
198
|
+
<IconButton
|
|
199
|
+
icon={ChevronUp}
|
|
200
|
+
size="sm"
|
|
201
|
+
label={t('table.record.previous')}
|
|
202
|
+
kbd={PREVIOUS_ROW}
|
|
203
|
+
disabled={row === undefined || index <= 0}
|
|
204
|
+
onClick={() => moveRow(index - 1)}
|
|
205
|
+
/>
|
|
206
|
+
<IconButton
|
|
207
|
+
icon={ChevronDown}
|
|
208
|
+
size="sm"
|
|
209
|
+
label={t('table.record.next')}
|
|
210
|
+
kbd={NEXT_ROW}
|
|
211
|
+
disabled={row === undefined || index >= rows.length - 1}
|
|
212
|
+
onClick={() => moveRow(index + 1)}
|
|
213
|
+
/>
|
|
214
|
+
</ButtonGroup>
|
|
183
215
|
{row !== undefined && (
|
|
184
|
-
|
|
185
|
-
<span className="text-sm font-medium text-text tabular-nums">
|
|
216
|
+
<span className="flex min-w-0 items-baseline gap-2 px-1">
|
|
217
|
+
<span className="truncate text-sm font-medium text-text tabular-nums">
|
|
186
218
|
{row.number === null ? t('table.record.newRow') : t('table.record.row', { number: formatNumber(row.number) })}
|
|
187
219
|
</span>
|
|
188
|
-
<span className="text-xs text-text-faint tabular-nums">
|
|
220
|
+
<span className="truncate text-xs text-text-faint tabular-nums">
|
|
189
221
|
{t('table.record.position', { index: formatNumber(index + 1), count: formatNumber(rows.length) })}
|
|
190
222
|
</span>
|
|
191
|
-
|
|
223
|
+
</span>
|
|
192
224
|
)}
|
|
225
|
+
<IconButton icon={X} size="sm" label={t('table.record.close')} className="ml-auto" onClick={onClose} />
|
|
193
226
|
</div>
|
|
194
227
|
{row === undefined ? (
|
|
195
228
|
<p className="px-4 py-6 text-xs text-text-muted">{empty}</p>
|
|
@@ -205,38 +238,75 @@ export function RecordView({
|
|
|
205
238
|
const followable = cellKey !== null && onFollow !== undefined && (canFollow?.(cellKey) ?? true);
|
|
206
239
|
const nullable = column.nullable !== false;
|
|
207
240
|
const placeholder = cell === null ? 'NULL' : typeof cell === 'object' && cell.kind === 'default' ? 'DEFAULT' : undefined;
|
|
241
|
+
const expandable = enumType === null && opensUp(cell, column.kind);
|
|
242
|
+
const expanded = expandable && opened.has(position);
|
|
208
243
|
return (
|
|
209
244
|
<ContextMenu.Root key={position}>
|
|
210
245
|
<ContextMenu.Trigger
|
|
211
|
-
|
|
212
|
-
|
|
246
|
+
ref={(node: HTMLElement | null) => {
|
|
247
|
+
blocks.current[position] = node;
|
|
248
|
+
}}
|
|
249
|
+
data-focused={focusedColumn === position ? '' : undefined}
|
|
250
|
+
className={clsx(
|
|
251
|
+
'flex flex-col gap-1 border-l-2 border-transparent px-3 py-1.5 data-[focused]:border-accent data-[focused]:bg-accent-soft/40',
|
|
252
|
+
row.edited?.has(position) && 'bg-accent/10'
|
|
253
|
+
)}
|
|
254
|
+
onFocusCapture={() => cellKey !== null && onFocusedCellChange?.(cellKey)}
|
|
213
255
|
>
|
|
214
|
-
<span className="flex w-
|
|
215
|
-
<span className=
|
|
216
|
-
{column.type !== '' && <span className="truncate text-xs text-text-faint">{column.type}</span>}
|
|
256
|
+
<span className="flex min-w-0 items-center gap-2">
|
|
257
|
+
<span className={clsx(CODE_TEXT, 'min-w-0 truncate text-text')}>{column.name}</span>
|
|
258
|
+
{column.type !== '' && <span className="min-w-0 truncate text-xs text-text-faint">{column.type}</span>}
|
|
259
|
+
<ButtonGroup className="ml-auto shrink-0">
|
|
260
|
+
{followable && (
|
|
261
|
+
<IconButton
|
|
262
|
+
icon={ArrowUpRight}
|
|
263
|
+
size="xs"
|
|
264
|
+
label={t('grid.followReference')}
|
|
265
|
+
onClick={() => cellKey !== null && onFollow?.(cellKey)}
|
|
266
|
+
/>
|
|
267
|
+
)}
|
|
268
|
+
{expandable && (
|
|
269
|
+
<IconButton
|
|
270
|
+
icon={expanded ? Minimize2 : Maximize2}
|
|
271
|
+
size="xs"
|
|
272
|
+
label={t(expanded ? 'table.record.collapse' : 'table.record.expand')}
|
|
273
|
+
aria-expanded={expanded}
|
|
274
|
+
onClick={() => toggleOpened(position)}
|
|
275
|
+
/>
|
|
276
|
+
)}
|
|
277
|
+
</ButtonGroup>
|
|
217
278
|
</span>
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
279
|
+
{expanded ? (
|
|
280
|
+
<WholeValue
|
|
281
|
+
key={`${row.key}:${position}`}
|
|
282
|
+
row={row}
|
|
283
|
+
column={position}
|
|
284
|
+
columns={columns}
|
|
285
|
+
editable={editableField}
|
|
286
|
+
loadValue={loadValue}
|
|
287
|
+
onCommit={(value) => commitValue(position, value)}
|
|
288
|
+
/>
|
|
289
|
+
) : enumType !== null ? (
|
|
290
|
+
<EnumPicker
|
|
291
|
+
look="field"
|
|
292
|
+
type={enumType}
|
|
293
|
+
value={typeof cell === 'string' ? cell : null}
|
|
294
|
+
nullable={nullable}
|
|
295
|
+
label={column.name}
|
|
296
|
+
disabled={!editableField}
|
|
297
|
+
ref={(node) => {
|
|
298
|
+
fields.current[position] = node;
|
|
299
|
+
}}
|
|
300
|
+
onDone={(result) => {
|
|
301
|
+
if (result !== undefined && result.value !== (typeof cell === 'string' ? cell : null)) {
|
|
302
|
+
commitValue(position, result.value);
|
|
303
|
+
}
|
|
304
|
+
}}
|
|
305
|
+
/>
|
|
306
|
+
) : (
|
|
307
|
+
<span className="relative flex min-w-0 items-center">
|
|
237
308
|
<Input
|
|
238
309
|
size="sm"
|
|
239
|
-
mono
|
|
240
310
|
spellCheck={false}
|
|
241
311
|
aria-label={column.name}
|
|
242
312
|
ref={(node) => {
|
|
@@ -245,23 +315,15 @@ export function RecordView({
|
|
|
245
315
|
value={active ? editing.draft : textOf(cell, column.kind, notation, !editableField)}
|
|
246
316
|
placeholder={placeholder}
|
|
247
317
|
readOnly={!editableField || (preview && (!active || editing.loading))}
|
|
248
|
-
className={clsx('min-w-0 flex-1', row.state === 'deleted' && 'line-through')}
|
|
318
|
+
className={clsx(CODE_TEXT, 'min-w-0 flex-1', row.state === 'deleted' && 'line-through')}
|
|
249
319
|
onFocus={() => void beginEdit(position)}
|
|
250
320
|
onBlur={commitEdit}
|
|
251
321
|
onChange={(event) => changeDraft(position, event.target.value)}
|
|
252
322
|
onKeyDown={(event) => handleKeyDown(event, position)}
|
|
253
323
|
/>
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
<IconButton
|
|
258
|
-
icon={ArrowUpRight}
|
|
259
|
-
size="xs"
|
|
260
|
-
label={t('grid.followReference')}
|
|
261
|
-
onClick={() => cellKey !== null && onFollow?.(cellKey)}
|
|
262
|
-
/>
|
|
263
|
-
)}
|
|
264
|
-
</span>
|
|
324
|
+
{active && editing.loading && <Spinner size={12} label={t('grid.loadingValue')} className="absolute right-2" />}
|
|
325
|
+
</span>
|
|
326
|
+
)}
|
|
265
327
|
</ContextMenu.Trigger>
|
|
266
328
|
<ContextMenu.Popup>
|
|
267
329
|
<ContextMenu.Item onClick={() => copyText(copyTextOf(cell))}>
|
|
@@ -291,3 +353,54 @@ export function RecordView({
|
|
|
291
353
|
</div>
|
|
292
354
|
);
|
|
293
355
|
}
|
|
356
|
+
|
|
357
|
+
/* A field opened up to its whole value, fetched with `loadValue` when the row holds only a preview. */
|
|
358
|
+
function WholeValue({
|
|
359
|
+
row,
|
|
360
|
+
column,
|
|
361
|
+
columns,
|
|
362
|
+
editable,
|
|
363
|
+
loadValue,
|
|
364
|
+
onCommit
|
|
365
|
+
}: {
|
|
366
|
+
row: GridRow;
|
|
367
|
+
column: number;
|
|
368
|
+
columns: readonly GridColumn[];
|
|
369
|
+
editable: boolean;
|
|
370
|
+
loadValue?(rowKey: string, column: number): Promise<Value | undefined>;
|
|
371
|
+
onCommit(value: EditValue): void;
|
|
372
|
+
}) {
|
|
373
|
+
const { t } = useTranslation('database');
|
|
374
|
+
const cell = row.cells[column] ?? null;
|
|
375
|
+
const known = wholeValueOf(cell);
|
|
376
|
+
const [fetched, setFetched] = useState<{ cell: unknown; value: Value | undefined } | null>(null);
|
|
377
|
+
const value = known ?? (fetched !== null && fetched.cell === cell ? fetched.value : undefined);
|
|
378
|
+
const waiting = known === undefined && (fetched === null || fetched.cell !== cell);
|
|
379
|
+
|
|
380
|
+
useEffect(() => {
|
|
381
|
+
if (known !== undefined || loadValue === undefined) {
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
let live = true;
|
|
385
|
+
void loadValue(row.key, column).then((whole) => {
|
|
386
|
+
if (live) {
|
|
387
|
+
setFetched({ cell, value: whole });
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
return () => {
|
|
391
|
+
live = false;
|
|
392
|
+
};
|
|
393
|
+
}, [known, loadValue, row.key, column, cell]);
|
|
394
|
+
|
|
395
|
+
return (
|
|
396
|
+
<div className="flex h-72 min-h-0 flex-col overflow-hidden rounded-md border border-border bg-surface">
|
|
397
|
+
{value !== undefined ? (
|
|
398
|
+
<ValueEditor column={columns[column]!} value={value} editable={editable} onCommit={onCommit} />
|
|
399
|
+
) : (
|
|
400
|
+
<div className="grid min-h-0 grow place-items-center text-xs text-text-muted">
|
|
401
|
+
{waiting && loadValue !== undefined ? <Spinner size={14} label={t('value.loading')} /> : t('value.notLoaded')}
|
|
402
|
+
</div>
|
|
403
|
+
)}
|
|
404
|
+
</div>
|
|
405
|
+
);
|
|
406
|
+
}
|
package/src/grid/ValueDock.tsx
CHANGED
|
@@ -11,21 +11,30 @@ export interface ValueDockProps {
|
|
|
11
11
|
panel: ReactNode;
|
|
12
12
|
/* The grid and whatever shares its column. */
|
|
13
13
|
children: ReactNode;
|
|
14
|
+
/* The width of the panel, when the owner keeps it; without it the dock keeps it itself. */
|
|
15
|
+
width?: number;
|
|
16
|
+
onWidthChange?(width: number): void;
|
|
14
17
|
className?: string;
|
|
15
18
|
ref?: Ref<HTMLDivElement>;
|
|
16
19
|
}
|
|
17
20
|
|
|
18
21
|
/* A grid with a panel along its right edge that a person can widen by its left edge. */
|
|
19
|
-
export function ValueDock({ open, panel, children, className, ref }: ValueDockProps) {
|
|
22
|
+
export function ValueDock({ open, panel, children, width: given, onWidthChange, className, ref }: ValueDockProps) {
|
|
20
23
|
const root = useRef<HTMLDivElement | null>(null);
|
|
21
24
|
const dock = useRef<HTMLDivElement>(null);
|
|
22
|
-
const [
|
|
25
|
+
const [own, setOwn] = useState(VALUE_PANEL_WIDTH);
|
|
26
|
+
const width = given ?? own;
|
|
23
27
|
const { startResize } = useColumnResize(dock, {
|
|
24
28
|
size: width,
|
|
25
29
|
min: VALUE_PANEL_MIN_WIDTH,
|
|
26
30
|
from: 'right',
|
|
27
31
|
max: () => (root.current?.clientWidth ?? 960) - VALUE_PANEL_MIN_WIDTH,
|
|
28
|
-
onSize:
|
|
32
|
+
onSize: (next) => {
|
|
33
|
+
if (given === undefined) {
|
|
34
|
+
setOwn(next);
|
|
35
|
+
}
|
|
36
|
+
onWidthChange?.(next);
|
|
37
|
+
}
|
|
29
38
|
});
|
|
30
39
|
|
|
31
40
|
return (
|
package/src/grid/layout.ts
CHANGED
|
@@ -7,7 +7,8 @@ export const MAX_COLUMN_WIDTH = 320;
|
|
|
7
7
|
export const MAX_RESIZED_WIDTH = 1200;
|
|
8
8
|
/* How wide fitting a column to its content may make it. */
|
|
9
9
|
export const MAX_FIT_WIDTH = 640;
|
|
10
|
-
|
|
10
|
+
/* The window of drawn rows moves in blocks of this many rows, so a scroll within a block draws nothing. */
|
|
11
|
+
export const ROW_BLOCK = 16;
|
|
11
12
|
/* Two presses on a resize handle this close are a double click. */
|
|
12
13
|
export const DOUBLE_CLICK_MS = 350;
|
|
13
14
|
/* What a viewport is taken to be until it is measured, so a first paint already holds rows. */
|
|
@@ -29,14 +30,23 @@ export interface VisibleRange {
|
|
|
29
30
|
readonly end: number;
|
|
30
31
|
}
|
|
31
32
|
|
|
32
|
-
/* The
|
|
33
|
-
export const
|
|
33
|
+
/* The block the top of the viewport is in, for a viewport `top` pixels into the rows. */
|
|
34
|
+
export const scrollBlock = (top: number): number => Math.floor(Math.max(0, top) / (ROW_BLOCK * ROW_HEIGHT));
|
|
35
|
+
|
|
36
|
+
/*
|
|
37
|
+
* The rows to draw while the top of a viewport `height` pixels tall is in `block`: those in view and a
|
|
38
|
+
* viewport more on each side, rounded out to whole blocks. A fast scroll finds its next rows drawn, and
|
|
39
|
+
* the window moves, and the grid draws, once per block instead of once per row.
|
|
40
|
+
*/
|
|
41
|
+
export const drawnRange = (block: number, height: number, count: number): VisibleRange => {
|
|
34
42
|
if (count <= 0 || height <= 0) {
|
|
35
43
|
return { start: 0, end: 0 };
|
|
36
44
|
}
|
|
37
|
-
const
|
|
38
|
-
const
|
|
39
|
-
|
|
45
|
+
const page = Math.ceil(height / ROW_HEIGHT);
|
|
46
|
+
const top = Math.max(0, block) * ROW_BLOCK;
|
|
47
|
+
const start = Math.max(0, Math.floor((top - page) / ROW_BLOCK) * ROW_BLOCK);
|
|
48
|
+
const end = Math.min(count, Math.ceil((top + ROW_BLOCK + 2 * page) / ROW_BLOCK) * ROW_BLOCK);
|
|
49
|
+
return { start: Math.min(start, end), end };
|
|
40
50
|
};
|
|
41
51
|
|
|
42
52
|
/*
|