fix(grid): protect unsaved cell edits on data tab reuse

This commit is contained in:
zipg authored and GitHub committed 2026-08-06 20:10:56 +08:00
1 parent 3e6b3d5321
commit 62b0c5d880
5 files changed
+49 -9

No files matched your search

+43 -7
View File
@@ -3091,7 +3091,7 @@ watch(
const tab = queryStore.tabs.find((item) => item.id === props.cacheKey);
if (tab?.mode === "data") tab.pendingDataChangeCount = count || undefined;
},
{ immediate: true },
{ immediate: true, flush: "sync" },
);
const saveActionMode = computed(() =>
@@ -4125,6 +4125,32 @@ watch(activeCellDetailTab, (tab) => {
}
});
const detailEditValue = ref("");
const detailEditOriginalValue = ref("");
const isEditingDetail = ref(false);
const hasPendingDetailEditorDraft = computed(() => isEditingDetail.value && detailEditValue.value !== detailEditOriginalValue.value);
const hasPendingInlineEditorDraft = computed(() => {
const cell = editingCell.value;
if (!cell) return false;
const item = getRowItem(cell.rowId);
if (!item || item.isDeleted) return false;
const originalValue = dataGridCellEditorText({
value: item.data[cell.col] ?? null,
databaseType: resolvedDatabaseType.value,
columnInfo: tableColumnForGridColumn(cell.col),
});
return editValue.value !== originalValue;
});
const hasPendingDataEditorDraft = computed(() => hasPendingDetailEditorDraft.value || hasPendingInlineEditorDraft.value);
function syncPendingDataEditorDraft(pending: boolean) {
if (props.context !== "table-data" || !props.cacheKey) return;
const tab = queryStore.tabs.find((item) => item.id === props.cacheKey);
if (tab?.mode === "data") tab.hasPendingDataEditorDraft = pending || undefined;
}
watch(hasPendingDataEditorDraft, syncPendingDataEditorDraft, { immediate: true, flush: "sync" });
const activeValueEditorActions = computed(() => {
const detail = activeCellDetail.value;
return valueEditorActions({
@@ -4213,17 +4239,17 @@ watch(activeCellDetail, (detail) => {
resetDetailEdit();
return;
}
detailEditValue.value = dataGridCellEditorText({
const value = dataGridCellEditorText({
value: detail.value,
databaseType: props.databaseType,
columnInfo: tableColumnForGridColumn(detail.colIndex),
});
detailEditValue.value = value;
detailEditOriginalValue.value = value;
syncEditorFromDetailEdit();
isEditingDetail.value = true;
});
const detailEditValue = ref("");
const isEditingDetail = ref(false);
const detailTemporalEditorConfig = computed(() => {
const detail = activeCellDetail.value;
return detail ? temporalEditorConfigForColumn(detail.colIndex) : undefined;
@@ -4287,6 +4313,7 @@ watch(valueEditorContainer, async (el) => {
function resetDetailEdit() {
isEditingDetail.value = false;
detailEditValue.value = "";
detailEditOriginalValue.value = "";
}
function closeCellDetails() {
@@ -4338,7 +4365,9 @@ function startDetailEdit() {
const detail = activeCellDetail.value;
if (!detail || !detail.isEditable) return;
warnFormattedJsonEditIfNeeded(detail);
detailEditValue.value = cellDetailEditText(detail);
const value = cellDetailEditText(detail);
detailEditValue.value = value;
detailEditOriginalValue.value = value;
isEditingDetail.value = true;
}
@@ -4350,6 +4379,7 @@ function commitDetailEdit() {
const item = getRowItem(detail.rowId);
if (!item || item.isDeleted) return;
applyCellValue(detail.rowId, detail.colIndex, detailEditValue.value);
detailEditOriginalValue.value = detailEditValue.value;
detailCell.value = detailCell.value ? { ...detailCell.value } : null;
}
@@ -4367,11 +4397,13 @@ function syncEditorFromDetailEdit() {
function cancelValueEditorEdit() {
const detail = activeCellDetail.value;
if (!detail || !detail.isEditable) return;
detailEditValue.value = dataGridCellEditorText({
const value = dataGridCellEditorText({
value: detail.value,
databaseType: props.databaseType,
columnInfo: tableColumnForGridColumn(detail.colIndex),
});
detailEditValue.value = value;
detailEditOriginalValue.value = value;
syncEditorFromDetailEdit();
isEditingDetail.value = true;
}
@@ -4397,11 +4429,13 @@ function restoreDetailOriginalValue() {
}
restoreCellValue(detail.rowId, detail.colIndex);
detailEditValue.value = dataGridCellEditorText({
const value = dataGridCellEditorText({
value: restoredValue,
databaseType: props.databaseType,
columnInfo: tableColumnForGridColumn(detail.colIndex),
});
detailEditValue.value = value;
detailEditOriginalValue.value = value;
syncEditorFromDetailEdit();
isEditingDetail.value = activeCellDetailTab.value === "valueEditor";
detailCell.value = { ...detailCell.value! };
@@ -4410,6 +4444,7 @@ function restoreDetailOriginalValue() {
function setValueEditorNull() {
setDetailNull();
detailEditValue.value = cellDetailEditorText(null);
detailEditOriginalValue.value = detailEditValue.value;
syncEditorFromDetailEdit();
isEditingDetail.value = activeCellDetailTab.value === "valueEditor";
}
@@ -8071,6 +8106,7 @@ onDeactivated(() => {
});
onUnmounted(() => {
syncPendingDataEditorDraft(false);
cleanupFrames();
autoRefresh.stop();
onDdlResizeEnd();
@@ -223,6 +223,7 @@ describe("useSidebarDataOpenRuntime", () => {
["pinned", { pinned: true }],
["executing", { isExecuting: true, executionId: "running" }],
["pending edits", { pendingDataChangeCount: 1 }],
["pending editor draft", { hasPendingDataEditorDraft: true }],
])("opens a new tab instead of replacing an active %s data tab", async (_label, patch) => {
mocks.dataTabReuseMode = "active-tab";
const activeTab = {
@@ -79,6 +79,7 @@ describe("dataTabOpenPolicy", () => {
["explaining", { isExplaining: true }],
["manual transaction", { txnSessionId: "txn-1" }],
["pending edits", { pendingDataChangeCount: 1 }],
["pending editor draft", { hasPendingDataEditorDraft: true }],
])("does not reuse the active tab when it is %s", (_label, patch) => {
const active = Object.assign(dataTab("orders", "orders"), patch);
@@ -5,7 +5,7 @@ import type { QueryTab, TreeNodeType } from "@/types/database";
export type DataTabOpenMode = "default" | "new-tab";
export type { DataTabReuseMode };
type DataTabLike = Pick<QueryTab, "id" | "mode" | "connectionId" | "database" | "catalog" | "schema" | "title" | "tableMeta" | "tableMetaUpdatedAt" | "pinned" | "isExecuting" | "isCancelling" | "isExplaining" | "txnSessionId" | "pendingDataChangeCount">;
type DataTabLike = Pick<QueryTab, "id" | "mode" | "connectionId" | "database" | "catalog" | "schema" | "title" | "tableMeta" | "tableMetaUpdatedAt" | "pinned" | "isExecuting" | "isCancelling" | "isExplaining" | "txnSessionId" | "pendingDataChangeCount" | "hasPendingDataEditorDraft">;
export interface DataTabTarget {
connectionId: string;
@@ -45,7 +45,7 @@ function isSameTable(tab: DataTabLike, target: DataTabTarget): boolean {
}
export function canReuseActiveDataTab(tab: DataTabLike | undefined, target: DataTabTarget): boolean {
return tab !== undefined && isSameDatabase(tab, target) && dataTabCatalog(tab) === (target.catalog || "") && !tab.pinned && !tab.isExecuting && !tab.isCancelling && !tab.isExplaining && !tab.txnSessionId && !tab.pendingDataChangeCount;
return tab !== undefined && isSameDatabase(tab, target) && dataTabCatalog(tab) === (target.catalog || "") && !tab.pinned && !tab.isExecuting && !tab.isCancelling && !tab.isExplaining && !tab.txnSessionId && !tab.pendingDataChangeCount && !tab.hasPendingDataEditorDraft;
}
export function canApplyDataTabMetadata(tab: DataTabLike | undefined, target: DataTabTarget, signal?: AbortSignal): boolean {
+2
View File
@@ -1062,6 +1062,8 @@ export interface QueryTab {
};
tableMetaUpdatedAt?: number;
pendingDataChangeCount?: number;
/** Ephemeral editor draft that has not yet been applied to the data grid. */
hasPendingDataEditorDraft?: boolean;
/** 冷缓存打开表数据时元数据仍在途:行标识未知,编辑/保存必须等待其落地 */
tableMetaPending?: boolean;
/** 取消请求单调计数:isCancelling 是瞬态的(取消失败/查询先完成会被清),