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

@@ -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;
}