feat(editor): show connection group tree in toolbar
This commit is contained in:
1 parent
45819df9e3
commit
c10b4e92ce
6 files changed
+575
-31
No files matched your search
@@ -0,0 +1,239 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from "vue";
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { Check, ChevronDown, ChevronRight, Folder, Search } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
|
||||
import { buildConnectionPickerRows, connectionPickerSelectableRows } from "@/lib/connection/connectionPickerTree";
|
||||
import { connectionIconType } from "@/lib/connection/connectionPresentation";
|
||||
import { cn } from "@/lib/common/utils";
|
||||
import type { ConnectionConfig, SidebarLayout } from "@/types/database";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: string;
|
||||
connections: ConnectionConfig[];
|
||||
layout: SidebarLayout;
|
||||
placeholder: string;
|
||||
searchPlaceholder: string;
|
||||
emptyText: string;
|
||||
disabled?: boolean;
|
||||
triggerClass?: HTMLAttributes["class"];
|
||||
triggerIconClass?: HTMLAttributes["class"];
|
||||
listClass?: HTMLAttributes["class"];
|
||||
}>(),
|
||||
{
|
||||
disabled: false,
|
||||
triggerIconClass: "size-4 text-muted-foreground",
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:modelValue": [value: string];
|
||||
"update:open": [value: boolean];
|
||||
}>();
|
||||
|
||||
defineSlots<{
|
||||
"trigger-label"?(props: { value: string; label: string }): any;
|
||||
}>();
|
||||
|
||||
const open = ref(false);
|
||||
const searchText = ref("");
|
||||
const searchInput = ref<InstanceType<typeof Input>>();
|
||||
const listContainer = ref<HTMLDivElement>();
|
||||
const collapsedGroupIds = ref<Set<string>>(new Set());
|
||||
const highlightIndex = ref(-1);
|
||||
|
||||
const connectionById = computed(() => new Map(props.connections.map((connection) => [connection.id, connection])));
|
||||
|
||||
const selectedLabel = computed(() => {
|
||||
if (!props.modelValue) return props.placeholder;
|
||||
return connectionById.value.get(props.modelValue)?.name || props.modelValue;
|
||||
});
|
||||
|
||||
const rows = computed(() => buildConnectionPickerRows(props.layout, props.connections, collapsedGroupIds.value, searchText.value));
|
||||
const selectableRows = computed(() => connectionPickerSelectableRows(rows.value));
|
||||
const isSearchActive = computed(() => Boolean(searchText.value.trim()));
|
||||
|
||||
function toggleGroup(groupId: string) {
|
||||
if (isSearchActive.value) return;
|
||||
const next = new Set(collapsedGroupIds.value);
|
||||
if (next.has(groupId)) next.delete(groupId);
|
||||
else next.add(groupId);
|
||||
collapsedGroupIds.value = next;
|
||||
}
|
||||
|
||||
function selectConnection(connectionId: string) {
|
||||
emit("update:modelValue", connectionId);
|
||||
open.value = false;
|
||||
}
|
||||
|
||||
function highlightSelectedConnection() {
|
||||
const selectedIndex = selectableRows.value.findIndex((row) => row.id === props.modelValue);
|
||||
highlightIndex.value = selectedIndex >= 0 ? selectedIndex : 0;
|
||||
}
|
||||
|
||||
async function scrollHighlightedRowIntoView() {
|
||||
await nextTick();
|
||||
const container = listContainer.value;
|
||||
if (!container || highlightIndex.value < 0) return;
|
||||
const target = container.querySelectorAll("[data-picker-connection]")[highlightIndex.value];
|
||||
target?.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
|
||||
watch(open, async (value) => {
|
||||
emit("update:open", value);
|
||||
if (!value) {
|
||||
searchText.value = "";
|
||||
highlightIndex.value = -1;
|
||||
return;
|
||||
}
|
||||
await nextTick();
|
||||
const input = searchInput.value?.$el as HTMLInputElement | undefined;
|
||||
input?.focus();
|
||||
highlightSelectedConnection();
|
||||
void scrollHighlightedRowIntoView();
|
||||
});
|
||||
|
||||
watch(searchText, () => {
|
||||
highlightIndex.value = selectableRows.value.length ? 0 : -1;
|
||||
void scrollHighlightedRowIntoView();
|
||||
});
|
||||
|
||||
watch(selectableRows, () => {
|
||||
if (!open.value || searchText.value) return;
|
||||
highlightSelectedConnection();
|
||||
});
|
||||
|
||||
function rowIndent(depth: number) {
|
||||
return { paddingLeft: `${8 + depth * 14}px` };
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
const total = selectableRows.value.length;
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
if (!total) return;
|
||||
highlightIndex.value = highlightIndex.value < total - 1 ? highlightIndex.value + 1 : 0;
|
||||
void scrollHighlightedRowIntoView();
|
||||
} else if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
if (!total) return;
|
||||
highlightIndex.value = highlightIndex.value > 0 ? highlightIndex.value - 1 : total - 1;
|
||||
void scrollHighlightedRowIntoView();
|
||||
} else if (event.key === "Enter") {
|
||||
if (highlightIndex.value < 0 || highlightIndex.value >= total) return;
|
||||
event.preventDefault();
|
||||
selectConnection(selectableRows.value[highlightIndex.value]!.id);
|
||||
} else if (event.key === "Escape") {
|
||||
open.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Popover v-model:open="open">
|
||||
<PopoverTrigger as-child>
|
||||
<Button type="button" variant="ghost" :disabled="disabled" :title="selectedLabel" :class="cn('h-6 w-auto max-w-56 min-w-0 justify-between gap-1 border-0 bg-transparent px-1 text-xs font-normal shadow-none hover:bg-muted/50 focus-visible:ring-0', triggerClass)">
|
||||
<slot name="trigger-label" :value="modelValue" :label="selectedLabel">
|
||||
<span class="truncate">{{ selectedLabel }}</span>
|
||||
</slot>
|
||||
<ChevronDown :class="cn('shrink-0 opacity-60', triggerIconClass)" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" :class="cn('w-auto max-w-[calc(100vw-1rem)] border-0 bg-transparent p-0 shadow-none ring-0')">
|
||||
<div :class="cn('shrink-0 rounded-md border bg-popover p-1.5 shadow-md', listClass)">
|
||||
<div class="relative rounded-md border bg-background">
|
||||
<Search class="pointer-events-none absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground" />
|
||||
<span v-if="!searchText" class="pointer-events-none absolute left-[25px] top-1/2 -translate-y-1/2 text-sm text-muted-foreground">{{ searchPlaceholder }}</span>
|
||||
<Input ref="searchInput" :model-value="searchText" class="h-6 border-0 pl-6 pr-2 text-sm caret-foreground shadow-none focus-visible:ring-0" @update:model-value="(value) => (searchText = String(value))" @keydown="handleKeydown" />
|
||||
</div>
|
||||
<div ref="listContainer" class="dbx-connection-tree-select-list max-h-64 overflow-y-auto py-1">
|
||||
<template v-if="rows.length">
|
||||
<template v-for="row in rows" :key="row.key">
|
||||
<button
|
||||
v-if="row.kind === 'group'"
|
||||
type="button"
|
||||
:title="row.label"
|
||||
:style="rowIndent(row.depth)"
|
||||
:data-picker-group="row.id"
|
||||
:aria-expanded="!row.collapsed"
|
||||
:disabled="isSearchActive"
|
||||
class="flex h-7 w-full min-w-0 items-center gap-1.5 rounded-md pr-2 text-left text-xs font-medium text-muted-foreground hover:bg-accent/60 hover:text-accent-foreground focus-visible:outline-none disabled:cursor-default disabled:hover:bg-transparent disabled:hover:text-muted-foreground"
|
||||
@click="toggleGroup(row.id)"
|
||||
>
|
||||
<ChevronDown v-if="!row.collapsed" class="h-3 w-3 shrink-0" />
|
||||
<ChevronRight v-else class="h-3 w-3 shrink-0" />
|
||||
<Folder class="h-3.5 w-3.5 shrink-0" />
|
||||
<span class="truncate">{{ row.label }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
:title="row.label"
|
||||
:style="rowIndent(row.depth)"
|
||||
:data-picker-connection="row.id"
|
||||
:class="
|
||||
cn(
|
||||
'flex h-8 w-full min-w-0 items-center gap-2 rounded-md pr-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',
|
||||
selectableRows[highlightIndex]?.id === row.id && 'bg-accent text-accent-foreground',
|
||||
)
|
||||
"
|
||||
@click="selectConnection(row.id)"
|
||||
>
|
||||
<Check :class="cn('h-3.5 w-3.5 shrink-0', row.id === modelValue ? 'opacity-100' : 'opacity-0')" />
|
||||
<DatabaseIcon :db-type="connectionIconType(connectionById.get(row.id))" class="h-3.5 w-3.5 shrink-0" />
|
||||
<span class="min-w-0 flex-1 truncate">{{ row.label }}</span>
|
||||
</button>
|
||||
</template>
|
||||
</template>
|
||||
<div v-else class="px-2 py-2 text-sm text-muted-foreground">
|
||||
{{ emptyText }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.dbx-connection-tree-select-list {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: color-mix(in oklch, var(--foreground) 30%, transparent) transparent;
|
||||
}
|
||||
|
||||
.dbx-connection-tree-select-list::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.dbx-connection-tree-select-list::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.dbx-connection-tree-select-list::-webkit-scrollbar-thumb {
|
||||
border: 1px solid transparent;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in oklch, var(--foreground) 30%, transparent);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
.dbx-connection-tree-select-list:hover::-webkit-scrollbar-thumb {
|
||||
border: 0;
|
||||
background: color-mix(in oklch, var(--foreground) 48%, transparent);
|
||||
}
|
||||
|
||||
.dark .dbx-connection-tree-select-list {
|
||||
scrollbar-color: rgb(82, 82, 91) transparent;
|
||||
}
|
||||
|
||||
.dark .dbx-connection-tree-select-list::-webkit-scrollbar-thumb {
|
||||
background: rgb(82, 82, 91);
|
||||
}
|
||||
|
||||
.dark .dbx-connection-tree-select-list:hover::-webkit-scrollbar-thumb {
|
||||
background: rgb(113, 113, 122);
|
||||
}
|
||||
</style>
|
||||
Vendored
+114
@@ -0,0 +1,114 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { createApp, defineComponent, h, nextTick, type App } from "vue";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import ConnectionTreeSelect from "@/components/connection/ConnectionTreeSelect.vue";
|
||||
import type { ConnectionConfig, SidebarLayout } from "@/types/database";
|
||||
|
||||
vi.mock("@/components/ui/button", async () => {
|
||||
const { createPassthroughStub } = await import("@/components/grid/__tests__/vueHostHarness");
|
||||
return { Button: createPassthroughStub("Button", "button") };
|
||||
});
|
||||
|
||||
vi.mock("@/components/ui/popover", async () => {
|
||||
const { createPassthroughStub } = await import("@/components/grid/__tests__/vueHostHarness");
|
||||
return {
|
||||
Popover: createPassthroughStub("Popover"),
|
||||
PopoverContent: createPassthroughStub("PopoverContent"),
|
||||
PopoverTrigger: createPassthroughStub("PopoverTrigger"),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/components/icons/DatabaseIcon.vue", () => ({
|
||||
default: defineComponent({
|
||||
name: "DatabaseIconStub",
|
||||
setup: () => () => h("span"),
|
||||
}),
|
||||
}));
|
||||
|
||||
const mountedApps: App[] = [];
|
||||
|
||||
const connections: ConnectionConfig[] = [
|
||||
{
|
||||
id: "c1",
|
||||
name: "Primary",
|
||||
db_type: "mysql",
|
||||
host: "localhost",
|
||||
port: 3306,
|
||||
username: "root",
|
||||
password: "",
|
||||
},
|
||||
];
|
||||
|
||||
const layout: SidebarLayout = {
|
||||
groups: [{ id: "g1", name: "Production", collapsed: false }],
|
||||
order: [{ type: "group", id: "g1", children: [{ type: "connection", id: "c1" }] }],
|
||||
};
|
||||
|
||||
async function mountPicker() {
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
const app = createApp(ConnectionTreeSelect, {
|
||||
modelValue: "",
|
||||
connections,
|
||||
layout,
|
||||
placeholder: "Select connection",
|
||||
searchPlaceholder: "Search connections",
|
||||
emptyText: "No connections",
|
||||
});
|
||||
mountedApps.push(app);
|
||||
app.mount(container);
|
||||
await nextTick();
|
||||
}
|
||||
|
||||
async function setSearchText(value: string) {
|
||||
const input = document.body.querySelector("input");
|
||||
if (!(input instanceof HTMLInputElement)) throw new Error("Search input was not rendered");
|
||||
input.value = value;
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
}
|
||||
|
||||
function groupButton() {
|
||||
const button = document.body.querySelector('[data-picker-group="g1"]');
|
||||
if (!(button instanceof HTMLButtonElement)) throw new Error("Group button was not rendered");
|
||||
return button;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const app of mountedApps.splice(0)) app.unmount();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
describe("ConnectionTreeSelect search collapse behavior", () => {
|
||||
it("ignores group clicks during search without changing the post-search state", async () => {
|
||||
await mountPicker();
|
||||
await setSearchText("Primary");
|
||||
|
||||
expect(groupButton().disabled).toBe(true);
|
||||
expect(groupButton().getAttribute("aria-expanded")).toBe("true");
|
||||
|
||||
groupButton().dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await nextTick();
|
||||
await setSearchText("");
|
||||
|
||||
expect(groupButton().disabled).toBe(false);
|
||||
expect(groupButton().getAttribute("aria-expanded")).toBe("true");
|
||||
expect(document.body.querySelector('[data-picker-connection="c1"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it("preserves group collapsing when search is inactive", async () => {
|
||||
await mountPicker();
|
||||
|
||||
groupButton().click();
|
||||
await nextTick();
|
||||
expect(groupButton().getAttribute("aria-expanded")).toBe("false");
|
||||
expect(document.body.querySelector('[data-picker-connection="c1"]')).toBeNull();
|
||||
|
||||
groupButton().click();
|
||||
await nextTick();
|
||||
expect(groupButton().getAttribute("aria-expanded")).toBe("true");
|
||||
expect(document.body.querySelector('[data-picker-connection="c1"]')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import { SearchableSelect } from "@/components/ui/searchable-select";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
|
||||
import TruncatedTextTooltip from "@/components/ui/TruncatedTextTooltip.vue";
|
||||
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
|
||||
import ConnectionTreeSelect from "@/components/connection/ConnectionTreeSelect.vue";
|
||||
import ProductionContextBadge from "@/components/common/ProductionContextBadge.vue";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
@@ -14,8 +15,6 @@ import { catalogDatabaseOptionsKey, databaseAfterCatalogChange, normalizedQueryT
|
||||
import { useSchemaOptions } from "@/composables/useSchemaOptions";
|
||||
import { connectionIconType } from "@/lib/connection/connectionPresentation";
|
||||
import { formatDatabaseLabel, isDefaultDatabase } from "@/lib/database/defaultDatabase";
|
||||
import { connectionDisplayName } from "@/lib/tabs/tabPresentation";
|
||||
import { useConnectionGroupLabel } from "@/composables/useConnectionGroupLabel";
|
||||
import { isSingleDatabase, supportsClearableQuerySchema, supportsSqlInListPaste, supportsTransaction as supportsTransactionFeature } from "@/lib/database/databaseCapabilities";
|
||||
import { supportsQueryExecution } from "@/lib/database/databaseFeatureSupport";
|
||||
import { connectionIsDorisFamilyCatalogCapable } from "@/lib/database/databaseFeatureSupport";
|
||||
@@ -91,8 +90,6 @@ const loadingActiveDatabaseOptions = computed(() => {
|
||||
});
|
||||
const switchingCatalog = ref(false);
|
||||
|
||||
const connectionOptionIds = computed(() => connectionStore.connections.map((connection) => connection.id));
|
||||
const { connectionGroupLabel } = useConnectionGroupLabel();
|
||||
const activeDatabaseValue = computed(() => props.activeTab.database || "");
|
||||
const activeProductionContext = computed(() => productionContextForDatabase(props.activeConnection, props.activeTab.database));
|
||||
const showConnectionProductionBadge = computed(() => activeProductionContext.value.reason === "connection");
|
||||
@@ -225,10 +222,6 @@ function databaseDisplayName(database: string): string {
|
||||
});
|
||||
}
|
||||
|
||||
function connectionById(connectionId: string): ConnectionConfig | undefined {
|
||||
return connectionStore.getConfig(connectionId);
|
||||
}
|
||||
|
||||
function databaseOptionIsProduction(database: string): boolean {
|
||||
if (!database || props.activeConnection?.is_production) return false;
|
||||
return productionContextForDatabase(props.activeConnection, database).reason === "database";
|
||||
@@ -427,19 +420,16 @@ async function changeCatalog(selectedCatalog: string) {
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<div class="flex items-center gap-1">
|
||||
<span v-if="activeConnection?.color" class="h-4 w-1 rounded-full shrink-0" :style="{ backgroundColor: activeConnection.color }" />
|
||||
<SearchableSelect
|
||||
<ConnectionTreeSelect
|
||||
:model-value="activeConnectionValue"
|
||||
:options="connectionOptionIds"
|
||||
:connections="connectionStore.connections"
|
||||
:layout="connectionStore.sidebarLayout"
|
||||
:placeholder="t('editor.selectConnection')"
|
||||
:search-placeholder="t('editor.searchConnection')"
|
||||
:empty-text="t('grid.noSearchResults')"
|
||||
:loading-text="t('common.loading')"
|
||||
trigger-variant="ghost"
|
||||
trigger-class="font-medium text-foreground"
|
||||
trigger-icon-class="h-3 w-3"
|
||||
:display-name="connectionDisplayName"
|
||||
list-class="w-96 max-w-[calc(100vw-2rem)]"
|
||||
item-class="min-h-9 h-auto py-1"
|
||||
@update:model-value="(connectionId) => emit('changeConnection', connectionId)"
|
||||
>
|
||||
<template #trigger-label="{ label }">
|
||||
@@ -450,18 +440,7 @@ async function changeCatalog(selectedCatalog: string) {
|
||||
</div>
|
||||
<span v-else class="truncate text-muted-foreground">{{ t("editor.selectConnection") }}</span>
|
||||
</template>
|
||||
<template #option-label="{ option, label }">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<DatabaseIcon :db-type="connectionIconType(connectionById(option))" class="h-3.5 w-3.5 shrink-0" />
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<span class="block min-w-0 max-w-48 shrink-0 whitespace-normal break-words rounded-sm bg-muted/70 px-1.5 py-0.5 text-[11px] leading-tight text-muted-foreground">
|
||||
{{ connectionGroupLabel(option) }}
|
||||
</span>
|
||||
<TruncatedTextTooltip :text="label" class="block min-w-[7rem] flex-1 text-sm font-medium" side="left" :side-offset="8" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</SearchableSelect>
|
||||
</ConnectionTreeSelect>
|
||||
</div>
|
||||
<div v-if="showCatalogSelector" class="flex items-center gap-1">
|
||||
<SearchableSelect
|
||||
|
||||
Reference in new issue
Block a user