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", () => {
@@ -117,6 +117,7 @@ export interface UseDataGridEditorOptions {
cacheKey?: ComputedRef<string | undefined>;
/** 保存成功后结果负载被原地修改时通知宿主,使缓存的字节估算失效。 */
onResultPayloadMutated?: () => void;
refreshSavedRows?: (request: { dirtyRows: ReadonlyMap<number, ReadonlyMap<number, CellValue>>; columns: readonly string[]; rows: readonly (readonly CellValue[])[] }) => Promise<boolean>;
onCellValueChanged?: (rowId: number, columnIndex: number) => void;
prepareFullReload?: () => void;
emit: (event: "reload", sql?: string, searchText?: string, whereInput?: string, orderBy?: string, limit?: number, offset?: number) => void;
@@ -1835,11 +1836,23 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
}
applyDirtyRowsToResult(snapshot);
options.onResultPayloadMutated?.();
let savedRowsRefreshed = false;
if (!shouldReloadAfterSave && snapshot.dirtyRows.size > 0 && options.refreshSavedRows) {
try {
savedRowsRefreshed = await options.refreshSavedRows({
dirtyRows: snapshot.dirtyRows,
columns: result.value.columns,
rows: result.value.rows,
});
} catch (error) {
console.warn("[DBX] failed to refresh saved data grid rows", error);
}
}
snapshot.newRowRefs.forEach((row) => savingNewRows.delete(row));
clearSavedPendingChanges(snapshot);
if (!hasPendingChanges.value) exitTransaction();
clearPendingChangeHistory();
if (shouldReloadAfterSqlSave) {
if (shouldReloadAfterSqlSave && !savedRowsRefreshed) {
reloadCurrentData();
}
await finishSaveChanges(snapshot);
@@ -0,0 +1,119 @@
import { describe, expect, it } from "vitest";
import { buildDataGridSavedRowRefreshPlan, dataGridSavedRowRefreshPatches } from "@/lib/dataGrid/dataGridSavedRowRefresh";
function dirtyRows(...entries: Array<[number, Array<[number, string | number | boolean | null]>]>): Map<number, Map<number, string | number | boolean | null>> {
return new Map(entries.map(([rowIndex, changes]) => [rowIndex, new Map(changes)]));
}
describe("dataGridSavedRowRefresh", () => {
it("plans stable table-data row refreshes by primary key", () => {
const result = buildDataGridSavedRowRefreshPlan({
context: "table-data",
infiniteScroll: false,
filterActive: false,
orderActive: false,
columns: ["id", "name"],
rows: [
[1, "Ada"],
[2, "Grace"],
],
primaryKeys: ["id"],
dirtyRows: dirtyRows([1, [[1, "Grace Hopper"]]]),
});
expect(result).toMatchObject({
eligible: true,
plan: {
sourceIndexes: [1],
identityColumns: ["id"],
identityColumnIndexes: [0],
},
});
});
it.each([
["results context", { context: "results" as const }, "not-table-data"],
["infinite scroll", { infiniteScroll: true }, "infinite-scroll"],
["active filter", { filterActive: true }, "active-filter"],
["active order", { orderActive: true }, "active-order"],
["missing key", { primaryKeys: [] }, "missing-identity"],
["edited key", { dirtyRows: dirtyRows([0, [[0, 2]]]) }, "identity-edited"],
])("falls back for %s", (_label, overrides, reason) => {
const result = buildDataGridSavedRowRefreshPlan({
context: "table-data",
infiniteScroll: false,
filterActive: false,
orderActive: false,
columns: ["id", "name"],
rows: [[1, "Ada"]],
primaryKeys: ["id"],
dirtyRows: dirtyRows([0, [[1, "Ada Lovelace"]]]),
...overrides,
});
expect(result).toEqual({ eligible: false, reason });
});
it("maps refreshed rows back to their original source indexes", () => {
const planResult = buildDataGridSavedRowRefreshPlan({
context: "table-data",
infiniteScroll: false,
filterActive: false,
orderActive: false,
columns: ["id", "name", "updated_at"],
rows: [
[1, "Ada", "old-1"],
[2, "Grace", "old-2"],
],
primaryKeys: ["id"],
dirtyRows: dirtyRows([0, [[1, "Ada Lovelace"]]], [1, [[1, "Grace Hopper"]]]),
});
expect(planResult.eligible).toBe(true);
if (!planResult.eligible) return;
expect(
dataGridSavedRowRefreshPatches(
planResult.plan,
["id", "name", "updated_at"],
undefined,
["updated_at", "name", "id"],
[
["fresh-2", "Grace Hopper", 2],
["fresh-1", "Ada Lovelace", 1],
],
),
).toEqual([
{ sourceIndex: 0, refreshedRowIndex: 1, row: [1, "Ada Lovelace", "fresh-1"] },
{ sourceIndex: 1, refreshedRowIndex: 0, row: [2, "Grace Hopper", "fresh-2"] },
]);
});
it("rejects missing or duplicate refresh results", () => {
const planResult = buildDataGridSavedRowRefreshPlan({
context: "table-data",
infiniteScroll: false,
filterActive: false,
orderActive: false,
columns: ["id", "name"],
rows: [[1, "Ada"]],
primaryKeys: ["id"],
dirtyRows: dirtyRows([0, [[1, "Ada Lovelace"]]]),
});
expect(planResult.eligible).toBe(true);
if (!planResult.eligible) return;
expect(dataGridSavedRowRefreshPatches(planResult.plan, ["id", "name"], undefined, ["id", "name"], [])).toBeNull();
expect(
dataGridSavedRowRefreshPatches(
planResult.plan,
["id", "name"],
undefined,
["id", "name"],
[
[1, "Ada"],
[1, "Ada duplicate"],
],
),
).toBeNull();
});
});
@@ -0,0 +1,138 @@
import type { CellValue } from "@/lib/dataGrid/cellValue";
export const DATA_GRID_SAVED_ROW_REFRESH_LIMIT = 100;
export type DataGridSavedRowRefreshIneligibleReason = "not-table-data" | "infinite-scroll" | "active-filter" | "active-order" | "too-many-rows" | "missing-identity" | "missing-identity-column" | "identity-edited" | "missing-source-row" | "duplicate-identity";
export interface DataGridSavedRowRefreshPlan {
sourceIndexes: number[];
identityColumns: string[];
identityColumnIndexes: number[];
identityKeys: string[];
}
export type DataGridSavedRowRefreshPlanResult = { eligible: true; plan: DataGridSavedRowRefreshPlan } | { eligible: false; reason: DataGridSavedRowRefreshIneligibleReason };
export interface BuildDataGridSavedRowRefreshPlanOptions {
context?: "results" | "table-data";
infiniteScroll: boolean;
filterActive: boolean;
orderActive: boolean;
columns: readonly string[];
sourceColumns?: readonly (string | undefined)[];
rows: readonly (readonly CellValue[])[];
primaryKeys: readonly string[];
dirtyRows: ReadonlyMap<number, ReadonlyMap<number, CellValue>>;
rowLimit?: number;
}
export interface DataGridSavedRowRefreshPatch {
sourceIndex: number;
refreshedRowIndex: number;
row: CellValue[];
}
function normalizeColumnName(value: string | undefined): string {
return value?.trim().toLocaleLowerCase() ?? "";
}
function resolveColumnIndexes(columns: readonly string[], sourceColumns: readonly (string | undefined)[] | undefined, targets: readonly string[]): number[] | null {
const indexes = targets.map((target) => {
const normalizedTarget = normalizeColumnName(target);
const sourceIndex = sourceColumns?.findIndex((column) => normalizeColumnName(column) === normalizedTarget) ?? -1;
if (sourceIndex >= 0) return sourceIndex;
return columns.findIndex((column, index) => !normalizeColumnName(sourceColumns?.[index]) && normalizeColumnName(column) === normalizedTarget);
});
return indexes.every((index) => index >= 0) ? indexes : null;
}
function serializeCellValue(value: CellValue): string {
if (value === null) return "null";
if (typeof value === "string") return `string:${value.length}:${value}`;
if (typeof value === "boolean") return value ? "boolean:1" : "boolean:0";
if (Number.isNaN(value)) return "number:NaN";
if (Object.is(value, -0)) return "number:-0";
return `number:${String(value)}`;
}
export function dataGridSavedRowIdentityKey(row: readonly CellValue[], identityColumnIndexes: readonly number[]): string {
return identityColumnIndexes.map((index) => serializeCellValue(row[index] ?? null)).join("\u0000");
}
export function buildDataGridSavedRowRefreshPlan(options: BuildDataGridSavedRowRefreshPlanOptions): DataGridSavedRowRefreshPlanResult {
if (options.context !== "table-data") return { eligible: false, reason: "not-table-data" };
if (options.infiniteScroll) return { eligible: false, reason: "infinite-scroll" };
if (options.filterActive) return { eligible: false, reason: "active-filter" };
if (options.orderActive) return { eligible: false, reason: "active-order" };
if (options.dirtyRows.size > (options.rowLimit ?? DATA_GRID_SAVED_ROW_REFRESH_LIMIT)) return { eligible: false, reason: "too-many-rows" };
if (options.primaryKeys.length === 0) return { eligible: false, reason: "missing-identity" };
const identityColumnIndexes = resolveColumnIndexes(options.columns, options.sourceColumns, options.primaryKeys);
if (!identityColumnIndexes) return { eligible: false, reason: "missing-identity-column" };
const identityIndexSet = new Set(identityColumnIndexes);
const sourceIndexes: number[] = [];
const identityKeys: string[] = [];
const seenIdentityKeys = new Set<string>();
for (const [sourceIndex, changes] of options.dirtyRows) {
if ([...changes.keys()].some((columnIndex) => identityIndexSet.has(columnIndex))) return { eligible: false, reason: "identity-edited" };
const row = options.rows[sourceIndex];
if (!row) return { eligible: false, reason: "missing-source-row" };
const identityKey = dataGridSavedRowIdentityKey(row, identityColumnIndexes);
if (seenIdentityKeys.has(identityKey)) return { eligible: false, reason: "duplicate-identity" };
seenIdentityKeys.add(identityKey);
sourceIndexes.push(sourceIndex);
identityKeys.push(identityKey);
}
return {
eligible: true,
plan: {
sourceIndexes,
identityColumns: [...options.primaryKeys],
identityColumnIndexes,
identityKeys,
},
};
}
function refreshedColumnMapping(columns: readonly string[], sourceColumns: readonly (string | undefined)[] | undefined, refreshedColumns: readonly string[]): number[] | null {
if (columns.length === refreshedColumns.length && columns.every((column, index) => normalizeColumnName(sourceColumns?.[index] ?? column) === normalizeColumnName(refreshedColumns[index]))) {
return columns.map((_, index) => index);
}
const usedIndexes = new Set<number>();
const mapping = columns.map((column, index) => {
const target = normalizeColumnName(sourceColumns?.[index] ?? column);
const refreshedIndex = refreshedColumns.findIndex((candidate, candidateIndex) => !usedIndexes.has(candidateIndex) && normalizeColumnName(candidate) === target);
if (refreshedIndex >= 0) usedIndexes.add(refreshedIndex);
return refreshedIndex;
});
return mapping.every((index) => index >= 0) ? mapping : null;
}
export function dataGridSavedRowRefreshPatches(plan: DataGridSavedRowRefreshPlan, columns: readonly string[], sourceColumns: readonly (string | undefined)[] | undefined, refreshedColumns: readonly string[], refreshedRows: readonly (readonly CellValue[])[]): DataGridSavedRowRefreshPatch[] | null {
const refreshedIdentityIndexes = resolveColumnIndexes(refreshedColumns, undefined, plan.identityColumns);
const columnMapping = refreshedColumnMapping(columns, sourceColumns, refreshedColumns);
if (!refreshedIdentityIndexes || !columnMapping) return null;
const refreshedRowsByIdentity = new Map<string, { refreshedRowIndex: number; row: readonly CellValue[] }>();
for (const [refreshedRowIndex, row] of refreshedRows.entries()) {
const identityKey = dataGridSavedRowIdentityKey(row, refreshedIdentityIndexes);
if (refreshedRowsByIdentity.has(identityKey)) return null;
refreshedRowsByIdentity.set(identityKey, { refreshedRowIndex, row });
}
if (refreshedRowsByIdentity.size !== plan.identityKeys.length) return null;
const patches: DataGridSavedRowRefreshPatch[] = [];
for (let index = 0; index < plan.identityKeys.length; index++) {
const refreshed = refreshedRowsByIdentity.get(plan.identityKeys[index]!);
if (!refreshed) return null;
patches.push({
sourceIndex: plan.sourceIndexes[index]!,
refreshedRowIndex: refreshed.refreshedRowIndex,
row: columnMapping.map((columnIndex) => refreshed.row[columnIndex] ?? null),
});
}
return patches;
}