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();
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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 エクスポート時に使用するヘッダー形式を選択してください:",
|
||||
|
||||
@@ -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 절 입력...",
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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 条件...",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user