feat(grid): add data grid quick entry

This commit is contained in:
funnytime authored and GitHub committed 2026-06-30 09:14:44 +08:00
1 parent 074cef8358
commit 3e7cf30ce5
23 files changed
+1679 -186

No files matched your search

+380 -91
View File
@@ -17,11 +17,30 @@ interface RowItem {
newIndex?: number;
data: CellValue[];
isNew: boolean;
isDraft?: boolean;
isDeleted: boolean;
isDirtyCol: boolean[];
status: string;
}
export const DATA_GRID_QUICK_ENTRY_DRAFT_ROW_ID = Number.MIN_SAFE_INTEGER;
type RowKind = "none" | "existing" | "new" | "draft";
type CommitEditResult =
| {
changed: false;
rowKind: RowKind;
}
| {
changed: true;
rowKind: Exclude<RowKind, "none">;
};
interface CommitEditOptions {
promoteDraft?: boolean;
}
type GridScrollerRef =
| HTMLElement
| {
@@ -37,6 +56,7 @@ export interface CustomSaveHandler {
canInsert?: boolean;
canDelete?: boolean;
readonlyColumns?: string[];
supportsInsert?: boolean;
targetLabel?: string;
}
@@ -65,6 +85,7 @@ export interface UseDataGridEditorOptions {
currentWhereInput: ComputedRef<string | undefined>;
orderByInput: Ref<string>;
rowStatusFilter: Ref<RowStatusFilter>;
dataGridQuickEntryEnabled?: ComputedRef<boolean>;
initialEditColumn?: ComputedRef<number>;
getRowItem: (rowId: number) => RowItem | undefined;
pageSize: Ref<number>;
@@ -77,6 +98,7 @@ export interface UseDataGridEditorOptions {
interface PendingChangesSnapshot {
newRows: CellValue[][];
quickEntryDraftRow?: CellValue[];
dirtyRows: Map<number, Map<number, CellValue>>;
deletedRows: Set<number>;
editingCell?: { rowId: number; col: number } | null;
@@ -87,7 +109,24 @@ interface PendingChangesSnapshot {
rowCount: number;
}
type PendingChangesHistorySnapshot = Pick<PendingChangesSnapshot, "newRows" | "dirtyRows" | "deletedRows" | "transactionActive">;
interface PendingSaveSnapshot {
newRows: CellValue[][];
newRowRefs: CellValue[][];
dirtyRows: Map<number, Map<number, CellValue>>;
deletedRows: Set<number>;
}
interface SaveChangesOptions {
autoSave?: boolean;
}
interface QueuedAutoSaveChange {
sourceIndex: number;
col: number;
value: CellValue;
}
type PendingChangesHistorySnapshot = Pick<PendingChangesSnapshot, "newRows" | "quickEntryDraftRow" | "dirtyRows" | "deletedRows" | "transactionActive">;
const pendingChangesCache = new Map<string, PendingChangesSnapshot>();
const closingPendingSnapshotTabs = new Set<string>();
@@ -143,6 +182,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
searchText,
orderByInput,
rowStatusFilter,
dataGridQuickEntryEnabled = computed(() => false),
initialEditColumn,
getRowItem,
pageSize,
@@ -156,12 +196,17 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
const dirtyRows = ref<Map<number, Map<number, CellValue>>>(new Map());
const newRows = ref<CellValue[][]>([]);
const deletedRows = ref<Set<number>>(new Set());
const quickEntryDraftRow = ref<CellValue[]>([]);
const undoStack = ref<PendingChangesHistorySnapshot[]>([]);
const redoStack = ref<PendingChangesHistorySnapshot[]>([]);
const pendingChangesVersion = ref(0);
let restoredEditingCell = false;
let restoredTransactionActive = false;
let suppressNextBlurCommit = false;
let pendingAutoSaveRequested = false;
const queuedAutoSaveChanges = new Map<string, QueuedAutoSaveChange>();
let draftPromotionScheduled = false;
const savingNewRows = new WeakSet<CellValue[]>();
let pendingScrollRestore: PendingChangesSnapshot["scroll"] | undefined;
let saveScrollSnapshotTimer = 0;
let componentActive = true;
@@ -172,6 +217,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
const cached = pendingChangesCache.get(key);
if (cached && cached.columnCount === result.value.columns.length && cached.rowCount === result.value.rows.length) {
newRows.value = cached.newRows;
quickEntryDraftRow.value = cached.quickEntryDraftRow ? [...cached.quickEntryDraftRow] : [];
dirtyRows.value = cached.dirtyRows;
deletedRows.value = cached.deletedRows;
editingCell.value = cached.editingCell ?? null;
@@ -243,6 +289,10 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
transactionActive.value = true;
}
function exitTransaction() {
transactionActive.value = false;
}
function touchPendingChanges() {
pendingChangesVersion.value++;
}
@@ -250,6 +300,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
function pendingChangesSnapshot(): PendingChangesHistorySnapshot {
return {
newRows: newRows.value.map((row) => [...row]),
quickEntryDraftRow: quickEntryDraftRow.value.length > 0 ? [...quickEntryDraftRow.value] : undefined,
dirtyRows: new Map([...dirtyRows.value].map(([rowIndex, changes]) => [rowIndex, new Map(changes)])),
deletedRows: new Set(deletedRows.value),
transactionActive: transactionActive.value,
@@ -258,9 +309,11 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
function restorePendingChangesSnapshot(snapshot: PendingChangesHistorySnapshot) {
newRows.value = snapshot.newRows.map((row) => [...row]);
quickEntryDraftRow.value = snapshot.quickEntryDraftRow ? [...snapshot.quickEntryDraftRow] : emptyDraftRow();
dirtyRows.value = new Map([...snapshot.dirtyRows].map(([rowIndex, changes]) => [rowIndex, new Map(changes)]));
deletedRows.value = new Set(snapshot.deletedRows);
transactionActive.value = snapshot.transactionActive === true && useTransaction.value === true;
queuedAutoSaveChanges.clear();
editingCell.value = null;
touchPendingChanges();
}
@@ -291,10 +344,6 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
restorePendingChangesSnapshot(snapshot);
}
function exitTransaction() {
transactionActive.value = false;
}
// --- Scroll helpers ---
let isCancelling = false;
let isCommitting = false;
@@ -451,13 +500,103 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
return row.map((v, colIdx) => (dirty.has(colIdx) ? dirty.get(colIdx)! : v));
}
function editingSourceRowItem(rowId: number): RowItem | undefined {
if (!dataGridQuickEntryEnabled.value || rowId < 0) return undefined;
const row = result.value.rows[rowId];
if (!row || deletedRows.value.has(rowId)) return undefined;
const dirty = dirtyRows.value.get(rowId);
return {
id: rowId,
sourceIndex: rowId,
data: rowDataWithChanges(row, rowId),
isNew: false,
isDeleted: false,
isDirtyCol: result.value.columns.map((_, colIdx) => !!dirty?.has(colIdx)),
status: dirty?.size ? "edited" : "clean",
};
}
function emptyDraftRow(): CellValue[] {
return result.value.columns.map(() => null);
}
function ensureQuickEntryDraftRow() {
if (quickEntryDraftRow.value.length !== result.value.columns.length) {
quickEntryDraftRow.value = emptyDraftRow();
}
}
function draftRowHasValue(row = quickEntryDraftRow.value): boolean {
return row.some((value) => value !== null && String(value).trim() !== "");
}
function isSavingNewRow(item: Pick<RowItem, "isNew" | "data"> | undefined): boolean {
return !!item?.isNew && savingNewRows.has(item.data);
}
function queuedAutoSaveKey(sourceIndex: number, col: number): string {
return `${sourceIndex}:${col}`;
}
function rememberQueuedAutoSaveChange(sourceIndex: number, col: number, value: CellValue) {
queuedAutoSaveChanges.set(queuedAutoSaveKey(sourceIndex, col), { sourceIndex, col, value });
}
function applyQueuedAutoSaveChanges(savedSnapshot?: PendingSaveSnapshot) {
if (queuedAutoSaveChanges.size === 0) return false;
let applied = false;
for (const change of queuedAutoSaveChanges.values()) {
if (deletedRows.value.has(change.sourceIndex) || !canEditExistingRows.value) continue;
const oldVal = result.value.rows[change.sourceIndex]?.[change.col];
const savedChanges = savedSnapshot?.dirtyRows.get(change.sourceIndex);
const baseline = savedChanges?.has(change.col) ? savedChanges.get(change.col) : oldVal;
if (change.value !== baseline) {
if (!dirtyRows.value.has(change.sourceIndex)) dirtyRows.value.set(change.sourceIndex, new Map());
dirtyRows.value.get(change.sourceIndex)!.set(change.col, change.value);
applied = true;
} else {
const rowChanges = dirtyRows.value.get(change.sourceIndex);
rowChanges?.delete(change.col);
if (rowChanges?.size === 0) dirtyRows.value.delete(change.sourceIndex);
}
}
queuedAutoSaveChanges.clear();
dirtyRows.value = new Map(dirtyRows.value);
return applied;
}
async function promoteQuickEntryDraftRow() {
draftPromotionScheduled = false;
ensureQuickEntryDraftRow();
if (!draftRowHasValue()) {
quickEntryDraftRow.value = emptyDraftRow();
return;
}
rowStatusFilter.value = rowStatusFilterAfterAddingRow(rowStatusFilter.value);
newRows.value = [...newRows.value, [...quickEntryDraftRow.value]];
quickEntryDraftRow.value = emptyDraftRow();
if (useTransaction.value && !transactionActive.value) {
enterTransaction();
}
if (dataGridQuickEntryEnabled.value) {
await saveChanges({ autoSave: true });
}
}
function scheduleQuickEntryDraftPromotion() {
if (draftPromotionScheduled) return;
draftPromotionScheduled = true;
void Promise.resolve().then(promoteQuickEntryDraftRow);
}
// --- Inline editing ---
function startEdit(rowId: number, colIdx: number) {
if (!editable.value) return;
if (!canEditColumn(colIdx)) return;
const item = getRowItem(rowId);
if (!item || item.isDeleted) return;
if (!item.isNew && !canEditExistingRows.value) return;
if (!item.isNew && !item.isDraft && !canEditExistingRows.value) return;
if (isSavingNewRow(item)) return;
isCancelling = false;
suppressNextBlurCommit = false;
editingCell.value = { rowId, col: colIdx };
@@ -470,47 +609,76 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
focusEditInput();
}
function commitEdit() {
if (isCancelling || isCommitting) return;
if (!editingCell.value) return;
function commitEdit(options: CommitEditOptions = {}): CommitEditResult {
if (isCancelling || isCommitting) return { changed: false, rowKind: "none" };
if (!editingCell.value) return { changed: false, rowKind: "none" };
isCommitting = true;
const { rowId, col } = editingCell.value;
const item = getRowItem(rowId);
const item = getRowItem(rowId) ?? editingSourceRowItem(rowId);
if (!item || item.isDeleted) {
editingCell.value = null;
isCommitting = false;
return;
return { changed: false, rowKind: "none" };
}
if (item.isDraft) {
ensureQuickEntryDraftRow();
const oldVal = quickEntryDraftRow.value[col] ?? null;
const newVal = coerceCellValue(editValue.value, oldVal, col);
const nextDraftRow = [...quickEntryDraftRow.value];
nextDraftRow[col] = newVal;
if (newVal !== oldVal) pushUndoSnapshot();
quickEntryDraftRow.value = nextDraftRow;
editingCell.value = null;
isCommitting = false;
if (!draftRowHasValue(nextDraftRow)) {
quickEntryDraftRow.value = emptyDraftRow();
return { changed: false, rowKind: "draft" };
}
if (options.promoteDraft === false) {
return { changed: false, rowKind: "draft" };
}
rowStatusFilter.value = rowStatusFilterAfterAddingRow(rowStatusFilter.value);
newRows.value = [...newRows.value, nextDraftRow];
quickEntryDraftRow.value = emptyDraftRow();
touchPendingChanges();
if (useTransaction.value && !transactionActive.value) {
enterTransaction();
}
return { changed: true, rowKind: "draft" };
}
if (item.isNew && item.newIndex !== undefined) {
const oldVal = newRows.value[item.newIndex]?.[col];
const newVal = coerceCellValue(editValue.value, oldVal, col);
const changed = newVal !== oldVal;
if (changed) pushUndoSnapshot();
if (newRows.value[item.newIndex]) {
if (newVal !== oldVal) pushUndoSnapshot();
newRows.value[item.newIndex][col] = newVal;
}
newRows.value = [...newRows.value];
touchPendingChanges();
if (changed) touchPendingChanges();
editingCell.value = null;
isCommitting = false;
return;
return changed ? { changed: true, rowKind: "new" } : { changed: false, rowKind: "new" };
}
if (item.sourceIndex === undefined) {
editingCell.value = null;
isCommitting = false;
return;
return { changed: false, rowKind: "none" };
}
if (!canEditExistingRows.value) {
editingCell.value = null;
isCommitting = false;
return;
return { changed: false, rowKind: "existing" };
}
const oldVal = result.value.rows[item.sourceIndex]?.[col];
const newVal = coerceCellValue(editValue.value, oldVal, col);
const changed = newVal !== item.data[col];
if (newVal !== oldVal) {
pushUndoSnapshot();
if (changed) pushUndoSnapshot();
if (!dirtyRows.value.has(item.sourceIndex)) dirtyRows.value.set(item.sourceIndex, new Map());
dirtyRows.value.get(item.sourceIndex)!.set(col, newVal);
if (useTransaction.value && !transactionActive.value) {
@@ -523,17 +691,28 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
if (rowChanges?.size === 0) dirtyRows.value.delete(item.sourceIndex);
}
dirtyRows.value = new Map(dirtyRows.value);
touchPendingChanges();
if (changed) touchPendingChanges();
editingCell.value = null;
isCommitting = false;
if (dataGridQuickEntryEnabled.value && isSaving.value && changed) {
rememberQueuedAutoSaveChange(item.sourceIndex, col, newVal);
}
return changed ? { changed: true, rowKind: "existing" } : { changed: false, rowKind: "existing" };
}
function commitEditFromBlur() {
async function commitEditAndMaybeAutoSave(options: CommitEditOptions = {}) {
const result = commitEdit(options);
if (dataGridQuickEntryEnabled.value && options.promoteDraft !== false && result.changed) {
await saveChanges({ autoSave: true });
}
}
async function commitEditFromBlur(options: CommitEditOptions = {}) {
if (suppressNextBlurCommit) {
suppressNextBlurCommit = false;
return;
}
commitEdit();
await commitEditAndMaybeAutoSave(options);
}
function applyCellValue(rowId: number, col: number, value: string | null) {
@@ -541,7 +720,21 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
const item = getRowItem(rowId);
if (!item || item.isDeleted) return;
if (item.isDraft) {
ensureQuickEntryDraftRow();
const oldVal = quickEntryDraftRow.value[col] ?? null;
const nextDraftRow = [...quickEntryDraftRow.value];
nextDraftRow[col] = value === null ? null : coerceCellValue(value, oldVal, col);
if (nextDraftRow[col] === oldVal) return;
pushUndoSnapshot();
quickEntryDraftRow.value = draftRowHasValue(nextDraftRow) ? nextDraftRow : emptyDraftRow();
touchPendingChanges();
scheduleQuickEntryDraftPromotion();
return;
}
if (item.isNew && item.newIndex !== undefined) {
if (isSavingNewRow(item)) return;
const row = newRows.value[item.newIndex];
if (!row) return;
const oldVal = row[col];
@@ -584,7 +777,19 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
const item = getRowItem(rowId);
if (!item || item.isDeleted) return;
if (item.isDraft) {
ensureQuickEntryDraftRow();
if (quickEntryDraftRow.value[col] === null) return;
pushUndoSnapshot();
const nextDraftRow = [...quickEntryDraftRow.value];
nextDraftRow[col] = null;
quickEntryDraftRow.value = draftRowHasValue(nextDraftRow) ? nextDraftRow : emptyDraftRow();
touchPendingChanges();
return;
}
if (item.isNew && item.newIndex !== undefined) {
if (isSavingNewRow(item)) return;
const row = newRows.value[item.newIndex];
if (!row || row[col] === null) return;
pushUndoSnapshot();
@@ -614,11 +819,10 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
}
function onEditKeydown(e: KeyboardEvent) {
const isExpandedTextarea = e.target instanceof HTMLTextAreaElement && e.target.dataset.expandedCellEditor === "true";
const isExpandedTextarea = typeof HTMLTextAreaElement !== "undefined" && e.target instanceof HTMLTextAreaElement && e.target.dataset.expandedCellEditor === "true";
if (e.key === "Enter" && (!isExpandedTextarea || e.metaKey || e.ctrlKey)) {
e.preventDefault();
commitEdit();
nextTick(focusScrollerWithoutScrolling);
void commitEditAndMaybeAutoSave().finally(() => nextTick(focusScrollerWithoutScrolling));
} else if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
@@ -700,6 +904,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
const item = getRowItem(rowId);
if (!item) return;
if (item.isNew && item.newIndex !== undefined) {
if (isSavingNewRow(item)) return;
pushUndoSnapshot();
newRows.value.splice(item.newIndex, 1);
newRows.value = [...newRows.value];
@@ -772,7 +977,81 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
}
// --- Save/Discard ---
function saveStatementOptions() {
function snapshotPendingSaveChanges(): PendingSaveSnapshot {
const currentNewRows = [...newRows.value];
return {
dirtyRows: new Map([...dirtyRows.value.entries()].map(([rowIndex, changes]) => [rowIndex, new Map(changes)])),
newRows: currentNewRows.map((row) => [...row]),
newRowRefs: currentNewRows,
deletedRows: new Set(deletedRows.value),
};
}
function hasPendingSaveChanges(snapshot: PendingSaveSnapshot) {
return snapshot.newRows.length > 0 || snapshot.dirtyRows.size > 0 || snapshot.deletedRows.size > 0;
}
function applyDirtyRowsToResult(snapshot: PendingSaveSnapshot) {
for (const [sourceIndex, changes] of snapshot.dirtyRows) {
const row = result.value.rows[sourceIndex];
if (row) {
for (const [colIdx, value] of changes) {
row[colIdx] = value;
}
}
}
}
function clearSavedPendingChanges(snapshot: PendingSaveSnapshot) {
for (const [sourceIndex, changes] of snapshot.dirtyRows) {
const liveChanges = dirtyRows.value.get(sourceIndex);
if (!liveChanges) continue;
for (const [colIdx, savedValue] of changes) {
if (liveChanges.get(colIdx) === savedValue) {
liveChanges.delete(colIdx);
}
}
if (liveChanges.size === 0) {
dirtyRows.value.delete(sourceIndex);
}
}
dirtyRows.value = new Map(dirtyRows.value);
if (snapshot.newRows.length > 0) {
const savedNewRows = new Set(snapshot.newRowRefs);
newRows.value = newRows.value.filter((row) => !savedNewRows.has(row));
}
for (const sourceIndex of snapshot.deletedRows) {
deletedRows.value.delete(sourceIndex);
}
deletedRows.value = new Set(deletedRows.value);
touchPendingChanges();
}
async function finishSaveChanges(savedSnapshot?: PendingSaveSnapshot) {
isSaving.value = false;
if (pendingAutoSaveRequested && dataGridQuickEntryEnabled.value) {
applyQueuedAutoSaveChanges(savedSnapshot);
} else {
queuedAutoSaveChanges.clear();
}
if (!hasPendingChanges.value) {
pendingAutoSaveRequested = false;
return;
}
if (pendingAutoSaveRequested && dataGridQuickEntryEnabled.value) {
pendingAutoSaveRequested = false;
await saveChanges({ autoSave: true });
}
}
async function finishInterruptedSaveChanges(snapshot: PendingSaveSnapshot) {
snapshot.newRowRefs.forEach((row) => savingNewRows.delete(row));
await finishSaveChanges();
}
function saveStatementOptions(snapshot = snapshotPendingSaveChanges()) {
if (!tableMeta.value) return null;
return {
databaseType: resolvedDatabaseType.value,
@@ -780,9 +1059,9 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
columns: result.value.columns,
sourceColumns: sourceColumns.value,
rows: result.value.rows,
dirtyRows: [...dirtyRows.value.entries()].map(([rowIndex, changes]) => [rowIndex, [...changes.entries()]] as [number, Array<[number, CellValue]>]),
deletedRows: [...deletedRows.value],
newRows: newRows.value,
dirtyRows: [...snapshot.dirtyRows.entries()].map(([rowIndex, changes]) => [rowIndex, [...changes.entries()]] as [number, Array<[number, CellValue]>]),
deletedRows: [...snapshot.deletedRows],
newRows: snapshot.newRows,
};
}
@@ -791,21 +1070,21 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
return [tableMeta.value.schema, tableMeta.value.tableName].filter(Boolean).join(".");
}
function dataChangeOperation() {
const operations = [newRows.value.length > 0 ? "INSERT" : "", dirtyRows.value.size > 0 ? "UPDATE" : "", deletedRows.value.size > 0 ? "DELETE" : ""].filter(Boolean);
function dataChangeOperation(snapshot: PendingSaveSnapshot) {
const operations = [snapshot.newRows.length > 0 ? "INSERT" : "", snapshot.dirtyRows.size > 0 ? "UPDATE" : "", snapshot.deletedRows.size > 0 ? "DELETE" : ""].filter(Boolean);
return operations.length === 1 ? operations[0] : "DATA CHANGE";
}
async function recordDataGridHistory(statements: string[], rollbackStatements: string[], elapsed: number, historyResult?: { affected_rows?: number; success?: boolean; error?: string }) {
async function recordDataGridHistory(statements: string[], rollbackStatements: string[], elapsed: number, snapshot: PendingSaveSnapshot, historyResult?: { affected_rows?: number; success?: boolean; error?: string }) {
if (!connectionId.value || !tableMeta.value) return;
const connName = connectionStore.getConfig(connectionId.value)?.name || "";
const success = historyResult?.success ?? true;
const details = {
schema: tableMeta.value.schema,
table: tableMeta.value.tableName,
inserted_rows: newRows.value.length,
updated_rows: dirtyRows.value.size,
deleted_rows: deletedRows.value.size,
inserted_rows: snapshot.newRows.length,
updated_rows: snapshot.dirtyRows.size,
deleted_rows: snapshot.deletedRows.size,
statements,
rollback_statements: success ? rollbackStatements : [],
error: success ? undefined : historyResult?.error,
@@ -819,7 +1098,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
success,
error: success ? undefined : historyResult?.error,
activity_kind: "data_change",
operation: dataChangeOperation(),
operation: dataChangeOperation(snapshot),
target: tableHistoryTarget(),
affected_rows: success ? (historyResult?.affected_rows ?? statements.length) : undefined,
rollback_sql: success && rollbackStatements.length ? rollbackStatements.join("\n") : undefined,
@@ -827,10 +1106,10 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
});
}
async function recordFailedDataGridHistory(statements: string[], rollbackStatements: string[], start: number, error: unknown) {
const message = normalizeDataGridSaveError(resolvedDatabaseType.value, error);
async function recordFailedDataGridHistory(statements: string[], rollbackStatements: string[], start: number, snapshot: PendingSaveSnapshot, error: unknown) {
const message = normalizeDataGridSaveError(databaseType.value, error);
try {
await recordDataGridHistory(statements, rollbackStatements, Date.now() - start, {
await recordDataGridHistory(statements, rollbackStatements, Date.now() - start, snapshot, {
success: false,
error: message,
});
@@ -844,70 +1123,71 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
options.emit("reload", sql.value, searchText.value, options.currentWhereInput.value, orderByInput.value.trim() || undefined, pageSize.value, (currentPage.value - 1) * pageSize.value);
}
function applyDirtyRowsToResult() {
for (const [sourceIndex, changes] of dirtyRows.value) {
const row = result.value.rows[sourceIndex];
if (row) {
for (const [colIdx, value] of changes) {
row[colIdx] = value;
}
}
async function saveChanges(saveOptions: SaveChangesOptions = {}) {
if (isSaving.value) {
if (saveOptions.autoSave) pendingAutoSaveRequested = true;
return;
}
const snapshot = snapshotPendingSaveChanges();
if (!hasPendingSaveChanges(snapshot)) {
return;
}
const customHandler = customSaveHandler?.value;
if (customHandler && snapshot.newRows.length > 0 && customHandler.supportsInsert !== true && customHandler.canInsert !== true) {
saveError.value = "当前保存目标不支持新增行。";
return;
}
}
async function saveChanges() {
saveError.value = "";
isSaving.value = true;
const shouldReloadAfterSave = newRows.value.length > 0 || deletedRows.value.size > 0;
snapshot.newRowRefs.forEach((row) => savingNewRows.add(row));
const shouldReloadAfterSave = snapshot.newRows.length > 0 || snapshot.deletedRows.size > 0;
if (customSaveHandler?.value) {
if (customHandler) {
try {
await customSaveHandler.value.save({
dirtyRows: dirtyRows.value,
newRows: newRows.value,
deletedRows: deletedRows.value,
await customHandler.save({
dirtyRows: snapshot.dirtyRows,
newRows: snapshot.newRows,
deletedRows: snapshot.deletedRows,
columns: result.value.columns,
rows: result.value.rows,
});
} catch (e: any) {
saveError.value = normalizeDataGridSaveError(resolvedDatabaseType.value, e);
isSaving.value = false;
saveError.value = normalizeDataGridSaveError(databaseType.value, e);
await finishInterruptedSaveChanges(snapshot);
return;
}
applyDirtyRowsToResult();
dirtyRows.value.clear();
newRows.value = [];
deletedRows.value.clear();
snapshot.newRowRefs.forEach((row) => savingNewRows.delete(row));
applyDirtyRowsToResult(snapshot);
clearSavedPendingChanges(snapshot);
if (!hasPendingChanges.value) exitTransaction();
clearPendingChangeHistory();
touchPendingChanges();
exitTransaction();
isSaving.value = false;
if (shouldReloadAfterSave) {
reloadCurrentData();
}
await finishSaveChanges(snapshot);
return;
}
const stmtOptions = saveStatementOptions();
const stmtOptions = saveStatementOptions(snapshot);
let preparedSave: Awaited<ReturnType<typeof api.prepareDataGridSave>> | undefined;
if (stmtOptions) {
try {
preparedSave = await api.prepareDataGridSave(stmtOptions);
} catch (e: any) {
saveError.value = normalizeDataGridSaveError(resolvedDatabaseType.value, e);
isSaving.value = false;
saveError.value = normalizeDataGridSaveError(databaseType.value, e);
await finishInterruptedSaveChanges(snapshot);
return;
}
}
if (preparedSave?.validationError) {
saveError.value = preparedSave.validationError;
isSaving.value = false;
await finishInterruptedSaveChanges(snapshot);
return;
}
const stmts = preparedSave?.statements ?? [];
if (stmts.length === 0) {
isSaving.value = false;
await finishInterruptedSaveChanges(snapshot);
return;
}
const rollbackStmts = preparedSave?.rollbackStatements ?? [];
@@ -915,7 +1195,6 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
let apiResult: { affected_rows?: number } | undefined;
console.info("[DBX][dataGrid:save-statements]", {
databaseType: databaseType.value,
resolvedDatabaseType: resolvedDatabaseType.value,
table: tableMeta.value ? [tableMeta.value.schema, tableMeta.value.tableName].filter(Boolean).join(".") : undefined,
statements: stmts,
rollbackStatements: rollbackStmts,
@@ -925,16 +1204,16 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
try {
apiResult = await api.executeInTransaction(connectionId.value!, database.value ?? "", stmts, preparedSave?.executionSchema);
} catch (e: any) {
saveError.value = await recordFailedDataGridHistory(stmts, rollbackStmts, start, e);
isSaving.value = false;
saveError.value = await recordFailedDataGridHistory(stmts, rollbackStmts, start, snapshot, e);
await finishInterruptedSaveChanges(snapshot);
return;
}
} else if (hasBackendSaveTarget.value) {
try {
apiResult = await api.executeBatch(connectionId.value!, database.value ?? "", stmts, preparedSave?.executionSchema);
} catch (e: any) {
saveError.value = await recordFailedDataGridHistory(stmts, rollbackStmts, start, e);
isSaving.value = false;
saveError.value = await recordFailedDataGridHistory(stmts, rollbackStmts, start, snapshot, e);
await finishInterruptedSaveChanges(snapshot);
return;
}
} else if (onExecuteSql.value) {
@@ -943,36 +1222,36 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
await onExecuteSql.value(sqlStmt);
}
} catch (e: any) {
saveError.value = await recordFailedDataGridHistory(stmts, rollbackStmts, start, e);
isSaving.value = false;
saveError.value = await recordFailedDataGridHistory(stmts, rollbackStmts, start, snapshot, e);
await finishInterruptedSaveChanges(snapshot);
return;
}
}
try {
await recordDataGridHistory(stmts, rollbackStmts, Date.now() - start, apiResult);
await recordDataGridHistory(stmts, rollbackStmts, Date.now() - start, snapshot, apiResult);
} catch (e) {
console.warn("[DBX] failed to record data grid history", e);
}
applyDirtyRowsToResult();
dirtyRows.value.clear();
newRows.value = [];
deletedRows.value.clear();
applyDirtyRowsToResult(snapshot);
snapshot.newRowRefs.forEach((row) => savingNewRows.delete(row));
clearSavedPendingChanges(snapshot);
if (!hasPendingChanges.value) exitTransaction();
clearPendingChangeHistory();
touchPendingChanges();
exitTransaction();
isSaving.value = false;
if (shouldReloadAfterSave) {
reloadCurrentData();
}
await finishSaveChanges(snapshot);
}
function discardChanges() {
dirtyRows.value.clear();
newRows.value = [];
deletedRows.value.clear();
quickEntryDraftRow.value = emptyDraftRow();
queuedAutoSaveChanges.clear();
editingCell.value = null;
clearPendingChangeHistory();
touchPendingChanges();
editingCell.value = null;
exitTransaction();
}
@@ -1001,12 +1280,14 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
}
const scroll = includeScroll ? (readScrollPosition() ?? pendingScrollRestore) : undefined;
if (includeScroll) pendingScrollRestore = scroll;
if (!hasPendingChanges.value && !(includeEditing && editingCell.value) && !scroll) {
const quickEntryDraftRowSnapshot = draftRowHasValue() ? [...quickEntryDraftRow.value] : undefined;
if (!hasPendingChanges.value && !quickEntryDraftRowSnapshot && !(includeEditing && editingCell.value) && !scroll) {
pendingChangesCache.delete(k);
return;
}
pendingChangesCache.set(k, {
newRows: newRows.value.map((r) => [...r]),
quickEntryDraftRow: quickEntryDraftRowSnapshot,
dirtyRows: new Map([...dirtyRows.value].map(([i, m]) => [i, new Map(m)])),
deletedRows: new Set(deletedRows.value),
editingCell: includeEditing && editingCell.value ? { ...editingCell.value } : null,
@@ -1084,7 +1365,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
previewStatements.value = stmts;
return stmts;
} catch (e: any) {
saveError.value = normalizeDataGridSaveError(resolvedDatabaseType.value, e);
saveError.value = normalizeDataGridSaveError(databaseType.value, e);
return [];
} finally {
isPreviewLoading.value = false;
@@ -1098,14 +1379,14 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
dirtyRows,
newRows,
deletedRows,
pendingChangesVersion,
quickEntryDraftRow,
quickEntryDraftRowId: DATA_GRID_QUICK_ENTRY_DRAFT_ROW_ID,
dirtyRowCount,
newRowCount,
deletedRowCount,
pendingChangesVersion,
pendingChangeCount,
hasPendingChanges,
canUndoPendingChange,
canRedoPendingChange,
transactionActive,
isSaving,
saveError,
@@ -1114,11 +1395,10 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
exitTransaction,
startEdit,
commitEdit,
commitEditAndMaybeAutoSave,
commitEditFromBlur,
applyCellValue,
restoreCellValue,
undoPendingChange,
redoPendingChange,
cancelEdit,
onEditKeydown,
addRow,
@@ -1136,7 +1416,14 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
deleteSelectedRow,
saveChanges,
discardChanges,
canUndoPendingChange,
canRedoPendingChange,
undoPendingChange,
redoPendingChange,
rowDataWithChanges,
ensureQuickEntryDraftRow,
draftRowHasValue,
isSavingNewRow,
coerceCellValue,
canEditColumn,
resetGridVerticalScroll,
@@ -1147,6 +1434,8 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
previewStatements,
isPreviewLoading,
previewChanges,
savePendingSnapshot,
restorePendingSnapshotFocus,
syncHeaderScroll: (headerRef: Ref<HTMLDivElement | undefined>) => (e: Event) => {
if (headerRef.value) {
headerRef.value.scrollLeft = (e.target as HTMLElement).scrollLeft;
@@ -22,6 +22,7 @@ interface RowItem {
newIndex?: number;
data: CellValue[];
isNew: boolean;
isDraft?: boolean;
isDeleted: boolean;
isDirtyCol: boolean[];
status: string;
@@ -139,13 +140,13 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
}
function rowsToExport(rowIds?: number[]): RowItem[] {
if (!rowIds?.length) return displayItems.value;
if (rowIds === undefined) return displayItems.value.filter((item) => !item.isDraft);
const rowIdSet = new Set(rowIds);
return displayItems.value.filter((item) => rowIdSet.has(item.id));
return displayItems.value.filter((item) => rowIdSet.has(item.id) && !item.isDraft);
}
async function resultToExport(rowIds?: number[], onProgress?: (info: { rowsExported: number; totalRows: number | null }) => void, useFullExport = true): Promise<{ columns: string[]; rows: CellValue[][] }> {
if (useFullExport && !rowIds?.length && fullExportResult) {
if (useFullExport && rowIds === undefined && fullExportResult) {
const result = await fullExportResult(onProgress);
if (result) return { columns: result.columns, rows: result.rows };
}
@@ -161,19 +162,19 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
function targetedRows(): RowItem[] {
if (hasRowSelection.value && selectedRowIds.value.size > 0) {
return displayItems.value.filter((item) => selectedRowIds.value.has(item.id));
return displayItems.value.filter((item) => selectedRowIds.value.has(item.id) && !item.isDraft);
}
const range = selectedRange.value;
if (range && range.startRow !== range.endRow) {
return displayItems.value.slice(range.startRow, range.endRow + 1);
return displayItems.value.slice(range.startRow, range.endRow + 1).filter((item) => !item.isDraft);
}
if (!contextCell.value) return [];
const item = getRowItem(contextCell.value.rowId);
return item ? [item] : [];
return item && !item.isDraft ? [item] : [];
}
function updateEligibleRows(): RowItem[] {
return targetedRows().filter((item) => !item.isNew && !item.isDeleted);
return targetedRows().filter((item) => !item.isNew && !item.isDraft && !item.isDeleted);
}
function updateCopyKey(): string {
@@ -389,7 +390,8 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
async function copySelectedRowsTsv() {
if (!hasRowSelection.value || selectedRowIds.value.size === 0) return;
const rows = displayItems.value.filter((item) => selectedRowIds.value.has(item.id)).map((item) => item.data);
const rows = displayItems.value.filter((item) => selectedRowIds.value.has(item.id) && !item.isDraft).map((item) => item.data);
if (rows.length === 0) return;
await copyText(formatSelectionAsTsv({ columns: columns.value, rows }));
}
@@ -417,30 +419,31 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
async function copyCell() {
if (!contextCell.value || contextCell.value.col < 0) return;
const item = getRowItem(contextCell.value.rowId);
if (!item || item.isDraft) return;
const val = item?.data[contextCell.value.col] ?? null;
await copyText(displayCellValue(val));
}
async function copyRow() {
if (hasRowSelection.value && selectedRowIds.value.size > 0) {
const items = displayItems.value.filter((item) => selectedRowIds.value.has(item.id));
const items = displayItems.value.filter((item) => selectedRowIds.value.has(item.id) && !item.isDraft);
await copyRowsAsJson(items);
return;
}
const range = selectedRange.value;
if (range && range.startRow !== range.endRow) {
const items = displayItems.value.slice(range.startRow, range.endRow + 1);
const items = displayItems.value.slice(range.startRow, range.endRow + 1).filter((item) => !item.isDraft);
await copyRowsAsJson(items);
return;
}
if (!contextCell.value) return;
const item = getRowItem(contextCell.value.rowId);
if (!item) return;
if (!item || item.isDraft) return;
await copyRowsAsJson([item]);
}
function insertEligibleRows(): RowItem[] {
return targetedRows();
return targetedRows().filter((item) => !item.isDraft);
}
async function copyRowAsInsert() {
@@ -480,7 +483,10 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
async function copyAll() {
const header = columns.value.join("\t");
const body = displayItems.value.map((item) => item.data.map((c) => displayCellValue(c)).join("\t")).join("\n");
const body = displayItems.value
.filter((item) => !item.isDraft)
.map((item) => item.data.map((c) => displayCellValue(c)).join("\t"))
.join("\n");
await copyText(`${header}\n${body}`);
}
@@ -501,7 +507,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
if (await exportQueryResultViaBackend("csv", rowIds)) return;
if (await exportFullTableDataViaBackend("csv", rowIds)) return;
const needsFullExport = !rowIds?.length && !!fullExportResult;
const needsFullExport = rowIds === undefined && !!fullExportResult;
if (needsFullExport && exportProgressDialog && exportProgressState) {
exportProgressState.value = {
title: t("exportProgress.title"),
@@ -705,7 +711,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
if (!path) return;
outputPath = path as string;
}
const needsFullExport = !rowIds?.length && !!fullExportResult;
const needsFullExport = rowIds === undefined && !!fullExportResult;
if (needsFullExport && exportProgressDialog && exportProgressState) {
exportProgressState.value = {
title: t("exportProgress.title"),
@@ -815,7 +821,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
async function exportFullTableDataViaBackend(format: "csv" | "xlsx" | "json" | "markdown" | "sql", rowIds?: number[]): Promise<boolean> {
const meta = tableMeta.value;
if (rowIds?.length || context.value !== "table-data" || !meta || !connectionId.value || !database.value) {
if (rowIds !== undefined || context.value !== "table-data" || !meta || !connectionId.value || !database.value) {
return false;
}
@@ -894,7 +900,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
}
async function exportQueryResultViaBackend(format: "csv" | "xlsx", rowIds?: number[]): Promise<boolean> {
if (rowIds?.length || context.value !== "results" || !queryResultExportRequest) {
if (rowIds !== undefined || context.value !== "results" || !queryResultExportRequest) {
return false;
}
@@ -9,6 +9,7 @@ interface RowItem {
newIndex?: number;
data: CellValue[];
isNew: boolean;
isDraft?: boolean;
isDeleted: boolean;
isDirtyCol: boolean[];
status: string;
@@ -45,21 +46,37 @@ export function useDataGridSelection(options: UseDataGridSelectionOptions) {
return normalizeSelectionRange(selectionAnchor.value, selectionFocus.value);
});
const visibleSelectionRows = computed(() => displayItems.value.map((item) => item.data));
const selectedCells = computed<SelectionData>(() => {
if (hasColumnSelection.value) {
return extractColumnsSelection(columns.value, visibleSelectionRows.value, selectedColumnIndexes.value);
const rows = displayItems.value.filter((item) => !item.isDraft).map((item) => item.data);
return extractColumnsSelection(columns.value, rows, selectedColumnIndexes.value);
}
if (selectedCellKeys.value.size > 0) {
return extractSelectedCellKeys(columns.value, visibleSelectionRows.value, selectedCellKeys.value);
const selectableKeys = [...selectedCellKeys.value].filter((key) => {
const position = parseCellKey(key);
return !!position && !displayItems.value[position.rowIndex]?.isDraft;
});
return extractSelectedCellKeys(
columns.value,
displayItems.value.map((item) => item.data),
selectableKeys,
);
}
const range = selectedRange.value;
if (!range) return { columns: [], rows: [] };
return extractSelection(columns.value, visibleSelectionRows.value, range);
const rows = displayItems.value
.slice(range.startRow, range.endRow + 1)
.filter((item) => !item.isDraft)
.map((item) => item.data);
return extractSelection(columns.value, rows, {
startRow: 0,
endRow: rows.length - 1,
startCol: range.startCol,
endCol: range.endCol,
});
});
const selectedCellCount = computed(() => (selectedCellKeys.value.size > 0 ? selectedCellKeys.value.size : selectedCells.value.columns.length * selectedCells.value.rows.length));
const selectedCellCount = computed(() => selectedCells.value.rows.reduce((count, row) => count + row.length, 0));
const hasCellSelection = computed(() => selectedCellCount.value > 0);
function clearCellSelection() {