fix(schema): avoid redundant filter refreshes
This commit is contained in:
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>
|
||||
|
||||
+49
@@ -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));
|
||||
}
|
||||
Reference in new issue
Block a user