fix(sidebar): improve active connection indicator
This commit is contained in:
13 files changed
+162
-17
No files matched your search
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import { useId } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { CircleDot } from "@lucide/vue";
|
||||
import LightTooltip from "@/components/ui/LightTooltip.vue";
|
||||
|
||||
defineProps<{
|
||||
activeConnectionCount: number;
|
||||
pressed: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggle: [];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const activeConnectionStatusId = useId();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LightTooltip :text="t('sidebar.showActiveConnectionsOnly')" side="top" :delay="300" nowrap>
|
||||
<button
|
||||
type="button"
|
||||
data-active-connection-filter
|
||||
class="relative shrink-0 h-6 w-6 flex items-center justify-center rounded border hover:bg-accent"
|
||||
:class="pressed ? 'text-primary bg-primary/10 border-primary/30' : 'border-border text-muted-foreground hover:text-foreground'"
|
||||
:aria-label="t('sidebar.showActiveConnectionsOnly')"
|
||||
:aria-describedby="activeConnectionStatusId"
|
||||
:aria-pressed="pressed"
|
||||
@click="emit('toggle')"
|
||||
>
|
||||
<CircleDot data-active-connection-filter-icon class="h-3.5 w-3.5" />
|
||||
<span v-if="activeConnectionCount > 0" data-active-connection-badge class="pointer-events-none absolute right-0.5 top-0.5 h-1.5 w-1.5 rounded-full bg-green-500 ring-1 ring-background" aria-hidden="true" />
|
||||
<span :id="activeConnectionStatusId" class="sr-only" aria-live="polite" aria-atomic="true">{{ t("sidebar.activeConnectionsStatus", { count: activeConnectionCount }) }}</span>
|
||||
</button>
|
||||
</LightTooltip>
|
||||
</template>
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, shallowRef, computed, nextTick, watch, provide, onMounted, onUnmounted, type Component, type ComponentPublicInstance, type CSSProperties } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Search, X, ListFilter, ListOrdered, ArrowDownAZ, ArrowUpZA, CircleDot, Crosshair, Server, Database, FolderTree, Table2, Eye, RotateCcw, Loader2, Unplug } from "@lucide/vue";
|
||||
import { Search, X, ListFilter, ListOrdered, ArrowDownAZ, ArrowUpZA, Crosshair, Server, Database, FolderTree, Table2, Eye, RotateCcw, Loader2, Unplug } from "@lucide/vue";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useQueryStore } from "@/stores/queryStore";
|
||||
import { useSavedSqlStore } from "@/stores/savedSqlStore";
|
||||
@@ -55,6 +55,7 @@ import { createSidebarPasteHandlerRegistry } from "@/lib/sidebar/sidebarPasteHan
|
||||
import { insertSidebarTableSearchControls, isSidebarTableSearchControlNode } from "@/lib/sidebar/sidebarTableSearchControl";
|
||||
import { createSidebarTableSearchDebouncer, invalidateSidebarTableSearchBuild, loadOrBuildSidebarTableSearchIndex, scheduleExclusiveSidebarTableSearchDebounce } from "@/lib/sidebar/sidebarTableSearchIndex";
|
||||
import TreeItem from "./TreeItem.vue";
|
||||
import ActiveConnectionFilterButton from "./ActiveConnectionFilterButton.vue";
|
||||
import SidebarTreeRuntimeHost from "./SidebarTreeRuntimeHost.vue";
|
||||
import SidebarTreeItemDialogs from "./SidebarTreeItemDialogs.vue";
|
||||
import InstallExtensionDialog from "@/components/objects/InstallExtensionDialog.vue";
|
||||
@@ -2454,18 +2455,7 @@ defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes, locateTabInSid
|
||||
/>
|
||||
</span>
|
||||
</LightTooltip>
|
||||
<LightTooltip :text="t('sidebar.showActiveConnectionsOnly')" side="top" :delay="300" nowrap>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 h-6 w-6 flex items-center justify-center rounded border hover:bg-accent"
|
||||
:class="showConnectedConnectionsOnly ? 'text-primary bg-primary/10 border-primary/30' : 'border-border text-muted-foreground hover:text-foreground'"
|
||||
:aria-label="t('sidebar.showActiveConnectionsOnly')"
|
||||
:aria-pressed="showConnectedConnectionsOnly"
|
||||
@click="showConnectedConnectionsOnly = !showConnectedConnectionsOnly"
|
||||
>
|
||||
<CircleDot class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</LightTooltip>
|
||||
<ActiveConnectionFilterButton :active-connection-count="store.connectedIds.size" :pressed="showConnectedConnectionsOnly" @toggle="showConnectedConnectionsOnly = !showConnectedConnectionsOnly" />
|
||||
</div>
|
||||
</div>
|
||||
<CustomContextMenu ref="sidebarContextMenuRef" :items="sidebarContextMenuItems" v-slot="contextMenuSlot">
|
||||
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { createApp, defineComponent, h, nextTick, reactive, type App } from "vue";
|
||||
import { createI18n } from "vue-i18n";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import ActiveConnectionFilterButton from "../ActiveConnectionFilterButton.vue";
|
||||
|
||||
vi.mock("@/components/ui/LightTooltip.vue", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
return {
|
||||
default: defineComponent({
|
||||
name: "LightTooltipStub",
|
||||
setup:
|
||||
(_, { slots }) =>
|
||||
() =>
|
||||
h("div", slots.default?.()),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const mountedApps: App[] = [];
|
||||
|
||||
async function mountFilter(activeConnectionCount: number) {
|
||||
const state = reactive({ activeConnectionCount, pressed: false });
|
||||
const host = defineComponent({
|
||||
setup: () => () =>
|
||||
h(ActiveConnectionFilterButton, {
|
||||
activeConnectionCount: state.activeConnectionCount,
|
||||
pressed: state.pressed,
|
||||
onToggle: () => {
|
||||
state.pressed = !state.pressed;
|
||||
},
|
||||
}),
|
||||
});
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
const app = createApp(host);
|
||||
app.use(
|
||||
createI18n({
|
||||
legacy: false,
|
||||
locale: "en",
|
||||
messages: {
|
||||
en: {
|
||||
sidebar: {
|
||||
activeConnectionsStatus: "Active connections: {count}",
|
||||
showActiveConnectionsOnly: "Show active connections only",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
mountedApps.push(app);
|
||||
app.mount(container);
|
||||
await nextTick();
|
||||
const button = container.querySelector("[data-active-connection-filter]");
|
||||
if (!(button instanceof HTMLButtonElement)) throw new Error("Active connection filter button was not rendered");
|
||||
return { button, state };
|
||||
}
|
||||
|
||||
function describedStatus(button: HTMLButtonElement) {
|
||||
const statusId = button.getAttribute("aria-describedby");
|
||||
if (!statusId) throw new Error("Active connection status description is missing");
|
||||
const status = document.getElementById(statusId);
|
||||
if (!(status instanceof HTMLSpanElement)) throw new Error("Active connection status element was not rendered");
|
||||
return status;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const app of mountedApps.splice(0)) app.unmount();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
describe("ActiveConnectionFilterButton", () => {
|
||||
it("keeps the filter icon stable while the active connection status changes from 0 to 1 to 0", async () => {
|
||||
const { button, state } = await mountFilter(0);
|
||||
|
||||
expect(button.querySelector("[data-active-connection-filter-icon]")).not.toBeNull();
|
||||
expect(button.querySelector("[data-active-connection-badge]")).toBeNull();
|
||||
expect(describedStatus(button).textContent).toBe("Active connections: 0");
|
||||
|
||||
state.activeConnectionCount = 1;
|
||||
await nextTick();
|
||||
expect(button.querySelector("[data-active-connection-filter-icon]")).not.toBeNull();
|
||||
expect(button.querySelector("[data-active-connection-badge]")).not.toBeNull();
|
||||
expect(describedStatus(button).textContent).toBe("Active connections: 1");
|
||||
|
||||
state.activeConnectionCount = 0;
|
||||
await nextTick();
|
||||
expect(button.querySelector("[data-active-connection-filter-icon]")).not.toBeNull();
|
||||
expect(button.querySelector("[data-active-connection-badge]")).toBeNull();
|
||||
expect(describedStatus(button).textContent).toBe("Active connections: 0");
|
||||
});
|
||||
|
||||
it("preserves the pressed and unpressed filter states", async () => {
|
||||
const { button } = await mountFilter(1);
|
||||
|
||||
expect(button.getAttribute("aria-pressed")).toBe("false");
|
||||
button.click();
|
||||
await nextTick();
|
||||
expect(button.getAttribute("aria-pressed")).toBe("true");
|
||||
button.click();
|
||||
await nextTick();
|
||||
expect(button.getAttribute("aria-pressed")).toBe("false");
|
||||
});
|
||||
});
|
||||
@@ -322,6 +322,7 @@ export default {
|
||||
showMore: "Show {count} more...",
|
||||
filterByType: "Filter by type",
|
||||
showActiveConnectionsOnly: "Show active connections only",
|
||||
activeConnectionsStatus: "Active connections: {count}",
|
||||
disconnectAllActiveConnections: "Disconnect all active connections",
|
||||
sortConnections: "Sort connections",
|
||||
sortConnectionsManual: "Manual order",
|
||||
|
||||
@@ -324,6 +324,7 @@ export default withEnglishFallback({
|
||||
showMore: "Mostrar {count} más...",
|
||||
filterByType: "Filtrar por tipo",
|
||||
showActiveConnectionsOnly: "Mostrar solo conexiones activas",
|
||||
activeConnectionsStatus: "Conexiones activas: {count}",
|
||||
disconnectAllActiveConnections: "Desconectar todas las conexiones activas",
|
||||
sortConnections: "Ordenar conexiones",
|
||||
sortConnectionsManual: "Orden manual",
|
||||
|
||||
@@ -323,6 +323,7 @@ export default withEnglishFallback({
|
||||
showMore: "Mostra altri {count}...",
|
||||
filterByType: "Filtra per tipo",
|
||||
showActiveConnectionsOnly: "Mostra solo connessioni attive",
|
||||
activeConnectionsStatus: "Connessioni attive: {count}",
|
||||
disconnectAllActiveConnections: "Disconnetti tutte le connessioni attive",
|
||||
sortConnections: "Ordina connessioni",
|
||||
sortConnectionsManual: "Ordine manuale",
|
||||
|
||||
@@ -324,6 +324,7 @@ export default withEnglishFallback({
|
||||
showMore: "さらに{count}件表示...",
|
||||
filterByType: "タイプでフィルター",
|
||||
showActiveConnectionsOnly: "アクティブな接続のみを表示",
|
||||
activeConnectionsStatus: "アクティブな接続: {count}",
|
||||
disconnectAllActiveConnections: "すべてのアクティブな接続を切断",
|
||||
sortConnections: "接続を並べ替え",
|
||||
sortConnectionsManual: "手動順",
|
||||
|
||||
@@ -322,6 +322,7 @@ export default withEnglishFallback({
|
||||
showMore: "{count}개 더 보기...",
|
||||
filterByType: "유형별로 필터",
|
||||
showActiveConnectionsOnly: "활성 연결만 표시",
|
||||
activeConnectionsStatus: "활성 연결: {count}",
|
||||
disconnectAllActiveConnections: "모든 활성 연결 끊기",
|
||||
sortConnections: "연결 정렬",
|
||||
sortConnectionsManual: "수동 순서",
|
||||
|
||||
@@ -324,6 +324,7 @@ export default withEnglishFallback({
|
||||
showMore: "Mostrar mais {count}...",
|
||||
filterByType: "Filtrar por tipo",
|
||||
showActiveConnectionsOnly: "Mostrar apenas conexões ativas",
|
||||
activeConnectionsStatus: "Conexões ativas: {count}",
|
||||
disconnectAllActiveConnections: "Desconectar todas as conexões ativas",
|
||||
sortConnections: "Ordenar conexões",
|
||||
sortConnectionsManual: "Ordem manual",
|
||||
|
||||
@@ -247,6 +247,7 @@ export default withEnglishFallback({
|
||||
showMore: "加载更多 ({count})...",
|
||||
filterByType: "按类型筛选",
|
||||
showActiveConnectionsOnly: "仅显示活跃连接",
|
||||
activeConnectionsStatus: "活跃连接:{count}",
|
||||
disconnectAllActiveConnections: "关闭所有活跃连接",
|
||||
sortConnections: "连接排序",
|
||||
sortConnectionsManual: "手动排序",
|
||||
|
||||
@@ -324,6 +324,7 @@ export default withEnglishFallback({
|
||||
showMore: "再顯示 {count} 個……",
|
||||
filterByType: "依類型篩選",
|
||||
showActiveConnectionsOnly: "僅顯示作用中連線",
|
||||
activeConnectionsStatus: "作用中連線:{count}",
|
||||
disconnectAllActiveConnections: "關閉所有作用中連線",
|
||||
sortConnections: "連線排序",
|
||||
sortConnectionsManual: "手動排序",
|
||||
|
||||
@@ -9,6 +9,7 @@ const dialogOverlaySource = readFileSync(new URL("../../components/ui/dialog/Dia
|
||||
const dataTransferDialogSource = readFileSync(new URL("../../components/transfer/DataTransferDialog.vue", import.meta.url), "utf8");
|
||||
const connectionDialogSource = readFileSync(new URL("../../components/connection/ConnectionDialog.vue", import.meta.url), "utf8");
|
||||
const connectionTreeSource = readFileSync(new URL("../../components/sidebar/ConnectionTree.vue", import.meta.url), "utf8");
|
||||
const activeConnectionFilterSource = readFileSync(new URL("../../components/sidebar/ActiveConnectionFilterButton.vue", import.meta.url), "utf8");
|
||||
const scheduledDatabaseBackupSource = readFileSync(new URL("../../components/backup/ScheduledDatabaseBackupSettings.vue", import.meta.url), "utf8");
|
||||
const driverStoreDialogSource = readFileSync(new URL("../../components/config/DriverStoreDialog.vue", import.meta.url), "utf8");
|
||||
const tunnelProfileManagerSource = readFileSync(new URL("../../components/connection/TunnelProfileManager.vue", import.meta.url), "utf8");
|
||||
@@ -97,9 +98,10 @@ describe("legacy WebView CSS fallbacks", () => {
|
||||
expect(globalsCss).toContain(".border-primary\\/30");
|
||||
expect(globalsCss).toContain("border-color: rgba(var(--dbx-primary-rgb), 0.3) !important;");
|
||||
expect(globalsCss).toContain(".hover\\:bg-primary\\/15:hover");
|
||||
expect(connectionTreeSource).toContain("showActiveConnectionsOnly");
|
||||
const activeConnectionSources = `${connectionTreeSource}\n${activeConnectionFilterSource}`;
|
||||
expect(activeConnectionSources).toContain("showActiveConnectionsOnly");
|
||||
// Three pre-existing usages plus the sidebar regex-search toggle.
|
||||
expect(connectionTreeSource.match(/bg-primary\/10 border-primary\/30/g)?.length).toBe(4);
|
||||
expect(activeConnectionSources.match(/bg-primary\/10 border-primary\/30/g)?.length).toBe(4);
|
||||
});
|
||||
|
||||
it("keeps legacy tab triggers connected to the configured corner style", () => {
|
||||
|
||||
Reference in new issue
Block a user