fix(schema): avoid redundant filter refreshes

This commit is contained in:
t8y2 committed 2026-07-19 11:48:25 +08:00
1 parent feb1c4bd34
commit c6d238348c
3 files changed
+147 -49

No files matched your search

@@ -1,13 +1,15 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { computed, ref, shallowRef, watch } from "vue";
import { useI18n } from "vue-i18n";
import { CheckSquare, Loader2, Search, Square } from "@lucide/vue";
import { RecycleScroller } from "vue-virtual-scroller";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { useConnectionStore } from "@/stores/connectionStore";
import { normalizeVisibleSchemaSelection } from "@/lib/database/visibleDatabases";
import * as api from "@/lib/backend/api";
import { addVisibleSchemas, initialVisibleSchemaSelection, resolveVisibleSchemaSaveAction, selectVisibleSchemas } from "./visibleSchemasDialogState";
const props = defineProps<{
open: boolean;
@@ -31,10 +33,11 @@ const emit = defineEmits<{
const { t } = useI18n();
const connectionStore = useConnectionStore();
const schemaNames = ref<string[]>([]);
const selectedNames = ref<Set<string>>(new Set());
const schemaNames = shallowRef<string[]>([]);
const selectedNames = shallowRef<Set<string>>(new Set());
const searchText = ref("");
const isLoading = ref(false);
const isSaving = ref(false);
const errorMessage = ref("");
const isDraftMode = computed(() => props.draftMode);
@@ -47,6 +50,7 @@ const filteredSchemaNames = computed(() => {
if (!query) return names;
return names.filter((name) => name.toLowerCase().includes(query));
});
const filteredSchemaItems = computed(() => filteredSchemaNames.value.map((name) => ({ name })));
const selectedCount = computed(() => selectedNames.value.size);
const totalCount = computed(() => {
const names = isDraftMode.value ? props.draftSchemaNames || [] : schemaNames.value;
@@ -71,13 +75,8 @@ watch(
function initDraftMode() {
searchText.value = "";
const names = props.draftSchemaNames || [];
const configured = props.draftInitialSelection;
if (configured && configured.length > 0) {
const visibleSet = new Set(normalizeVisibleSchemaSelection(configured, names));
selectedNames.value = new Set(names.filter((name) => visibleSet.has(name)));
} else {
selectedNames.value = new Set(names);
}
// Draft callers historically use an empty array for both absent and all-selected state.
selectedNames.value = initialVisibleSchemaSelection(names, props.draftInitialSelection, true);
}
async function loadSchemas() {
@@ -91,12 +90,7 @@ async function loadSchemas() {
const names = await api.listSchemas(props.connectionId, database);
schemaNames.value = names;
const configured = config?.visible_schemas?.[database || ""];
if (Array.isArray(configured)) {
const visibleSet = new Set(normalizeVisibleSchemaSelection(configured, names));
selectedNames.value = new Set(names.filter((name) => visibleSet.has(name)));
} else {
selectedNames.value = new Set(names);
}
selectedNames.value = initialVisibleSchemaSelection(names, configured);
} catch (e: any) {
schemaNames.value = [];
selectedNames.value = new Set();
@@ -117,15 +111,11 @@ const isSearching = computed(() => searchText.value.trim().length > 0);
function selectAll() {
const names = isDraftMode.value ? props.draftSchemaNames || [] : schemaNames.value;
selectedNames.value = new Set(names);
selectedNames.value = selectVisibleSchemas(names);
}
function selectFiltered() {
const next = new Set(selectedNames.value);
for (const name of filteredSchemaNames.value) {
next.add(name);
}
selectedNames.value = next;
selectedNames.value = addVisibleSchemas(selectedNames.value, filteredSchemaNames.value);
}
function clearSelection() {
@@ -133,23 +123,31 @@ function clearSelection() {
}
async function showAllSchemas() {
if (isSaving.value) return;
isSaving.value = true;
if (isDraftMode.value) {
selectedNames.value = new Set(props.draftSchemaNames || []);
selectedNames.value = selectVisibleSchemas(props.draftSchemaNames || []);
emit("draft:showAll");
emit("update:open", false);
isSaving.value = false;
return;
}
const config = connectionStore.getConfig(props.connectionId);
const database = props.database || config?.database || "";
if (config?.visible_schemas?.[database || ""]) {
await connectionStore.clearVisibleSchemas(props.connectionId, database);
}
selectedNames.value = new Set(schemaNames.value);
selectedNames.value = selectVisibleSchemas(schemaNames.value);
emit("update:open", false);
try {
if (config?.visible_schemas?.[database || ""]) {
// Match DBeaver's filter dialog: close first, then refresh the navigator asynchronously.
await connectionStore.clearVisibleSchemas(props.connectionId, database);
}
} finally {
isSaving.value = false;
}
}
async function saveSelection() {
if (!canSaveSelection.value) return;
if (!canSaveSelection.value || isSaving.value) return;
const names = isDraftMode.value ? props.draftSchemaNames || [] : schemaNames.value;
const normalized = normalizeVisibleSchemaSelection([...selectedNames.value], names);
if (isDraftMode.value) {
@@ -159,8 +157,21 @@ async function saveSelection() {
}
const config = connectionStore.getConfig(props.connectionId);
const database = props.database || config?.database || "";
await connectionStore.setVisibleSchemas(props.connectionId, database, normalized);
const action = resolveVisibleSchemaSaveAction(selectedNames.value, names, config?.visible_schemas?.[database || ""]);
emit("update:open", false);
if (action.type === "none") return;
isSaving.value = true;
try {
// Persist only real filter changes; the store refreshes the tree after this dialog closes.
if (action.type === "clear") {
await connectionStore.clearVisibleSchemas(props.connectionId, database);
} else {
await connectionStore.setVisibleSchemas(props.connectionId, database, action.schemaNames);
}
} finally {
isSaving.value = false;
}
}
</script>
@@ -182,16 +193,16 @@ async function saveSelection() {
<div class="flex items-center justify-between text-xs text-muted-foreground">
<span>{{ t("visibleSchemas.selectedCount", { selected: selectedCount, total: totalCount }) }}</span>
<div class="flex items-center gap-2">
<button class="hover:text-foreground disabled:opacity-50" :disabled="isLoadingState" @click="selectAll">
<button type="button" class="hover:text-foreground disabled:opacity-50" :disabled="isLoadingState || isSaving" @click="selectAll">
{{ t("visibleSchemas.selectAll") }}
</button>
<button v-if="isSearching" class="hover:text-foreground disabled:opacity-50" :disabled="isLoadingState" @click="selectFiltered">
<button v-if="isSearching" type="button" class="hover:text-foreground disabled:opacity-50" :disabled="isLoadingState || isSaving" @click="selectFiltered">
{{ t("visibleSchemas.selectFiltered") }}
</button>
<button class="hover:text-foreground disabled:opacity-50" :disabled="isLoadingState" @click="clearSelection">
<button type="button" class="hover:text-foreground disabled:opacity-50" :disabled="isLoadingState || isSaving" @click="clearSelection">
{{ t("visibleSchemas.clear") }}
</button>
<button class="hover:text-foreground disabled:opacity-50" :disabled="isLoadingState" @click="showAllSchemas">
<button type="button" class="hover:text-foreground disabled:opacity-50" :disabled="isLoadingState || isSaving" @click="showAllSchemas">
{{ t("visibleSchemas.showAll") }}
</button>
</div>
@@ -200,7 +211,7 @@ async function saveSelection() {
{{ t("visibleSchemas.emptySelection") }}
</p>
<div class="h-72 overflow-y-auto rounded-md border bg-background/50 p-1">
<div v-if="isLoadingState || errorMessageState || !filteredSchemaItems.length" class="h-72 overflow-y-auto rounded-md border bg-background/50 p-1">
<div v-if="isLoadingState" class="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 class="h-4 w-4 animate-spin" />
{{ t("common.loading") }}
@@ -211,24 +222,18 @@ async function saveSelection() {
<div v-else-if="!filteredSchemaNames.length" class="p-3 text-sm text-muted-foreground">
{{ t("grid.noSearchResults") }}
</div>
<template v-else>
<button
v-for="schema in filteredSchemaNames"
:key="schema"
type="button"
class="flex h-8 w-full min-w-0 items-center gap-2 rounded-sm px-2 text-left text-sm hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground focus-visible:outline-none"
@click="toggleSchema(schema)"
>
<CheckSquare v-if="selectedNames.has(schema)" class="h-4 w-4 shrink-0 text-primary" />
<Square v-else class="h-4 w-4 shrink-0 text-muted-foreground" />
<span class="truncate">{{ schema }}</span>
</button>
</template>
</div>
<RecycleScroller v-else v-slot="{ item }" class="h-72 rounded-md border bg-background/50 p-1" :items="filteredSchemaItems" :item-size="32" key-field="name" :buffer="160">
<button type="button" class="flex h-8 w-full min-w-0 items-center gap-2 rounded-sm px-2 text-left text-sm hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground focus-visible:outline-none" @click="toggleSchema(item.name)">
<CheckSquare v-if="selectedNames.has(item.name)" class="h-4 w-4 shrink-0 text-primary" />
<Square v-else class="h-4 w-4 shrink-0 text-muted-foreground" />
<span class="truncate">{{ item.name }}</span>
</button>
</RecycleScroller>
<DialogFooter>
<Button variant="outline" @click="emit('update:open', false)">{{ t("dangerDialog.cancel") }}</Button>
<Button :disabled="isLoadingState || !!errorMessageState || !canSaveSelection" @click="saveSelection">
<Button type="button" variant="outline" :disabled="isSaving" @click="emit('update:open', false)">{{ t("dangerDialog.cancel") }}</Button>
<Button type="button" :disabled="isLoadingState || isSaving || !!errorMessageState || !canSaveSelection" @click="saveSelection">
{{ t("visibleSchemas.save") }}
</Button>
</DialogFooter>
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import { addVisibleSchemas, initialVisibleSchemaSelection, resolveVisibleSchemaSaveAction, selectVisibleSchemas } from "../visibleSchemasDialogState";
describe("visibleSchemasDialogState", () => {
const schemaNames = ["public", "sales", "audit"];
it("supports select all, clear, and filtered selection without mutating the previous set", () => {
const selected = selectVisibleSchemas(schemaNames);
const cleared = new Set<string>();
const filtered = addVisibleSchemas(cleared, ["sales", "audit"]);
expect([...selected]).toEqual(schemaNames);
expect([...cleared]).toEqual([]);
expect([...filtered]).toEqual(["sales", "audit"]);
expect([...addVisibleSchemas(filtered, ["public", "sales"])]).toEqual(["sales", "audit", "public"]);
expect([...filtered]).toEqual(["sales", "audit"]);
});
it("keeps an explicitly empty normal-mode filter empty", () => {
expect([...initialVisibleSchemaSelection(schemaNames, undefined)]).toEqual(schemaNames);
expect([...initialVisibleSchemaSelection(schemaNames, [])]).toEqual([]);
});
it("keeps draft-mode empty or absent selections as all selected", () => {
expect([...initialVisibleSchemaSelection(schemaNames, undefined, true)]).toEqual(schemaNames);
expect([...initialVisibleSchemaSelection(schemaNames, [], true)]).toEqual(schemaNames);
});
it("normalizes configured schemas against the available list", () => {
expect([...initialVisibleSchemaSelection(schemaNames, ["missing", "audit", "audit"])]).toEqual(["audit"]);
});
it("skips persistence when an unchanged filter is saved", () => {
expect(resolveVisibleSchemaSaveAction(new Set(["audit", "public"]), schemaNames, ["public", "audit"])).toEqual({ type: "none" });
});
it("stores changed partial selections in stable schema order", () => {
expect(resolveVisibleSchemaSaveAction(new Set(["audit", "public"]), schemaNames, ["sales"])).toEqual({
type: "set",
schemaNames: ["public", "audit"],
});
});
it("represents a full selection as show-all and avoids redundant tree refreshes", () => {
const selected = new Set(schemaNames);
expect(resolveVisibleSchemaSaveAction(selected, schemaNames, undefined)).toEqual({ type: "none" });
expect(resolveVisibleSchemaSaveAction(selected, schemaNames, ["public"])).toEqual({ type: "clear" });
});
});
@@ -0,0 +1,44 @@
import { normalizeVisibleSchemaSelection } from "@/lib/database/visibleDatabases";
export type VisibleSchemaSaveAction = { type: "none" } | { type: "clear" } | { type: "set"; schemaNames: string[] };
export function initialVisibleSchemaSelection(schemaNames: string[], configured: string[] | undefined, emptySelectionMeansAll = false): Set<string> {
if (configured === undefined || (emptySelectionMeansAll && configured.length === 0)) return new Set(schemaNames);
return new Set(normalizeVisibleSchemaSelection(configured, schemaNames));
}
export function selectVisibleSchemas(schemaNames: string[]): Set<string> {
return new Set(schemaNames);
}
export function addVisibleSchemas(selection: Set<string>, schemaNames: string[]): Set<string> {
const next = new Set(selection);
for (const name of schemaNames) next.add(name);
return next;
}
export function resolveVisibleSchemaSaveAction(selection: Set<string>, schemaNames: string[], configured: string[] | undefined): VisibleSchemaSaveAction {
const normalized = normalizeSelectionInSchemaOrder(selection, schemaNames);
const availableCount = new Set(schemaNames).size;
if (normalized.length === availableCount) {
return configured ? { type: "clear" } : { type: "none" };
}
if (configured && sameSelection(normalized, normalizeVisibleSchemaSelection(configured, schemaNames))) {
return { type: "none" };
}
return { type: "set", schemaNames: normalized };
}
function normalizeSelectionInSchemaOrder(selection: Set<string>, schemaNames: string[]): string[] {
return normalizeVisibleSchemaSelection(
schemaNames.filter((name) => selection.has(name)),
schemaNames,
);
}
function sameSelection(left: string[], right: string[]): boolean {
if (left.length !== right.length) return false;
const rightSet = new Set(right);
return left.every((name) => rightSet.has(name));
}