fix(grid): replace matches in truncated large values
This commit is contained in:
6 files changed
+157
-24
No files matched your search
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user