fix(grid): refresh saved rows in place

This commit is contained in:
t8y2 committed 2026-08-25 11:05:38 +08:00
1 parent 08365d73eb
commit 7c8e0a88cf
6 files changed
+535 -7

No files matched your search

+144 -6
View File
@@ -230,6 +230,7 @@ import { CANVAS_DATA_GRID_ROW_HEIGHT, MAX_CANVAS_DATA_GRID_PIXEL_RATIO, canvasDa
import { DATA_GRID_DARK_STRIPED_ROW_BG, DATA_GRID_LIGHT_STRIPED_ROW_BG, dataGridActiveRowBackground } from "@/lib/dataGrid/dataGridPaintTheme";
import { createRowLowerTextCache } from "@/lib/dataGrid/dataGridRowLowerText";
import { dataGridPreviewLabelKey, dataGridSaveActionMode, dataGridSaveToolbarState } from "@/lib/dataGrid/dataGridSaveUi";
import { buildDataGridSavedRowRefreshPlan, dataGridSavedRowRefreshPatches } from "@/lib/dataGrid/dataGridSavedRowRefresh";
import type { QueryEditabilityReason } from "@/lib/sql/sqlAnalysis";
import { EDITOR_FONT_FAMILY_CSS_VAR } from "@/lib/editor/editorThemes";
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
@@ -568,6 +569,14 @@ let preservedDetailsOnNextResult: {
rowDialog?: PersistedDataGridSelection;
columnDialog?: PersistedDataGridSelection;
} | null = null;
let preservedViewportAnchorOnNextResult: {
anchor: {
row?: PersistedDataGridSelection;
fallbackDisplayIndex: number;
offsetWithinRow: number;
};
sourceResult: QueryResult;
} | null = null;
watch(
() => props.result,
@@ -3506,6 +3515,7 @@ watch(
if (!loading && prevLoading && isRefreshingData.value) {
isRefreshingData.value = false;
if (preservedSelectionOnNextResult?.sourceResult === props.result) preservedSelectionOnNextResult = null;
if (preservedViewportAnchorOnNextResult?.sourceResult === props.result) preservedViewportAnchorOnNextResult = null;
const total = paginationTotalRowCount.value;
if (!total || total <= 0) return;
const lastPageNum = Math.max(1, Math.ceil(total / pageSize.value));
@@ -3863,6 +3873,88 @@ type DisplayRowRef =
status: RowStatus;
};
async function refreshSavedRows(request: { dirtyRows: ReadonlyMap<number, ReadonlyMap<number, CellValue>>; columns: readonly string[]; rows: readonly (readonly CellValue[])[] }): Promise<boolean> {
const tableMeta = props.tableMeta;
const connectionId = props.connectionId;
if (!tableMeta || !connectionId) return false;
const planResult = buildDataGridSavedRowRefreshPlan({
context: props.context,
infiniteScroll: infiniteScrollEnabled.value,
filterActive: !!currentWhereInput() || hasLocalColumnFilters.value || (dataGridSearchMode.value === "filter" && !!deferredClientSearchText.value),
orderActive: !!orderByInput.value.trim() || !!sortCol.value,
columns: request.columns,
sourceColumns: props.sourceColumns,
rows: request.rows,
primaryKeys: tableMeta.primaryKeys,
dirtyRows: request.dirtyRows,
});
if (!planResult.eligible || planResult.plan.sourceIndexes.length === 0) return false;
const identityConditions: string[] = [];
for (const sourceIndex of planResult.plan.sourceIndexes) {
const row = request.rows[sourceIndex];
if (!row) return false;
const conditions: string[] = [];
for (let identityIndex = 0; identityIndex < planResult.plan.identityColumns.length; identityIndex++) {
const columnName = planResult.plan.identityColumns[identityIndex]!;
const value = row[planResult.plan.identityColumnIndexes[identityIndex]!] ?? null;
const condition = await buildDataGridContextFilterCondition({
databaseType: resolvedDatabaseType.value,
identifierQuote: connectionStore.connectionIdentifierQuote?.(connectionId),
columnName,
columnInfo: tableMeta.columns.find((column) => column.name.toLocaleLowerCase() === columnName.toLocaleLowerCase()),
mode: value === null ? "is-null" : "equals",
value,
});
if (!condition) return false;
conditions.push(`(${condition})`);
}
identityConditions.push(`(${conditions.join(" AND ")})`);
}
const sql = await buildTableSelectSql({
databaseType: resolvedDatabaseType.value,
driverProfile: connectionStore.getConfig(connectionId)?.driver_profile,
identifierQuote: connectionStore.connectionIdentifierQuote?.(connectionId),
catalog: tableMeta.catalog,
database: tableMeta.database,
schema: tableMeta.schema,
tableName: tableMeta.tableName,
tableType: tableMeta.tableType,
columns: tableMeta.columns.map((column) => column.name),
includeDatabaseName: settingsStore.editorSettings.generateSqlIncludeDatabaseName,
primaryKeys: tableMeta.primaryKeys,
...tableDataLargeValuePreviewOptions(resolvedDatabaseType.value, tableMeta.columns, tableMeta.primaryKeys, pageSize.value),
whereInput: identityConditions.join(" OR "),
limit: planResult.plan.sourceIndexes.length + 1,
includeRowId: usesSyntheticRowIdKey(resolvedDatabaseType.value, tableMeta.primaryKeys, tableMeta.tableType),
});
const refreshed = await api.executeQuery(connectionId, props.executionDatabase ?? props.database ?? "", sql, tableMeta.schema ?? props.schema, undefined, {
maxRows: planResult.plan.sourceIndexes.length + 1,
fetchSize: planResult.plan.sourceIndexes.length + 1,
pageSize: planResult.plan.sourceIndexes.length + 1,
});
const patches = dataGridSavedRowRefreshPatches(planResult.plan, request.columns, props.sourceColumns, refreshed.columns, refreshed.rows);
if (!patches) return false;
const refreshedSourceIndexes = new Set(patches.map((patch) => patch.sourceIndex));
for (const patch of patches) props.result.rows[patch.sourceIndex] = patch.row;
if (props.result.large_value_cells || refreshed.large_value_cells) {
props.result.large_value_cells = [
...(props.result.large_value_cells ?? []).filter((cell) => !refreshedSourceIndexes.has(cell.row_index)),
...patches.flatMap((patch) => (refreshed.large_value_cells ?? []).filter((cell) => cell.row_index === patch.refreshedRowIndex).map((cell) => ({ ...cell, row_index: patch.sourceIndex }))),
];
}
if (props.result.spatial_values || refreshed.spatial_values) {
props.result.spatial_values ??= props.result.rows.map(() => []);
for (const patch of patches) props.result.spatial_values[patch.sourceIndex] = [...(refreshed.spatial_values?.[patch.refreshedRowIndex] ?? [])];
}
clearCellFormatCache();
scheduleCanvasDraw();
return true;
}
const editor = useDataGridEditor({
result: computed(() => props.result),
editable: computed(() => props.editable),
@@ -3889,6 +3981,7 @@ const editor = useDataGridEditor({
currentPage,
cacheKey: computed(() => props.pendingStateKey ?? props.cacheKey),
onResultPayloadMutated: () => queryStore.invalidateResultEstimateForPayload(props.result),
refreshSavedRows,
onCellValueChanged: invalidateVisibleLargeValuePreviewCell,
prepareFullReload,
emit,
@@ -4341,11 +4434,13 @@ function resetInfiniteScrollState() {
}
function prepareFullReload() {
const viewportAnchor = captureViewportAnchorForRefresh();
if (infiniteScrollEnabled.value) {
resetInfiniteScrollState();
}
const selection = captureCurrentSelectionForRefresh();
preservedSelectionOnNextResult = selection ? { selection, sourceResult: props.result } : null;
preservedViewportAnchorOnNextResult = viewportAnchor ? { anchor: viewportAnchor, sourceResult: props.result } : null;
preservedDetailsOnNextResult = captureDetailsForRefresh();
preserveTransposeOnNextResult.value = showTranspose.value;
isRefreshingData.value = true;
@@ -4379,13 +4474,8 @@ async function onToolbarCommit() {
}
function onToolbarRollback() {
preserveTransposeOnNextResult.value = showTranspose.value;
discardChanges();
// Reset infinite scroll state on rollback
if (infiniteScrollEnabled.value) {
resetInfiniteScrollState();
}
isRefreshingData.value = true;
prepareFullReload();
emit("reload", props.sql, searchText.value, currentWhereInput(), currentOrderBy(), pageSize.value, (currentPage.value - 1) * pageSize.value);
}
@@ -5436,6 +5526,51 @@ function captureRowTargetForRefresh(rowId: number | null): PersistedDataGridSele
);
}
function captureViewportAnchorForRefresh(): { row?: PersistedDataGridSelection; fallbackDisplayIndex: number; offsetWithinRow: number } | null {
if (showTranspose.value || displayItems.value.length === 0) return null;
const scroller = useCanvasGridRows.value ? canvasScrollerElement() : gridScrollerElement();
if (!scroller) return null;
const rowHeight = useCanvasGridRows.value ? CANVAS_DATA_GRID_ROW_HEIGHT : DOM_DATA_GRID_ROW_HEIGHT;
const fallbackDisplayIndex = Math.max(0, Math.min(displayItems.value.length - 1, Math.floor(scroller.scrollTop / rowHeight)));
const item = displayItems.value[fallbackDisplayIndex];
return {
row: item ? captureRowTargetForRefresh(item.id) : undefined,
fallbackDisplayIndex,
offsetWithinRow: Math.max(0, scroller.scrollTop - fallbackDisplayIndex * rowHeight),
};
}
function restoreViewportAnchorAfterRefresh(anchor: { row?: PersistedDataGridSelection; fallbackDisplayIndex: number; offsetWithinRow: number }) {
if (showTranspose.value || displayItems.value.length === 0) return;
let displayRowIndex = anchor.fallbackDisplayIndex;
if (anchor.row) {
const restored = restoreDataGridSelection({
snapshot: anchor.row,
columns: props.result.columns,
sourceColumns: props.sourceColumns,
rows: props.result.rows,
visibleColumnIndexes: visibleColumnIndexes.value,
displayItems: displayItems.value,
});
if (restored?.kind === "rows") displayRowIndex = restored.scrollRowIndex;
}
displayRowIndex = Math.max(0, Math.min(displayItems.value.length - 1, displayRowIndex));
nextTick(() => {
requestAnimationFrame(() => {
const canvasMode = useCanvasGridRows.value;
const scroller = canvasMode ? canvasScrollerElement() : gridScrollerElement();
if (!scroller) return;
const rowHeight = canvasMode ? CANVAS_DATA_GRID_ROW_HEIGHT : DOM_DATA_GRID_ROW_HEIGHT;
const nextScrollTop = Math.max(0, Math.min(displayRowIndex * rowHeight + anchor.offsetWithinRow, scroller.scrollHeight - scroller.clientHeight));
const virtualScroller = scrollerRef.value;
if (!canvasMode && virtualScroller && !(virtualScroller instanceof HTMLElement)) virtualScroller.scrollToPosition?.(nextScrollTop);
else scroller.scrollTop = nextScrollTop;
if (canvasMode) syncCanvasViewport();
else updateGridVerticalScrollbar(scroller);
});
});
}
function captureColumnTargetForRefresh(columnIndex: number | null): PersistedDataGridSelection | undefined {
if (columnIndex === null) return undefined;
const visibleColumnIndex = visibleColumnIndexes.value.indexOf(columnIndex);
@@ -9870,6 +10005,8 @@ watch(
(result, previousResult) => {
const selectionSnapshot = preservedSelectionOnNextResult?.selection;
preservedSelectionOnNextResult = null;
const viewportAnchorSnapshot = preservedViewportAnchorOnNextResult?.anchor;
preservedViewportAnchorOnNextResult = null;
const detailsSnapshot = preservedDetailsOnNextResult;
preservedDetailsOnNextResult = null;
const shouldPreserveTranspose = preserveTransposeOnNextResult.value;
@@ -9908,6 +10045,7 @@ watch(
exitTransaction();
if (selectionSnapshot) restoreSelectionAfterRefresh(selectionSnapshot);
if (detailsSnapshot) restoreDetailsAfterRefresh(detailsSnapshot);
if (viewportAnchorSnapshot) restoreViewportAnchorAfterRefresh(viewportAnchorSnapshot);
},
);
@@ -15,17 +15,23 @@ describe("DataGrid save reload integration", () => {
it("shares the toolbar full-reload preparation with editor saves", () => {
const prepareSource = functionSource("prepareFullReload", "onToolbarRefresh");
const toolbarSource = functionSource("onToolbarRefresh", "setAutoRefreshInterval");
const rollbackSource = functionSource("onToolbarRollback", "addRow");
expect(dataGridSource).toContain("prepareFullReload,\n emit,");
expect(dataGridSource).toContain("refreshSavedRows,\n onCellValueChanged");
expect(toolbarSource).toContain("prepareFullReload();");
expect(rollbackSource).toContain("prepareFullReload();");
expect(toolbarSource).toContain("const resetToFirstPage = hasPendingConditionInputs();");
expect(toolbarSource).toContain("resetToFirstPage ? 0 : (currentPage.value - 1) * pageSize.value");
expect(prepareSource).toContain("resetInfiniteScrollState();");
expect(prepareSource).toContain("captureViewportAnchorForRefresh();");
expect(prepareSource).toContain("preservedViewportAnchorOnNextResult = viewportAnchor ? { anchor: viewportAnchor, sourceResult: props.result } : null;");
expect(prepareSource).toContain("captureCurrentSelectionForRefresh();");
expect(prepareSource).toContain("preservedSelectionOnNextResult = selection ? { selection, sourceResult: props.result } : null;");
expect(prepareSource).toContain("preservedDetailsOnNextResult = captureDetailsForRefresh();");
expect(prepareSource).toContain("preserveTransposeOnNextResult.value = showTranspose.value;");
expect(dataGridSource).toContain("if (detailsSnapshot) restoreDetailsAfterRefresh(detailsSnapshot);");
expect(dataGridSource).toContain("if (viewportAnchorSnapshot) restoreViewportAnchorAfterRefresh(viewportAnchorSnapshot);");
});
it("resets accumulated infinite-scroll pagination before reloading from offset zero", () => {