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();