feat(grid): add find and replace for query results

This commit is contained in:
caichangqing1120 authored and GitHub committed 2026-09-17 14:21:41 +08:00
1 parent d17ce46626
commit 8567e50887
19 files changed
+747 -81

No files matched your search

+115 -2
View File
@@ -305,6 +305,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 { useDataGridResultLifecycle } from "@/composables/useDataGridResultLifecycle";
import { useDataGridAutoRefresh } from "@/composables/useDataGridAutoRefresh";
import { useDataGridAsyncSurface } from "@/composables/useDataGridAsyncSurface";
@@ -1012,11 +1013,21 @@ const transposeScrollLeft = ref(0);
const transposeViewportWidth = ref(0);
const { sortColumn: sortCol, sortColumnIndex: sortColIndex, sortDirection: sortDir, sortMode, setSort, clearSort } = useDataGridSort();
const searchBarRef = ref<{ focus: (select?: boolean) => void } | null>(null);
const replaceOpen = ref(false);
const replacementText = ref("");
const replaceScope = ref<DataGridReplaceScope>("loaded");
const replaceCaseSensitive = ref(false);
const replaceColumn = ref(-1);
const dataGridSearch = useDataGridSearch({
columns: () => props.result.columns,
suggestionColumns: () => props.tableMeta?.columns.map((column) => column.name) ?? props.result.columns,
rows: () => displayItems.value,
getCellSearchText: (row, columnIndex) => (row.data[columnIndex] === null ? "" : rowLowerTextCache.get(row.data, columnIndex)),
getCellRawSearchText: (row, columnIndex) => (typeof row.data[columnIndex] === "string" ? (row.data[columnIndex] as string) : replaceOpen.value ? "" : String(row.data[columnIndex] ?? "")),
caseSensitive: () => replaceOpen.value && replaceCaseSensitive.value,
literalQuery: replaceOpen,
includeColumnMatches: () => !replaceOpen.value,
isCellSearchable: (row, columnIndex) => !replaceOpen.value || (canReplaceGridCell(row, columnIndex) && replacementCellInScope(row.id, columnIndex)),
onNavigate: () => nextTick(scrollToCurrentMatch),
// Same key as useDataGridEditor below: table data tabs use the tab id, query
// results use resultGridInstanceKey so a re-execute starts with a clean search.
@@ -1724,12 +1735,23 @@ function focusSearch(target: Element | null = null): boolean {
}
function closeSearch() {
replaceOpen.value = false;
dataGridSearch.close();
}
const PAIRS: Record<string, string> = { "'": "'", '"': '"', "(": ")" };
function onSearchKeydown(e: KeyboardEvent) {
if (replaceOpen.value) {
if (isCancelSearchShortcut(e)) {
e.preventDefault();
closeSearch();
} else if (e.key === "Enter") {
e.preventDefault();
navigateMatch(e.shiftKey ? -1 : 1);
}
return;
}
if (e.key in PAIRS && !e.ctrlKey && !e.metaKey) {
const input = e.target as HTMLInputElement;
const start = input.selectionStart ?? 0;
@@ -3704,6 +3726,7 @@ const {
commitEditAndMaybeAutoSave,
commitEditFromBlur,
applyCellValue,
stageCellReplacements,
restoreCellValue,
cancelEdit,
onEditKeydown,
@@ -4367,7 +4390,7 @@ const rollbackToolbarCapability = computed<DataGridToolbarActionCapability>(() =
const sortedRows = computed(() => {
let indices = localFilteredRows.value;
const q = deferredClientSearchText.value;
if (q && dataGridSearchMode.value === "filter") {
if (q && dataGridSearchMode.value === "filter" && !replaceOpen.value) {
// Preserve the legacy Ctrl+F behavior when the user chooses row filtering.
const rows = props.result.rows;
indices = indices.filter((sourceIndex) => {
@@ -4771,7 +4794,7 @@ const deleteRowDetails = computed(() => {
});
const hasVisibleRows = computed(() => displayRowCount.value > 0);
const hasActiveFilter = computed(() => (dataGridSearchMode.value === "filter" && !!deferredClientSearchText.value) || rowStatusFilter.value !== "all" || hasLocalColumnFilters.value || hasServerColumnFilters.value);
const hasActiveFilter = computed(() => (dataGridSearchMode.value === "filter" && !replaceOpen.value && !!deferredClientSearchText.value) || rowStatusFilter.value !== "all" || hasLocalColumnFilters.value || hasServerColumnFilters.value);
const emptyTitle = computed(() => (hasActiveFilter.value ? t("grid.noFilteredRows") : t("grid.noRows")));
const emptyDescription = computed(() => (hasActiveFilter.value ? t("grid.noFilteredRowsDescription") : t("grid.noRowsDescription")));
watch(
@@ -8059,6 +8082,84 @@ function selectedRangeTargetsOnlyDraftRow(): boolean {
return displayItemAt(range.startRow)?.isDraft === true;
}
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);
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" };
}
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)
);
}
function replacementCellInScope(rowId: number, col: number): boolean {
if (replaceScope.value === "loaded") return true;
if (replaceScope.value === "column") return col === replaceColumn.value;
const rowIndex = displayRowIndexById(rowId);
const visibleCol = visibleColumnIndexes.value.indexOf(col);
return rowIndex >= 0 && visibleCol >= 0 && (cellIsSelected(rowIndex, visibleCol) || isRowSelected(rowId) || columnIsSelected(visibleCol));
}
const replacementMatches = computed(() => {
if (!replaceOpen.value || !replaceAvailable.value) return [];
const items = new Map(props.result.rows.map((_, rowId) => [rowId, replacementRowItem(rowId)!]));
return findDataGridReplacementMatches({
rows: [...items.values()].map((item) => ({ rowId: item.id, data: item.data })),
search: deferredClientSearchText.value,
caseSensitive: replaceCaseSensitive.value,
includesCell: replacementCellInScope,
canReplaceCell: (rowId, col) => canReplaceGridCell(items.get(rowId), col),
});
});
const canReplaceCurrent = computed(() => {
if (searchText.value !== deferredClientSearchText.value) return false;
const match = currentSearchMatch.value;
if (!match || match.kind !== "cell") return false;
const item = displayItemAt(match.displayRow);
return !!item && replacementMatches.value.some((candidate) => candidate.rowId === item.id && candidate.col === match.col);
});
watch([replaceOpen, replaceScope], () => {
if (!replaceOpen.value || replaceScope.value !== "column") return;
const selectionCol = selectionFocus.value?.colIndex ?? [...selectedColumnIndexes.value][0];
replaceColumn.value = selectionCol !== null && selectionCol !== undefined ? actualColumnIndex(selectionCol) : (currentSearchMatch.value?.col ?? visibleColumnIndexes.value[0] ?? -1);
});
watch(
() => props.result,
() => {
replaceOpen.value = false;
replaceColumn.value = -1;
},
);
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);
}
function fillSelectionWithValue(value: string | null, options: { preserveEmptyString?: boolean; emptyStringAsNull?: boolean } = {}): boolean {
const range = selectedRange.value;
let applied = false;
@@ -11842,6 +11943,16 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val
<DataGridSearchBar
ref="searchBarRef"
v-model:text="searchText"
v-model:replace-open="replaceOpen"
v-model:replacement-text="replacementText"
v-model:replace-scope="replaceScope"
v-model:case-sensitive="replaceCaseSensitive"
v-model:replace-column="replaceColumn"
:replace-available="replaceAvailable"
:replace-busy="replaceBusy"
:replace-match-count="replacementMatches.length"
:can-replace-current="canReplaceCurrent"
:columns="props.result.columns"
:open="searchOverlayVisible"
:suggestions="searchSuggestions"
:suggestion-index="suggestionIndex"
@@ -11851,6 +11962,8 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val
:values-truncated="(props.result.large_value_cells?.length ?? 0) > 0"
@keydown="onSearchKeydown"
@navigate="navigateMatch"
@replace-current="replaceGridMatches(true)"
@replace-all="replaceGridMatches()"
@close="closeSearch"
@accept-suggestion="
suggestionIndex = $event;
@@ -1,29 +1,45 @@
<script setup lang="ts">
import { ref } from "vue";
import { ChevronDown, ChevronUp, Search, X } from "@lucide/vue";
import { CaseSensitive, ChevronDown, ChevronRight, ChevronUp, Search, X } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import { vNamingStyleSupport } from "@/directives/vNamingStyleSupport";
import type { DataGridReplaceScope } from "@/lib/dataGrid/dataGridReplace";
const { t } = useI18n();
const props = defineProps<{
open: boolean;
suggestions: string[];
suggestionIndex: number;
matchCount: number;
currentMatchIndex: number;
hasDeferredSearchText: boolean;
/** 结果里含被截断显示的长值(large_value_cells),搜索只覆盖显示文本。 */
valuesTruncated?: boolean;
}>();
const props = withDefaults(
defineProps<{
open: boolean;
suggestions: string[];
suggestionIndex: number;
matchCount: number;
currentMatchIndex: number;
hasDeferredSearchText: boolean;
/** 结果里含被截断显示的长值(large_value_cells),搜索只覆盖显示文本。 */
valuesTruncated?: boolean;
replaceAvailable?: boolean;
replaceBusy?: boolean;
replaceMatchCount?: number;
canReplaceCurrent?: boolean;
columns?: string[];
}>(),
{ replaceAvailable: undefined },
);
const searchText = defineModel<string>("text", { default: "" });
const replaceOpen = defineModel<boolean>("replaceOpen", { default: false });
const replacementText = defineModel<string>("replacementText", { default: "" });
const replaceScope = defineModel<DataGridReplaceScope>("replaceScope", { default: "loaded" });
const caseSensitive = defineModel<boolean>("caseSensitive", { default: false });
const replaceColumn = defineModel<number>("replaceColumn", { default: -1 });
const emit = defineEmits<{
keydown: [event: KeyboardEvent];
close: [];
navigate: [delta: number];
acceptSuggestion: [index: number];
hoverSuggestion: [index: number];
replaceCurrent: [];
replaceAll: [];
}>();
const searchInput = ref<HTMLInputElement>();
@@ -48,66 +64,124 @@ defineExpose({
<template>
<Transition enter-active-class="transition-opacity duration-150" leave-active-class="transition-opacity duration-100" enter-from-class="opacity-0" leave-to-class="opacity-0">
<div v-if="props.open" class="absolute top-1 right-2 z-20 flex items-center gap-1 px-2 py-1 bg-background border rounded-md shadow-md">
<Search class="w-3.5 h-3.5 text-muted-foreground shrink-0" />
<input
ref="searchInput"
v-model="searchText"
v-naming-style-support
type="search"
autocapitalize="off"
autocomplete="off"
autocorrect="off"
spellcheck="false"
class="w-48 h-5 min-w-0 text-xs bg-transparent outline-none placeholder:text-muted-foreground"
:placeholder="t('grid.search')"
@keydown="emit('keydown', $event)"
/>
<div v-if="props.suggestions.length > 0" class="absolute top-full right-0 mt-0.5 z-50 min-w-[180px] rounded-md border bg-popover text-popover-foreground shadow-md">
<div
v-for="(suggestion, index) in props.suggestions"
:key="suggestion"
class="flex items-center px-3 py-1.5 text-xs cursor-pointer"
:class="index === props.suggestionIndex ? 'bg-accent text-accent-foreground' : 'hover:bg-gray-200 dark:hover:bg-gray-800'"
@mousedown="onSuggestionMouseDown($event, index)"
@mouseenter="emit('hoverSuggestion', index)"
<div v-if="props.open" data-grid-search-bar class="absolute top-1 right-2 z-20 max-w-[calc(100%-1rem)] px-2 py-1 bg-background border rounded-md shadow-md" @keydown.esc.stop.prevent="emit('close')">
<div class="flex items-center gap-1 min-w-0">
<button
v-if="props.replaceAvailable !== undefined"
data-grid-replace-toggle
type="button"
class="shrink-0 text-muted-foreground hover:text-foreground"
:disabled="!props.replaceAvailable"
:title="t(replaceOpen ? 'editor.search.collapseReplace' : 'editor.search.expandReplace')"
:aria-label="t(replaceOpen ? 'editor.search.collapseReplace' : 'editor.search.expandReplace')"
:aria-expanded="replaceOpen"
@click="replaceOpen = !replaceOpen"
>
<Search class="w-3 h-3 mr-2 text-muted-foreground shrink-0" />
<span>{{ suggestion }}</span>
<ChevronDown v-if="replaceOpen" class="w-3.5 h-3.5" />
<ChevronRight v-else class="w-3.5 h-3.5" />
</button>
<Search class="w-3.5 h-3.5 text-muted-foreground shrink-0" />
<input
ref="searchInput"
v-model="searchText"
v-naming-style-support
type="search"
autocapitalize="off"
autocomplete="off"
autocorrect="off"
spellcheck="false"
class="w-48 h-5 min-w-0 flex-1 text-xs bg-transparent outline-none placeholder:text-muted-foreground"
:placeholder="t('grid.search')"
@keydown="emit('keydown', $event)"
/>
<button
v-if="replaceOpen"
data-grid-replace-case
type="button"
class="shrink-0 rounded p-0.5 text-muted-foreground hover:text-foreground"
:class="caseSensitive ? 'bg-accent text-accent-foreground' : ''"
:title="t('editor.search.caseSensitive')"
:aria-label="t('editor.search.caseSensitive')"
:aria-pressed="caseSensitive"
@click="caseSensitive = !caseSensitive"
>
<CaseSensitive class="w-3.5 h-3.5" />
</button>
<div v-if="props.suggestions.length > 0" class="absolute top-full right-0 mt-0.5 z-50 min-w-[180px] rounded-md border bg-popover text-popover-foreground shadow-md">
<div
v-for="(suggestion, index) in props.suggestions"
:key="suggestion"
class="flex items-center px-3 py-1.5 text-xs cursor-pointer"
:class="index === props.suggestionIndex ? 'bg-accent text-accent-foreground' : 'hover:bg-gray-200 dark:hover:bg-gray-800'"
@mousedown="onSuggestionMouseDown($event, index)"
@mouseenter="emit('hoverSuggestion', index)"
>
<Search class="w-3 h-3 mr-2 text-muted-foreground shrink-0" />
<span>{{ suggestion }}</span>
</div>
</div>
</div>
<!-- 截断标记:表格预览对长文本/JSON 值做服务端截断,客户端搜索只能覆盖
<!-- 截断标记:表格预览对长文本/JSON 值做服务端截断,客户端搜索只能覆盖
显示前缀,匹配数可能少于 SQL 查询(#7279)。 -->
<span v-if="props.valuesTruncated && (props.matchCount > 0 || props.hasDeferredSearchText)" data-grid-search-truncated-hint class="text-xs text-amber-600 dark:text-amber-400 shrink-0 cursor-help" :title="t('grid.searchTruncatedValuesHint')" :aria-label="t('grid.searchTruncatedValuesHint')"
>≈</span
>
<span v-if="props.matchCount > 0" class="text-xs text-muted-foreground shrink-0">{{ props.currentMatchIndex + 1 }}/{{ props.matchCount }}</span>
<span v-else-if="props.hasDeferredSearchText" class="text-xs text-muted-foreground shrink-0">0</span>
<button
type="button"
class="text-muted-foreground hover:text-foreground disabled:opacity-40 disabled:pointer-events-none shrink-0"
:disabled="props.matchCount === 0"
:title="t('search.prevMatch')"
:aria-label="t('search.prevMatch')"
@mousedown="keepSearchInputFocused"
@click="emit('navigate', -1)"
>
<ChevronUp class="w-3.5 h-3.5" />
</button>
<button
type="button"
class="text-muted-foreground hover:text-foreground disabled:opacity-40 disabled:pointer-events-none shrink-0"
:disabled="props.matchCount === 0"
:title="t('search.nextMatch')"
:aria-label="t('search.nextMatch')"
@mousedown="keepSearchInputFocused"
@click="emit('navigate', 1)"
>
<ChevronDown class="w-3.5 h-3.5" />
</button>
<button type="button" class="text-muted-foreground hover:text-foreground shrink-0" :title="t('search.close')" :aria-label="t('search.close')" @click="emit('close')">
<X class="w-3.5 h-3.5" />
</button>
<span v-if="props.valuesTruncated && (props.matchCount > 0 || props.hasDeferredSearchText)" data-grid-search-truncated-hint class="text-xs text-amber-600 dark:text-amber-400 shrink-0 cursor-help" :title="t('grid.searchTruncatedValuesHint')" :aria-label="t('grid.searchTruncatedValuesHint')"
>≈</span
>
<span v-if="props.matchCount > 0" class="text-xs text-muted-foreground shrink-0">{{ props.currentMatchIndex + 1 }}/{{ props.matchCount }}</span>
<span v-else-if="props.hasDeferredSearchText" class="text-xs text-muted-foreground shrink-0">0</span>
<button
type="button"
class="text-muted-foreground hover:text-foreground disabled:opacity-40 disabled:pointer-events-none shrink-0"
:disabled="props.matchCount === 0"
:title="t('editor.search.prevMatch')"
:aria-label="t('editor.search.prevMatch')"
@mousedown="keepSearchInputFocused"
@click="emit('navigate', -1)"
>
<ChevronUp class="w-3.5 h-3.5" />
</button>
<button
type="button"
class="text-muted-foreground hover:text-foreground disabled:opacity-40 disabled:pointer-events-none shrink-0"
:disabled="props.matchCount === 0"
:title="t('editor.search.nextMatch')"
:aria-label="t('editor.search.nextMatch')"
@mousedown="keepSearchInputFocused"
@click="emit('navigate', 1)"
>
<ChevronDown class="w-3.5 h-3.5" />
</button>
<button type="button" class="text-muted-foreground hover:text-foreground shrink-0" :title="t('editor.search.close')" :aria-label="t('editor.search.close')" @click="emit('close')">
<X class="w-3.5 h-3.5" />
</button>
</div>
<div v-if="replaceOpen" class="flex flex-wrap items-center gap-1 mt-1 border-t pt-1">
<input
v-model="replacementText"
data-grid-replacement-input
type="text"
autocomplete="off"
spellcheck="false"
class="h-6 min-w-0 w-48 flex-1 text-xs bg-transparent outline-none border rounded px-1.5"
:placeholder="t('grid.replaceText')"
:aria-label="t('grid.replaceText')"
@keydown.enter.stop.prevent="props.replaceAvailable && !props.replaceBusy && props.canReplaceCurrent && emit('replaceCurrent')"
/>
<button data-grid-replace-current type="button" class="h-6 border rounded px-2 text-xs disabled:opacity-40" :disabled="!props.replaceAvailable || props.replaceBusy || !props.canReplaceCurrent" :title="t('grid.replaceCurrentCell')" @click="emit('replaceCurrent')">
{{ t("editor.search.replace") }}
</button>
<button data-grid-replace-all type="button" class="h-6 border rounded px-2 text-xs disabled:opacity-40" :disabled="!props.replaceAvailable || props.replaceBusy || !props.replaceMatchCount" @click="emit('replaceAll')">{{ t("editor.search.replaceAll") }}</button>
</div>
<div v-if="replaceOpen" class="flex flex-wrap items-center gap-1 mt-1 text-xs">
<select v-model="replaceScope" data-grid-replace-scope class="min-w-0 h-6 max-w-full rounded border bg-background px-1" :aria-label="t('grid.replaceScope')">
<option value="loaded">{{ t("grid.replaceLoadedResults") }}</option>
<option value="column">{{ t("grid.replaceCurrentColumn") }}</option>
<option value="selection">{{ t("grid.replaceSelectedCells") }}</option>
</select>
<select v-if="replaceScope === 'column'" v-model.number="replaceColumn" data-grid-replace-column class="h-6 min-w-0 flex-1 max-w-48 rounded border bg-background px-1" :aria-label="t('grid.replaceCurrentColumn')">
<option :value="-1" disabled>{{ t("grid.replaceChooseColumn") }}</option>
<option v-for="(column, index) in props.columns" :key="index" :value="index">{{ column }}</option>
</select>
<span class="text-muted-foreground" aria-live="polite">{{ t("grid.replaceMatchedCells", { count: props.replaceMatchCount ?? 0 }) }}</span>
</div>
</div>
</Transition>
</template>
@@ -0,0 +1,86 @@
// @vitest-environment happy-dom
import { createApp, h, nextTick } from "vue";
import { afterEach, describe, expect, it, vi } from "vitest";
import DataGridSearchBar from "../DataGridSearchBar.vue";
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) }));
const cleanup: (() => void)[] = [];
afterEach(() => cleanup.splice(0).forEach((dispose) => dispose()));
function mountBar(overrides: Record<string, unknown> = {}) {
const root = document.createElement("div");
document.body.append(root);
const replaceAll = vi.fn();
const replaceCurrent = vi.fn();
const changeText = vi.fn();
const changeScope = vi.fn();
const changeCase = vi.fn();
const app = createApp({
render: () =>
h(DataGridSearchBar, {
open: true,
suggestions: [],
suggestionIndex: -1,
matchCount: 2,
currentMatchIndex: 0,
hasDeferredSearchText: true,
replaceOpen: true,
replaceAvailable: true,
replaceMatchCount: 2,
canReplaceCurrent: true,
columns: ["first", "paths"],
onReplaceAll: replaceAll,
onReplaceCurrent: replaceCurrent,
"onUpdate:replacementText": changeText,
"onUpdate:replaceScope": changeScope,
"onUpdate:caseSensitive": changeCase,
...overrides,
}),
});
app.mount(root);
cleanup.push(() => {
app.unmount();
root.remove();
});
return { root, replaceAll, replaceCurrent, changeText, changeScope, changeCase };
}
describe("DataGridSearchBar replacement controls", () => {
it("emits literal empty replacement input, scope and case choices and replacement commands", async () => {
const bar = mountBar();
const input = bar.root.querySelector<HTMLInputElement>("[data-grid-replacement-input]")!;
expect(input).not.toBeNull();
input.value = "$&";
input.dispatchEvent(new Event("input"));
expect(bar.changeText).toHaveBeenCalledWith("$&");
input.value = "";
input.dispatchEvent(new Event("input"));
expect(bar.changeText).toHaveBeenLastCalledWith("");
const scope = bar.root.querySelector<HTMLSelectElement>("[data-grid-replace-scope]")!;
scope.value = "selection";
scope.dispatchEvent(new Event("change"));
expect(bar.changeScope).toHaveBeenCalledWith("selection");
bar.root.querySelector<HTMLButtonElement>("[data-grid-replace-case]")!.click();
expect(bar.changeCase).toHaveBeenCalledWith(true);
bar.root.querySelector<HTMLButtonElement>("[data-grid-replace-all]")!.click();
bar.root.querySelector<HTMLButtonElement>("[data-grid-replace-current]")!.click();
expect(bar.replaceAll).toHaveBeenCalledOnce();
expect(bar.replaceCurrent).toHaveBeenCalledOnce();
await nextTick();
});
it("disables replacement for readonly/busy results and does not expose it in search-only hosts", () => {
const readonly = mountBar({ replaceAvailable: false });
const button = readonly.root.querySelector<HTMLButtonElement>("[data-grid-replace-all]")!;
expect(button.disabled).toBe(true);
button.click();
expect(readonly.replaceAll).not.toHaveBeenCalled();
const busy = mountBar({ replaceBusy: true });
expect(busy.root.querySelector<HTMLButtonElement>("[data-grid-replace-all]")!.disabled).toBe(true);
const empty = mountBar({ replaceMatchCount: 0, canReplaceCurrent: false });
expect(empty.root.querySelector<HTMLButtonElement>("[data-grid-replace-all]")!.disabled).toBe(true);
const searchOnly = mountBar({ replaceOpen: false, replaceAvailable: undefined });
expect(searchOnly.root.querySelector("[data-grid-replacement-input]")).toBeNull();
expect(searchOnly.root.querySelector("[data-grid-replace-toggle]")).toBeNull();
});
});
@@ -20,6 +20,7 @@ vi.mock("@lucide/vue", async () => {
const icon = createPassthroughStub("Icon", "i");
return {
Check: icon,
CaseSensitive: icon,
ChevronDown: icon,
ChevronUp: icon,
ChevronLeft: icon,
@@ -225,14 +226,14 @@ describe("DataGridSearchBar", () => {
dispatch(suggestion, "mouseenter");
expect(hoverSuggestion).toHaveBeenCalledWith(0);
const previousButton = findOne(mounted.root, (node) => node.props["aria-label"] === "search.prevMatch");
const nextButton = findOne(mounted.root, (node) => node.props["aria-label"] === "search.nextMatch");
const previousButton = findOne(mounted.root, (node) => node.props["aria-label"] === "editor.search.prevMatch");
const nextButton = findOne(mounted.root, (node) => node.props["aria-label"] === "editor.search.nextMatch");
expect(dispatch(previousButton, "mousedown").defaultPrevented).toBe(true);
dispatch(previousButton, "click");
dispatch(nextButton, "click");
expect(navigate.mock.calls).toEqual([[-1], [1]]);
const closeButton = findOne(mounted.root, (node) => node.props["aria-label"] === "search.close");
const closeButton = findOne(mounted.root, (node) => node.props["aria-label"] === "editor.search.close");
dispatch(closeButton, "click");
expect(close).toHaveBeenCalledOnce();
@@ -49,6 +49,7 @@ function createEditorWithResult(
onCellValueChanged?: (rowId: number, columnIndex: number) => void,
tableColumns?: Array<{ name: string; data_type: string; extra?: string; column_default?: string }>,
mongoCollectionGrid = false,
quickEntry = false,
) {
let editor: ReturnType<typeof useDataGridEditor>;
const result = ref<{ columns: string[]; rows: CellValue[][] }>({
@@ -77,6 +78,7 @@ function createEditorWithResult(
onExecuteSql: computed(() => undefined),
sql: computed(() => undefined),
searchText: ref(""),
dataGridQuickEntryEnabled: computed(() => quickEntry),
whereFilterInput: ref(""),
currentWhereInput: computed(() => undefined),
orderByInput: ref(""),
@@ -136,6 +138,122 @@ function createEditor(...args: Parameters<typeof createEditorWithResult>) {
return createEditorWithResult(...args).editor;
}
describe("useDataGridEditor searched replacements", () => {
beforeEach(() => {
mocks.prepareDataGridSave.mockReset();
mocks.executeBatch.mockReset();
mocks.getConfig.mockReturnValue(undefined);
});
it("stages one undoable batch, keeps empty strings and preserves prior pending edits", () => {
const editor = createEditor(undefined, true, undefined, undefined, [["hit", "keep", "hit"]]);
editor.newRows.value = [];
editor.applyCellValue(0, 1, "draft");
expect(
editor.stageCellReplacements([
{ rowId: 0, col: 0, previousValue: "hit", value: "" },
{ rowId: 0, col: 2, previousValue: "hit", value: "new" },
]),
).toBe(2);
expect(editor.dirtyRows.value.get(0)).toEqual(
new Map([
[1, "draft"],
[0, ""],
[2, "new"],
]),
);
expect(editor.manualSaveRequired.value).toBe(true);
editor.undoPendingChange();
expect(editor.dirtyRows.value.get(0)).toEqual(new Map([[1, "draft"]]));
expect(editor.manualSaveRequired.value).toBe(false);
editor.redoPendingChange();
expect(editor.dirtyRows.value.get(0)?.get(0)).toBe("");
expect(editor.manualSaveRequired.value).toBe(true);
expect(mocks.executeBatch).not.toHaveBeenCalled();
});
it("rejects readonly, stale, deleted, non-string and out-of-range targets", () => {
const editor = createEditor(
["first", undefined, "last"],
true,
undefined,
[2],
[
["hit", "hit", "hit"],
[123, null, false],
],
);
editor.newRows.value = [];
editor.deletedRows.value.add(0);
const changes = [
{ rowId: 0, col: 0, previousValue: "hit", value: "x" },
{ rowId: 1, col: 0, previousValue: "123", value: "x" },
{ rowId: 9, col: 0, previousValue: "hit", value: "x" },
];
expect(editor.stageCellReplacements(changes)).toBe(0);
editor.deletedRows.value.clear();
expect(
editor.stageCellReplacements([
{ rowId: 0, col: 0, previousValue: "stale", value: "x" },
{ rowId: 0, col: 1, previousValue: "hit", value: "x" },
{ rowId: 0, col: 2, previousValue: "hit", value: "x" },
{ rowId: -1, col: 0, previousValue: "hit", value: "x" },
]),
).toBe(0);
expect(editor.dirtyRows.value.size).toBe(0);
expect(editor.manualSaveRequired.value).toBe(false);
});
it("blocks later quick-entry autosaves until explicit save or discard", async () => {
const editor = createEditor(undefined, true, undefined, undefined, [["hit", "keep", "hit"]], undefined, undefined, false, true);
editor.newRows.value = [];
editor.stageCellReplacements([{ rowId: 0, col: 0, previousValue: "hit", value: "new" }]);
editor.applyCellValue(0, 1, "later edit");
await editor.saveChanges({ autoSave: true });
expect(mocks.prepareDataGridSave).not.toHaveBeenCalled();
expect(mocks.executeBatch).not.toHaveBeenCalled();
expect(editor.hasPendingChanges.value).toBe(true);
mocks.prepareDataGridSave.mockResolvedValue({ statements: ["UPDATE people SET first='new',hidden='later edit' WHERE last='hit'"], rollbackStatements: [] });
mocks.executeBatch.mockResolvedValue([]);
await editor.saveChanges();
expect(mocks.executeBatch).toHaveBeenCalledOnce();
expect(editor.manualSaveRequired.value).toBe(false);
editor.stageCellReplacements([{ rowId: 0, col: 0, previousValue: "new", value: "other" }]);
editor.discardChanges();
expect(editor.manualSaveRequired.value).toBe(false);
});
it("persists the manual-save interlock across a tab remount", async () => {
const key = "replace-remount";
const rows: CellValue[][] = [["hit", "keep", "hit"]];
const first = createEditor(undefined, true, key, undefined, rows);
first.newRows.value = [];
first.stageCellReplacements([{ rowId: 0, col: 0, previousValue: "hit", value: "new" }]);
first.savePendingSnapshot();
const second = createEditor(undefined, true, key, undefined, rows);
second.newRows.value = [];
expect(second.manualSaveRequired.value).toBe(true);
await second.saveChanges({ autoSave: true });
expect(mocks.executeBatch).not.toHaveBeenCalled();
second.discardChanges();
clearDataGridPendingSnapshot(key);
});
it("clears the manual-save interlock when replacement restores all original values", () => {
const editor = createEditor(undefined, true, undefined, undefined, [["hit", "keep", "hit"]]);
editor.newRows.value = [];
editor.stageCellReplacements([{ rowId: 0, col: 0, previousValue: "hit", value: "new" }]);
editor.stageCellReplacements([{ rowId: 0, col: 0, previousValue: "new", value: "hit" }]);
expect(editor.hasPendingChanges.value).toBe(false);
expect(editor.manualSaveRequired.value).toBe(false);
editor.undoPendingChange();
expect(editor.dirtyRows.value.get(0)?.get(0)).toBe("new");
expect(editor.manualSaveRequired.value).toBe(true);
editor.redoPendingChange();
expect(editor.manualSaveRequired.value).toBe(false);
});
});
function beforeTabSwitchEvent(fromTabId: string, tabId = "next-tab") {
return new CustomEvent("dbx:before-tab-switch", { detail: { tabId, fromTabId } });
}
@@ -15,6 +15,49 @@ async function flushSearchDebounce() {
}
describe("useDataGridSearch", () => {
it("supports literal case-sensitive replacement searches without column-name matches", async () => {
vi.useFakeTimers();
const sensitive = ref(true);
const search = useDataGridSearch({
columns: ["HIT", "value"],
rows: [
["HIT", "hit hit"],
[" hit ", "hit"],
],
getCellSearchText: (row, col) => row[col].toLowerCase(),
getCellRawSearchText: (row, col) => row[col],
caseSensitive: sensitive,
literalQuery: true,
includeColumnMatches: false,
isCellSearchable: (_row, col) => col === 0,
});
search.searchText.value = "HIT";
await flushSearchDebounce();
expect(search.matches.value).toEqual([{ kind: "cell", displayRow: 0, col: 0 }]);
sensitive.value = false;
await flushSearchDebounce();
expect(search.matchCount.value).toBe(2);
search.searchText.value = " hit ";
await flushSearchDebounce();
expect(search.matches.value).toEqual([{ kind: "cell", displayRow: 1, col: 0 }]);
});
it("degrades case-sensitive matching to case-insensitive without a raw-text provider", async () => {
vi.useFakeTimers();
const search = useDataGridSearch({
columns: ["name"],
rows: [["Alice"]],
// No getCellRawSearchText: only the lowercase cache exists, so an
// uppercase query must still match instead of never finding the cell.
getCellSearchText: (row, col) => row[col].toLowerCase(),
caseSensitive: true,
literalQuery: false,
includeColumnMatches: false,
});
search.searchText.value = "ALICE";
await flushSearchDebounce();
expect(search.matchCount.value).toBe(1);
});
it("debounces matching across columns and cells", async () => {
vi.useFakeTimers();
// getCellSearchText 契约:返回小写文本(调用方负责缓存小写副本)
@@ -136,6 +136,7 @@ export interface UseDataGridEditorOptions {
}
interface PendingChangesSnapshot {
manualSaveRequired?: boolean;
newRows: CellValue[][];
newRowMeta: GridNewRowMeta[];
quickEntryDraftRow?: CellValue[];
@@ -173,7 +174,7 @@ interface QueuedAutoSaveChange {
value: CellValue;
}
type PendingChangesHistorySnapshot = Pick<PendingChangesSnapshot, "newRows" | "newRowMeta" | "quickEntryDraftRow" | "dirtyRows" | "deletedRows" | "transactionActive">;
type PendingChangesHistorySnapshot = Pick<PendingChangesSnapshot, "newRows" | "newRowMeta" | "quickEntryDraftRow" | "dirtyRows" | "deletedRows" | "transactionActive" | "manualSaveRequired">;
const pendingChangesCache = new Map<string, PendingChangesSnapshot>();
const closingPendingSnapshotTabs = new Set<string>();
@@ -312,6 +313,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
const undoStack = ref<PendingChangesHistorySnapshot[]>([]);
const redoStack = ref<PendingChangesHistorySnapshot[]>([]);
const pendingChangesVersion = ref(0);
const manualSaveRequired = ref(false);
let restoredEditingCell = false;
let restoredTransactionActive = false;
let suppressNextBlurCommit = false;
@@ -336,6 +338,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
quickEntryDraftRow.value = cached.quickEntryDraftRow ? [...cached.quickEntryDraftRow] : [];
dirtyRows.value = cached.dirtyRows;
deletedRows.value = cached.deletedRows;
manualSaveRequired.value = cached.manualSaveRequired === true;
editingCell.value = cached.editingCell ?? null;
editValue.value = cached.editValue ?? "";
restoredEditingCell = !!cached.editingCell;
@@ -439,6 +442,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
function pendingChangesSnapshot(): PendingChangesHistorySnapshot {
return {
manualSaveRequired: manualSaveRequired.value,
newRows: newRows.value.map((row) => [...row]),
newRowMeta: cloneNewRowMeta(newRowMeta.value),
quickEntryDraftRow: quickEntryDraftRow.value.length > 0 ? [...quickEntryDraftRow.value] : undefined,
@@ -455,6 +459,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
restoreNewRowMeta(snapshot.newRowMeta ?? []);
quickEntryDraftRow.value = snapshot.quickEntryDraftRow ? [...snapshot.quickEntryDraftRow] : emptyDraftRow();
dirtyRows.value = restoredDirtyRows;
manualSaveRequired.value = snapshot.manualSaveRequired === true;
deletedRows.value = new Set(snapshot.deletedRows);
transactionActive.value = snapshot.transactionActive === true && useTransaction.value === true;
queuedAutoSaveChanges.clear();
@@ -957,8 +962,12 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
}
function applyCellValue(rowId: number, col: number, value: string | null, options: ApplyCellValueOptions = {}) {
if (!canEditColumn(col)) return;
const item = getRowItem(rowId);
applyCellValueToItem(item, rowId, col, value, options);
}
function applyCellValueToItem(item: RowItem | undefined, rowId: number, col: number, value: string | null, options: ApplyCellValueOptions = {}) {
if (!canEditColumn(col)) return;
if (!item || item.isDeleted) return;
if (item.isDraft) {
@@ -1056,6 +1065,34 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
onCellValueChanged?.(rowId, col);
}
function stageCellReplacements(changes: readonly import("@/lib/dataGrid/dataGridReplace").DataGridCellReplacement[]): number {
if (!editable.value || !canEditExistingRows.value || isSaving.value || isConditionalUpdateActive.value) return 0;
let changed = 0;
beginBatch();
try {
for (const change of changes) {
const { rowId, col, 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;
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];
if (currentValue !== previousValue) {
changed++;
// Set after the first undo snapshot, so undo restores the prior policy.
manualSaveRequired.value = true;
}
}
return changed;
} finally {
commitBatch();
if (!hasPendingChanges.value) manualSaveRequired.value = false;
}
}
function restoreCellValue(rowId: number, col: number) {
if (!canEditColumn(col)) return;
const item = getRowItem(rowId);
@@ -1501,6 +1538,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
async function finishSaveChanges(savedSnapshot?: PendingSaveSnapshot) {
isSaving.value = false;
if (!hasPendingChanges.value) manualSaveRequired.value = false;
if (pendingAutoSaveRequested && dataGridQuickEntryEnabled.value) {
applyQueuedAutoSaveChanges(savedSnapshot);
} else {
@@ -1811,6 +1849,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
}
async function saveChanges(saveOptions: SaveChangesOptions = {}) {
if (saveOptions.autoSave && manualSaveRequired.value) return;
if (isSaving.value) {
if (saveOptions.autoSave) pendingAutoSaveRequested = true;
return;
@@ -2016,6 +2055,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
function discardChanges() {
if (isConditionalUpdateActive.value) return;
manualSaveRequired.value = false;
dirtyRows.value = new Map();
newRows.value = [];
newRowMeta.value = [];
@@ -2060,6 +2100,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
return;
}
pendingChangesCache.set(k, {
manualSaveRequired: manualSaveRequired.value,
newRows: newRows.value.map((r) => [...r]),
newRowMeta: cloneNewRowMeta(newRowMeta.value),
quickEntryDraftRow: quickEntryDraftRowSnapshot,
@@ -2168,6 +2209,8 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
newRowCount,
deletedRowCount,
pendingChangesVersion,
manualSaveRequired,
stageCellReplacements,
pendingChangeCount,
hasPendingChanges,
transactionActive,
@@ -1,6 +1,7 @@
import { computed, getCurrentScope, nextTick, onScopeDispose, ref, toValue, watch, type MaybeRefOrGetter } from "vue";
import { dataGridSearchMatchKey } from "@/lib/dataGrid/canvasDataGridRenderer";
import { loadDataGridSearchState, saveDataGridSearchState } from "@/lib/dataGrid/dataGridSearchStatePersistence";
import { dataGridReplacementPattern } from "@/lib/dataGrid/dataGridReplace";
export type DataGridSearchMatch = {
kind: "cell" | "column";
@@ -17,6 +18,11 @@ export type UseDataGridSearchOptions<Row> = {
/** 必须返回小写文本(查询词已小写)。调用方可据此缓存小写副本,
* 避免每次按键对全部单元格重新分配 toLowerCase 字符串。 */
getCellSearchText: (row: Row, columnIndex: number) => string;
getCellRawSearchText?: (row: Row, columnIndex: number) => string;
caseSensitive?: MaybeRefOrGetter<boolean>;
literalQuery?: MaybeRefOrGetter<boolean>;
includeColumnMatches?: MaybeRefOrGetter<boolean>;
isCellSearchable?: (row: Row, columnIndex: number) => boolean;
debounceMs?: number;
onNavigate?: (match: DataGridSearchMatch) => void;
/** 按标签页键控的持久化键;不传则该宿主不参与搜索状态保留。 */
@@ -38,6 +44,11 @@ export function useDataGridSearch<Row>(options: UseDataGridSearchOptions<Row>) {
const suggestionIndex = ref(-1);
let searchTimer: ReturnType<typeof setTimeout> | undefined;
// Case-sensitive matching needs the raw (non-lowercased) cell text. Without a
// raw-text provider only the lowercase cache exists, so degrade to
// case-insensitive instead of never matching uppercase input.
const effectiveCaseSensitive = computed(() => !!toValue(options.caseSensitive) && (!!toValue(options.literalQuery) || !!options.getCellRawSearchText));
const matchState = computed(() => {
const query = deferredSearchText.value;
const keys: number[] = [];
@@ -50,12 +61,23 @@ export function useDataGridSearch<Row>(options: UseDataGridSearchOptions<Row>) {
matchSet.add(key);
};
const columns = toValue(options.columns);
columns.forEach((column, col) => {
if (column.toLowerCase().includes(query)) addMatch(-1, col);
});
const sensitive = effectiveCaseSensitive.value;
const pattern = toValue(options.literalQuery) ? dataGridReplacementPattern(query, sensitive) : undefined;
const includes = (value: string) => {
if (!pattern) return (sensitive ? value : value.toLowerCase()).includes(query);
pattern.lastIndex = 0;
return pattern.test(value);
};
if (toValue(options.includeColumnMatches) !== false) {
columns.forEach((column, col) => {
if (includes(column)) addMatch(-1, col);
});
}
toValue(options.rows).forEach((row, displayRow) => {
columns.forEach((_, col) => {
if (options.getCellSearchText(row, col).includes(query)) addMatch(displayRow, col);
if (options.isCellSearchable && !options.isCellSearchable(row, col)) return;
const text = (pattern || sensitive) && options.getCellRawSearchText ? options.getCellRawSearchText(row, col) : options.getCellSearchText(row, col);
if (includes(text)) addMatch(displayRow, col);
});
});
return { keys, matchSet };
@@ -81,9 +103,10 @@ export function useDataGridSearch<Row>(options: UseDataGridSearchOptions<Row>) {
// watcher flush and self-invalidates if the user types during the restore window.
let restoreToken: { searchText: string; matchIndex: number } | null = null;
watch(searchText, (value) => {
watch([searchText, () => toValue(options.caseSensitive), () => toValue(options.literalQuery)], ([value]) => {
clearTimer();
const query = value.trim().toLowerCase();
const text = toValue(options.literalQuery) ? value : value.trim();
const query = toValue(options.literalQuery) || effectiveCaseSensitive.value ? text : text.toLowerCase();
const restoring = restoreToken !== null && restoreToken.searchText === value;
if (!query) deferredSearchText.value = "";
// Restoring is not typing: resolve the query now so the row set (and, in
@@ -102,6 +125,12 @@ export function useDataGridSearch<Row>(options: UseDataGridSearchOptions<Row>) {
// are writing keeps the normal debounce, index reset and auto-navigate.
restoreToken = null;
if (toValue(options.literalQuery)) {
suggestions.value = [];
suggestionIndex.value = -1;
return;
}
const lastToken = value.trim().split(SEARCH_TOKEN_SEPARATOR).pop()?.toLowerCase() ?? "";
suggestions.value = lastToken
? toValue(options.suggestionColumns ?? options.columns)
+9
View File
@@ -2168,6 +2168,15 @@ export default {
batchAppendPasteTargetNotEmpty: "The selected new row already contains data.",
batchAppendPasteEmpty: "The clipboard does not contain rows to add.",
batchAppendPasteReadonlyColumn: "The pasted data includes a column that cannot be edited.",
replaceText: "Replace with",
replaceCurrentCell: "Replace all matching text in the current cell",
replaceScope: "Replacement scope",
replaceLoadedResults: "Loaded results",
replaceCurrentColumn: "Current column",
replaceSelectedCells: "Selected cells",
replaceChooseColumn: "Choose a column",
replaceMatchedCells: "{count} matching cells",
replaceStagedCells: "Replaced {count} cells; pending save",
search: "Search...",
searchTruncatedValuesHint: "Some long text/JSON values are truncated for display; the search only covers loaded cell text and may miss matches.",
searchOrWhere: "Search, or enter a WHERE clause...",
+9
View File
@@ -2357,6 +2357,15 @@ export default withEnglishFallback({
batchAppendPasteTargetNotEmpty: "La nueva fila seleccionada ya contiene datos.",
batchAppendPasteEmpty: "No hay filas nuevas en el portapapeles.",
batchAppendPasteReadonlyColumn: "Los datos pegados contienen columnas no editables.",
replaceText: "Reemplazar por",
replaceCurrentCell: "Reemplazar todo el texto coincidente en la celda actual",
replaceScope: "Ámbito de reemplazo",
replaceLoadedResults: "Resultados cargados",
replaceCurrentColumn: "Columna actual",
replaceSelectedCells: "Celdas seleccionadas",
replaceChooseColumn: "Elegir una columna",
replaceMatchedCells: "{count} celdas coincidentes",
replaceStagedCells: "Se reemplazaron {count} celdas; pendiente de guardar",
xlsxHeaderTitle: "Formato de encabezado",
xlsxExportTitle: "Opciones de exportacion",
xlsxHeaderPrompt: "Seleccione el formato de encabezado al exportar a Excel:",
+9
View File
@@ -2355,6 +2355,15 @@ export default withEnglishFallback({
batchAppendPasteTargetNotEmpty: "La riga nuova selezionata contiene già dati.",
batchAppendPasteEmpty: "Negli appunti non ci sono righe da aggiungere.",
batchAppendPasteReadonlyColumn: "I dati incollati contengono colonne non modificabili.",
replaceText: "Sostituisci con",
replaceCurrentCell: "Sostituisci tutto il testo corrispondente nella cella corrente",
replaceScope: "Ambito di sostituzione",
replaceLoadedResults: "Risultati caricati",
replaceCurrentColumn: "Colonna corrente",
replaceSelectedCells: "Celle selezionate",
replaceChooseColumn: "Scegli una colonna",
replaceMatchedCells: "{count} celle corrispondenti",
replaceStagedCells: "Sostituite {count} celle; salvataggio in sospeso",
xlsxHeaderTitle: "Formato intestazione",
xlsxExportTitle: "Opzioni di esportazione",
xlsxHeaderPrompt: "Seleziona il formato dell'intestazione da utilizzare per l'esportazione in Excel:",
+9
View File
@@ -2377,6 +2377,15 @@ export default withEnglishFallback({
batchAppendPasteTargetNotEmpty: "選択した新規行には既にデータが含まれています。",
batchAppendPasteEmpty: "クリップボードに追加可能な行がありません。",
batchAppendPasteReadonlyColumn: "貼り付けたデータに編集不可能な列が含まれています。",
replaceText: "置換後の文字列",
replaceCurrentCell: "現在のセル内の一致するテキストをすべて置換",
replaceScope: "置換範囲",
replaceLoadedResults: "読み込み済みの結果",
replaceCurrentColumn: "現在の列",
replaceSelectedCells: "選択したセル",
replaceChooseColumn: "列を選択",
replaceMatchedCells: "一致するセル: {count} 件",
replaceStagedCells: "{count} 個のセルを置換しました。保存待ちです",
xlsxHeaderTitle: "ヘッダー形式",
xlsxExportTitle: "エクスポートオプション",
xlsxHeaderPrompt: "Excel エクスポート時に使用するヘッダー形式を選択してください:",
+9
View File
@@ -2005,6 +2005,15 @@ export default withEnglishFallback({
hideSelectedColumns: "선택한 컬럼 숨기기 ({count})",
showAllColumnsMenu: "모든 컬럼 표시",
pasted: "붙여넣었습니다!",
replaceText: "바꿀 내용",
replaceCurrentCell: "현재 셀에서 일치하는 텍스트 모두 바꾸기",
replaceScope: "바꾸기 범위",
replaceLoadedResults: "로드된 결과",
replaceCurrentColumn: "현재 컬럼",
replaceSelectedCells: "선택한 셀",
replaceChooseColumn: "컬럼 선택",
replaceMatchedCells: "일치하는 셀 {count}개",
replaceStagedCells: "셀 {count}개를 바꿨습니다. 저장 대기 중",
search: "검색...",
searchTruncatedValuesHint: "일부 긴 텍스트/JSON 값은 표시를 위해 잘렸습니다. 검색은 로드된 표시 내용만 대상으로 하며 SQL 쿼리보다 일치 수가 적을 수 있습니다.",
searchOrWhere: "검색 또는 WHERE 절 입력...",
+9
View File
@@ -2357,6 +2357,15 @@ export default withEnglishFallback({
batchAppendPasteTargetNotEmpty: "A nova linha selecionada já contém dados.",
batchAppendPasteEmpty: "Não há linhas para adicionar na área de transferência.",
batchAppendPasteReadonlyColumn: "Os dados colados contêm colunas não editáveis.",
replaceText: "Substituir por",
replaceCurrentCell: "Substituir todo o texto correspondente na célula atual",
replaceScope: "Escopo da substituição",
replaceLoadedResults: "Resultados carregados",
replaceCurrentColumn: "Coluna atual",
replaceSelectedCells: "Células selecionadas",
replaceChooseColumn: "Escolher uma coluna",
replaceMatchedCells: "{count} células correspondentes",
replaceStagedCells: "{count} células substituídas; aguardando salvamento",
xlsxHeaderTitle: "Formato do cabeçalho",
xlsxExportTitle: "Opcoes de exportacao",
xlsxHeaderPrompt: "Selecione o formato do cabeçalho a ser usado ao exportar Excel:",
+9
View File
@@ -2083,6 +2083,15 @@ export default withEnglishFallback({
batchAppendPasteTargetNotEmpty: "选中的新增行已包含数据。",
batchAppendPasteEmpty: "剪贴板中没有可新增的行。",
batchAppendPasteReadonlyColumn: "粘贴的数据包含不可编辑的列。",
replaceText: "替换为",
replaceCurrentCell: "替换当前命中单元格中的全部匹配文字",
replaceScope: "替换范围",
replaceLoadedResults: "已加载结果",
replaceCurrentColumn: "当前列",
replaceSelectedCells: "选中单元格",
replaceChooseColumn: "选择列",
replaceMatchedCells: "命中 {count} 个单元格",
replaceStagedCells: "已替换 {count} 个单元格,待保存",
search: "搜索...",
searchTruncatedValuesHint: "部分长文本/JSON 值已截断显示,搜索仅覆盖已加载的显示内容,匹配数可能少于 SQL 查询。",
searchOrWhere: "搜索,或输入 WHERE 条件...",
+9
View File
@@ -2360,6 +2360,15 @@ export default withEnglishFallback({
batchAppendPasteTargetNotEmpty: "選取的新增行已包含資料。",
batchAppendPasteEmpty: "剪貼簿中沒有可新增的行。",
batchAppendPasteReadonlyColumn: "貼上的資料包含不可編輯的列。",
replaceText: "取代為",
replaceCurrentCell: "取代目前符合儲存格中的所有相符文字",
replaceScope: "取代範圍",
replaceLoadedResults: "已載入結果",
replaceCurrentColumn: "目前欄位",
replaceSelectedCells: "所選儲存格",
replaceChooseColumn: "選擇欄位",
replaceMatchedCells: "符合 {count} 個儲存格",
replaceStagedCells: "已取代 {count} 個儲存格,待儲存",
xlsxHeaderTitle: "表頭格式",
xlsxExportTitle: "匯出選項",
xlsxHeaderPrompt: "請選擇匯出 Excel 時使用的表頭格式:",
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import { findDataGridReplacementMatches, replaceDataGridText } from "@/lib/dataGrid/dataGridReplace";
describe("data grid searched text replacement", () => {
it("replaces repeated JSON fragments without changing the rest of the value", () => {
const value = '[{"backend":"svc.test-test:80","path":"/api/**"},{"backend":"svc2.test-test:81"}]';
expect(replaceDataGridText(value, "test-test", "prod", false)).toBe('[{"backend":"svc.prod:80","path":"/api/**"},{"backend":"svc2.prod:81"}]');
});
it("treats both search and replacement as literals", () => {
expect(replaceDataGridText("a.* a.*", ".*", "$&$1", false)).toBe("a$&$1 a$&$1");
expect(replaceDataGridText("one\\two", "\\", "$'", false)).toBe("one$'two");
});
it("supports case sensitivity, whitespace searches and empty replacement strings", () => {
expect(replaceDataGridText("TEST test", "test", "x", true)).toBe("TEST x");
expect(replaceDataGridText("TEST test", "test", "x", false)).toBe("x x");
expect(replaceDataGridText(" a ", " ", "", false)).toBe("a");
expect(replaceDataGridText("hit", "hit", "", false)).toBe("");
expect(replaceDataGridText("hit", "", "x", false)).toBe("hit");
});
const rows = [
{ rowId: 7, data: ["hit", null, 123, true, "HIT"] },
{ rowId: 2, data: ["hit", "hit", "none", false, "hit"] },
];
it("matches only string cells in loaded rows using stable source IDs", () => {
expect(findDataGridReplacementMatches({ rows, search: "hit" })).toEqual([
{ rowId: 7, col: 0, value: "hit" },
{ rowId: 7, col: 4, value: "HIT" },
{ rowId: 2, col: 0, value: "hit" },
{ rowId: 2, col: 1, value: "hit" },
{ rowId: 2, col: 4, value: "hit" },
]);
});
it("applies column, selection, case, readonly and truncation restrictions before matching", () => {
expect(findDataGridReplacementMatches({ rows, search: "hit", caseSensitive: true, column: 4 })).toEqual([{ rowId: 2, col: 4, value: "hit" }]);
expect(findDataGridReplacementMatches({ rows, search: "hit", includesCell: (rowId, col) => rowId === 2 && col === 1 })).toEqual([{ rowId: 2, col: 1, value: "hit" }]);
expect(findDataGridReplacementMatches({ rows, search: "hit", canReplaceCell: (rowId, col) => rowId !== 7 && col !== 1, isTruncated: (rowId, col) => rowId === 2 && col === 4 })).toEqual([{ rowId: 2, col: 0, value: "hit" }]);
expect(findDataGridReplacementMatches({ rows, search: "hit", includesCell: () => false })).toEqual([]);
expect(findDataGridReplacementMatches({ rows, search: "" })).toEqual([]);
});
});
@@ -0,0 +1,42 @@
import type { CellValue } from "@/lib/dataGrid/cellValue";
export type DataGridReplacementMatch = { rowId: number; col: number; value: string };
export type DataGridCellReplacement = DataGridReplacementMatch & { previousValue: string };
export type DataGridReplaceScope = "loaded" | "column" | "selection";
type ReplacementMatchOptions = {
rows: readonly { rowId: number; data: readonly CellValue[] }[];
search: string;
caseSensitive?: boolean;
column?: number;
includesCell?: (rowId: number, col: number) => boolean;
canReplaceCell?: (rowId: number, col: number) => boolean;
isTruncated?: (rowId: number, col: number) => boolean;
};
export function dataGridReplacementPattern(search: string, caseSensitive: boolean): RegExp {
return new RegExp(search.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), caseSensitive ? "gu" : "giu");
}
export function replaceDataGridText(value: string, search: string, replacement: string, caseSensitive: boolean): string {
if (!search) return value;
// A callback keeps $&, $1 and other replacement tokens literal.
return value.replace(dataGridReplacementPattern(search, caseSensitive), () => replacement);
}
export function findDataGridReplacementMatches(options: ReplacementMatchOptions): DataGridReplacementMatch[] {
if (!options.search) return [];
const pattern = dataGridReplacementPattern(options.search, options.caseSensitive ?? false);
const matches: DataGridReplacementMatch[] = [];
for (const row of options.rows) {
row.data.forEach((value, col) => {
if (typeof value !== "string" || (options.column !== undefined && col !== options.column)) return;
if (options.includesCell && !options.includesCell(row.rowId, col)) return;
if (options.canReplaceCell && !options.canReplaceCell(row.rowId, col)) return;
if (options.isTruncated?.(row.rowId, col)) return;
pattern.lastIndex = 0;
if (pattern.test(value)) matches.push({ rowId: row.rowId, col, value });
});
}
return matches;
}