feat(grid): add find and replace for query results
This commit is contained in:
1 parent
d17ce46626
commit
8567e50887
19 files changed
+747
-81
No files matched your search
@@ -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();
|
||||
|
||||
|
||||
Reference in new issue
Block a user