fix(grid): replace matches in truncated large values

This commit is contained in:
ccq authored and GitHub committed 2026-09-18 22:48:16 +08:00
1 parent 4699295ed6
commit 8bafe0916c
6 files changed
+157 -24

No files matched your search

+30 -19
View File
@@ -311,7 +311,7 @@ import { createDataGridRuntimeScope } from "@/lib/dataGrid/dataGridRuntime";
import { useDataGridEditor } from "@/composables/useDataGridEditor";
import { useDataGridSort } from "@/composables/useDataGridSort";
import { useDataGridSearch, type DataGridSearchMatch } from "@/composables/useDataGridSearch";
import { findDataGridReplacementMatches, replaceDataGridText, type DataGridReplaceScope } from "@/lib/dataGrid/dataGridReplace";
import { findDataGridReplacementMatches, prepareDataGridCellReplacements, type DataGridReplaceScope } from "@/lib/dataGrid/dataGridReplace";
import { useDataGridResultLifecycle } from "@/composables/useDataGridResultLifecycle";
import { useDataGridAutoRefresh } from "@/composables/useDataGridAutoRefresh";
import { useDataGridAsyncSurface } from "@/composables/useDataGridAsyncSurface";
@@ -8160,29 +8160,19 @@ function selectedRangeTargetsOnlyDraftRow(): boolean {
}
const replaceAvailable = computed(() => !!props.editable && hasDataGridSaveTarget.value && canEditExistingRows.value && !resolvedConnectionConfig.value?.read_only && !isConditionalUpdateActive.value);
const replaceBusy = computed(() => isSaving.value || gridSurfaceBusy.value || props.loading === true);
const replaceResolving = ref(false);
const replaceBusy = computed(() => replaceResolving.value || isSaving.value || gridSurfaceBusy.value || props.loading === true);
function replacementRowItem(rowId: number): RowItem | undefined {
const row = props.result.rows[rowId];
if (!row || rowId < 0) return undefined;
return { id: rowId, displayIndex: displayRowIndexById(rowId), sourceIndex: rowId, data: rowDataWithChanges(row, rowId), isNew: false, isDeleted: deletedRows.value.has(rowId), isDirtyCol: [], status: dirtyRows.value.has(rowId) ? "edited" : "clean" };
const dirty = dirtyRows.value.get(rowId);
return { id: rowId, displayIndex: displayRowIndexById(rowId), sourceIndex: rowId, data: rowDataWithChanges(row, rowId), isNew: false, isDeleted: deletedRows.value.has(rowId), isDirtyCol: dirtyColumnsForRow(dirty, props.result.columns.length), status: dirty?.size ? "edited" : "clean" };
}
function canReplaceGridCell(item: RowItem | undefined, col: number): boolean {
const type = allColumnTypes.value[col];
return (
replaceAvailable.value &&
!!item &&
item.sourceIndex !== undefined &&
!item.isNew &&
!item.isDraft &&
typeof item.data[col] === "string" &&
canEditCellItem(item, col) &&
!isLargeValuePreview(item, col) &&
!isBinaryCellColumnType(type) &&
!isNumericColumnType(type) &&
!isBooleanGridCell(item, col)
);
return replaceAvailable.value && !!item && item.sourceIndex !== undefined && !item.isNew && !item.isDraft && typeof item.data[col] === "string" && canEditCellItem(item, col) && !isBinaryCellColumnType(type) && !isNumericColumnType(type) && !isBooleanGridCell(item, col);
}
function replacementCellInScope(rowId: number, col: number): boolean {
@@ -8227,14 +8217,35 @@ watch(
},
);
function replaceGridMatches(currentOnly = false) {
async function replaceGridMatches(currentOnly = false) {
if (!replaceAvailable.value || replaceBusy.value) return;
if (currentOnly && !canReplaceCurrent.value) return;
const current = currentSearchMatch.value;
const currentRowId = current?.kind === "cell" ? displayItemAt(current.displayRow)?.id : undefined;
const matches = replacementMatches.value.filter((match) => !currentOnly || (match.rowId === currentRowId && match.col === current?.col));
const count = stageCellReplacements(matches.map((match) => ({ ...match, previousValue: match.value, value: replaceDataGridText(match.value, deferredClientSearchText.value, replacementText.value, replaceCaseSensitive.value) })));
if (count > 0) toast(t("grid.replaceStagedCells", { count }), 5000);
if (!matches.length) return;
const sourceResult = props.result;
const search = deferredClientSearchText.value;
const replacement = replacementText.value;
const caseSensitive = replaceCaseSensitive.value;
replaceResolving.value = true;
try {
const changes = await prepareDataGridCellReplacements({
matches,
search,
replacement,
caseSensitive,
needsResolution: (rowId, col) => isLargeValuePreview(replacementRowItem(rowId), col),
resolveValues: resolveLargeValueCells,
});
if (props.result !== sourceResult) return;
const count = stageCellReplacements(changes);
if (count > 0) toast(t("grid.replaceStagedCells", { count }), 5000);
} catch (error) {
if (props.result === sourceResult) reportLargeValueLoadError(error);
} finally {
replaceResolving.value = false;
}
}
function fillSelectionWithValue(value: string | null, options: { preserveEmptyString?: boolean; emptyStringAsNull?: boolean } = {}): boolean {
@@ -0,0 +1,23 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const dataGridSource = readFileSync(new URL("../DataGrid.vue", import.meta.url), "utf8");
function functionSource(name: string, nextName: string): string {
const start = dataGridSource.indexOf(`function ${name}`);
const relativeEnd = dataGridSource.slice(start).search(new RegExp(`\\n(?:async\\s+)?function\\s+${nextName}\\b`));
expect(start).toBeGreaterThanOrEqual(0);
expect(relativeEnd).toBeGreaterThan(0);
return dataGridSource.slice(start, start + relativeEnd);
}
describe("DataGrid large-value replacement", () => {
it("counts preview matches and resolves full values before staging replacements", () => {
const eligibilitySource = functionSource("canReplaceGridCell", "replacementCellInScope");
const replaceSource = functionSource("replaceGridMatches", "fillSelectionWithValue");
expect(eligibilitySource).not.toContain("!isLargeValuePreview");
expect(replaceSource).toContain("await prepareDataGridCellReplacements");
expect(replaceSource).toContain("resolveLargeValueCells");
});
});
@@ -213,6 +213,24 @@ describe("useDataGridEditor searched replacements", () => {
expect(mocks.executeBatch).not.toHaveBeenCalled();
});
it("stages a resolved large-value replacement while the source row still contains its preview", () => {
const editor = createEditor(undefined, true, undefined, undefined, [["prefix hit…"]]);
editor.newRows.value = [];
expect(
editor.stageCellReplacements([
{
rowId: 0,
col: 0,
sourceValue: "prefix hit…",
previousValue: "prefix hit suffix",
value: "prefix done suffix",
},
]),
).toBe(1);
expect(editor.dirtyRows.value.get(0)?.get(0)).toBe("prefix done suffix");
});
it("rejects readonly, stale, deleted, non-string and out-of-range targets", () => {
const editor = createEditor(
["first", undefined, "last"],
@@ -1071,12 +1071,12 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
beginBatch();
try {
for (const change of changes) {
const { rowId, col, previousValue, value } = change;
const { rowId, col, previousValue, sourceValue = previousValue, value } = change;
if (!Number.isInteger(rowId) || rowId < 0 || !Number.isInteger(col) || col < 0 || col >= result.value.columns.length || !canEditColumn(col) || deletedRows.value.has(rowId)) continue;
const row = result.value.rows[rowId];
if (!row) continue;
const data = rowDataWithChanges(row, rowId);
if (typeof data[col] !== "string" || data[col] !== previousValue || value === previousValue) continue;
if (typeof data[col] !== "string" || data[col] !== sourceValue || value === previousValue) continue;
const item: RowItem = { id: rowId, sourceIndex: rowId, data, isNew: false, isDeleted: false, isDirtyCol: [], status: "clean" };
applyCellValueToItem(item, rowId, col, value, { preserveEmptyString: true });
const currentValue = rowDataWithChanges(row, rowId)[col];
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { findDataGridReplacementMatches, replaceDataGridText } from "@/lib/dataGrid/dataGridReplace";
import { describe, expect, it, vi } from "vitest";
import { findDataGridReplacementMatches, prepareDataGridCellReplacements, replaceDataGridText } from "@/lib/dataGrid/dataGridReplace";
describe("data grid searched text replacement", () => {
it("replaces repeated JSON fragments without changing the rest of the value", () => {
@@ -42,4 +42,46 @@ describe("data grid searched text replacement", () => {
expect(findDataGridReplacementMatches({ rows, search: "hit", includesCell: () => false })).toEqual([]);
expect(findDataGridReplacementMatches({ rows, search: "" })).toEqual([]);
});
it("loads complete large values once and replaces the complete text", async () => {
const resolveValues = vi.fn(async () => new Map([[7, new Map([[0, "prefix pujiang-pujiang suffix"]])]]));
await expect(
prepareDataGridCellReplacements({
matches: [{ rowId: 7, col: 0, value: "prefix pujiang-pujiang…" }],
search: "pujiang-pujiang",
replacement: "test-pj-charge",
caseSensitive: false,
needsResolution: () => true,
resolveValues,
}),
).resolves.toEqual([
{
rowId: 7,
col: 0,
sourceValue: "prefix pujiang-pujiang…",
previousValue: "prefix pujiang-pujiang suffix",
value: "prefix test-pj-charge suffix",
},
]);
expect(resolveValues).toHaveBeenCalledWith([7], [0]);
});
it("revalidates resolved values and skips stale preview matches", async () => {
const resolveValues = vi.fn(async () => new Map([[7, new Map([[0, "value changed on reload"]])]]));
await expect(
prepareDataGridCellReplacements({
matches: [
{ rowId: 7, col: 0, value: "preview hit" },
{ rowId: 2, col: 1, value: "ordinary hit" },
],
search: "hit",
replacement: "done",
caseSensitive: false,
needsResolution: (rowId) => rowId === 7,
resolveValues,
}),
).resolves.toEqual([{ rowId: 2, col: 1, previousValue: "ordinary hit", value: "ordinary done" }]);
});
});
@@ -1,7 +1,7 @@
import type { CellValue } from "@/lib/dataGrid/cellValue";
export type DataGridReplacementMatch = { rowId: number; col: number; value: string };
export type DataGridCellReplacement = DataGridReplacementMatch & { previousValue: string };
export type DataGridCellReplacement = DataGridReplacementMatch & { previousValue: string; sourceValue?: string };
export type DataGridReplaceScope = "loaded" | "column" | "selection";
type ReplacementMatchOptions = {
@@ -14,6 +14,15 @@ type ReplacementMatchOptions = {
isTruncated?: (rowId: number, col: number) => boolean;
};
type PrepareCellReplacementsOptions = {
matches: readonly DataGridReplacementMatch[];
search: string;
replacement: string;
caseSensitive: boolean;
needsResolution: (rowId: number, col: number) => boolean;
resolveValues: (rowIds: number[], columnIndexes: number[]) => Promise<Map<number, Map<number, CellValue>>>;
};
export function dataGridReplacementPattern(search: string, caseSensitive: boolean): RegExp {
return new RegExp(search.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), caseSensitive ? "gu" : "giu");
}
@@ -40,3 +49,33 @@ export function findDataGridReplacementMatches(options: ReplacementMatchOptions)
}
return matches;
}
export async function prepareDataGridCellReplacements(options: PrepareCellReplacementsOptions): Promise<DataGridCellReplacement[]> {
if (!options.search || options.matches.length === 0) return [];
const resolutionMatches = options.matches.filter((match) => options.needsResolution(match.rowId, match.col));
const resolutionKeys = new Set(resolutionMatches.map((match) => `${match.rowId}:${match.col}`));
const resolvedValues = resolutionMatches.length ? await options.resolveValues([...new Set(resolutionMatches.map((match) => match.rowId))], [...new Set(resolutionMatches.map((match) => match.col))]) : new Map<number, Map<number, CellValue>>();
const pattern = dataGridReplacementPattern(options.search, options.caseSensitive);
const replacements: DataGridCellReplacement[] = [];
for (const match of options.matches) {
const needsResolution = resolutionKeys.has(`${match.rowId}:${match.col}`);
const resolvedRow = resolvedValues.get(match.rowId);
if (needsResolution && !resolvedRow?.has(match.col)) continue;
const previousValue = needsResolution ? resolvedRow?.get(match.col) : match.value;
if (typeof previousValue !== "string") continue;
pattern.lastIndex = 0;
if (!pattern.test(previousValue)) continue;
const value = replaceDataGridText(previousValue, options.search, options.replacement, options.caseSensitive);
if (value === previousValue) continue;
replacements.push({
rowId: match.rowId,
col: match.col,
previousValue,
value,
...(needsResolution ? { sourceValue: match.value } : {}),
});
}
return replacements;
}