fix(grid): refresh saved rows in place
This commit is contained in:
6 files changed
+535
-7
No files matched your search
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user