feat(sidebar): expose visible object filters in the connection tree
* feat(sidebar): expose visible object filter * fix(sidebar): refine visible filter status * refactor(sidebar): move visible filter status to details * fix(sidebar): clarify visible filter action
This commit is contained in:
1 parent
cc5a4068d2
commit
dfb9971849
26 files changed
+793
-9
No files matched your search
@@ -4828,6 +4828,11 @@ function handleRowKeydown(node: TreeNode, event: KeyboardEvent) {
|
||||
onKeydown(event);
|
||||
}
|
||||
|
||||
function openPrimaryVisibleFilter(node: TreeNode) {
|
||||
activateRuntimeNode(node);
|
||||
openVisibleDatabasesDialog();
|
||||
}
|
||||
|
||||
function openDataInNewTab(node: TreeNode) {
|
||||
activateRuntimeNode(node);
|
||||
openDataInNewTabImmediately(node);
|
||||
@@ -4848,6 +4853,7 @@ defineExpose({
|
||||
handleRowClick,
|
||||
handleRowDoubleClick,
|
||||
handleRowKeydown,
|
||||
openPrimaryVisibleFilter,
|
||||
openDataInNewTab,
|
||||
requestPaste,
|
||||
toggleNode,
|
||||
|
||||
@@ -62,6 +62,7 @@ import { shouldMeasureSidebarLabelOverflow } from "@/lib/sidebar/sidebarLabelToo
|
||||
import { treeSelectionRangeIdsByIndex, treeSelectionRangeIds } from "@/lib/sidebar/sidebarTreeSelection";
|
||||
import { isSidebarDatabaseOpenForVisual } from "@/lib/sidebar/sidebarDatabaseOpenState";
|
||||
import { sidebarTreeContextKey } from "@/lib/sidebar/sidebarTreeContext";
|
||||
import { connectionCanConfigureSidebarVisibleDatabases } from "@/lib/sidebar/sidebarVisibleFilterMenu";
|
||||
import { isWindows } from "@/lib/backend/platform";
|
||||
import { flattenTree } from "@/composables/useFlatTree";
|
||||
import { productionContextForDatabase } from "@/lib/database/productionSafety";
|
||||
@@ -364,6 +365,8 @@ type DetailTooltipRow = {
|
||||
multiline?: boolean;
|
||||
/** When set, renders each value on its own line (e.g. one host per line) */
|
||||
values?: string[];
|
||||
action?: () => void;
|
||||
actionLabel?: string;
|
||||
};
|
||||
|
||||
function cleanTooltipValue(value: string | number | null | undefined): string {
|
||||
@@ -420,6 +423,16 @@ const detailTooltip = computed(() => {
|
||||
.map((h) => h.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
const visibleFilterSummary = connectionCanConfigureSidebarVisibleDatabases(config.db_type) ? connectionStore.getSidebarVisibleFilterSummary(node.connectionId) : null;
|
||||
const visibleFilterRow: DetailTooltipRow | null =
|
||||
visibleFilterSummary?.selected != null && visibleFilterSummary.total != null
|
||||
? {
|
||||
label: t(visibleFilterSummary.mode === "schema" ? "visibleSchemas.detailLabel" : "visibleDatabases.detailLabel"),
|
||||
value: `${visibleFilterSummary.selected}/${visibleFilterSummary.total}`,
|
||||
action: () => treeRuntime.openPrimaryVisibleFilter(node),
|
||||
actionLabel: t(visibleFilterSummary.mode === "schema" ? "visibleSchemas.detailActionLabel" : "visibleDatabases.detailActionLabel", { connection: config.name }),
|
||||
}
|
||||
: null;
|
||||
const rows: DetailTooltipRow[] = [
|
||||
{ label: t("connection.name"), value: cleanTooltipValue(config.name) },
|
||||
{ label: "URL", value: connectionTooltipUrl(config), multiline: true },
|
||||
@@ -429,6 +442,7 @@ const detailTooltip = computed(() => {
|
||||
{ label: t("connection.user"), value: cleanTooltipValue(config.username) },
|
||||
{ label: t("connection.type"), value: config.driver_label || config.driver_profile || config.db_type },
|
||||
{ label: t("connection.databaseInfo.productVersion"), value: cleanTooltipValue(config.database_info?.productVersion) },
|
||||
...(visibleFilterRow ? [visibleFilterRow] : []),
|
||||
{ label: t("connection.note"), value: cleanTooltipValue(config.note), multiline: true },
|
||||
].filter((row) => row.value);
|
||||
return { rows };
|
||||
@@ -1248,6 +1262,16 @@ function onKeydown(event: KeyboardEvent) {
|
||||
<span v-for="(v, vi) in row.values" :key="vi" class="break-all">{{ v }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<button
|
||||
v-else-if="row.action"
|
||||
type="button"
|
||||
class="w-fit rounded bg-primary/10 px-1 font-mono text-primary underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
:aria-label="row.actionLabel"
|
||||
:title="row.actionLabel"
|
||||
@click.stop="row.action()"
|
||||
>
|
||||
{{ row.value }}
|
||||
</button>
|
||||
<span v-else-if="row.multiline" class="max-h-20 overflow-hidden whitespace-pre-wrap break-words text-foreground/90">
|
||||
{{ row.value }}
|
||||
</span>
|
||||
|
||||
@@ -84,6 +84,7 @@ async function loadDatabases() {
|
||||
searchText.value = "";
|
||||
try {
|
||||
const names = await loadObjectNames();
|
||||
connectionStore.recordPrimaryVisibleObjectNames(props.connectionId, names);
|
||||
objectNames.value = names;
|
||||
showSystemDatabases.value = false;
|
||||
const configured = isSchemaFilterMode.value ? connection.value?.visible_schemas?.[databaseKey.value] : connection.value?.visible_databases;
|
||||
|
||||
+1
@@ -61,6 +61,7 @@ function runtimeHost(): SidebarTreeRuntimeHost {
|
||||
handleRowClick: vi.fn(),
|
||||
handleRowDoubleClick: vi.fn(),
|
||||
handleRowKeydown: vi.fn(),
|
||||
openPrimaryVisibleFilter: vi.fn(),
|
||||
openDataInNewTab: vi.fn(),
|
||||
requestPaste: vi.fn(() => false),
|
||||
toggleNode: vi.fn(),
|
||||
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { createApp, defineComponent, h, nextTick, type App } from "vue";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "@/i18n";
|
||||
import TreeItem from "@/components/sidebar/TreeItem.vue";
|
||||
import { createSidebarTreeRuntime, sidebarTreeRuntimeKey, type SidebarTreeRuntimeHost } from "@/lib/sidebar/sidebarTreeRuntime";
|
||||
import type { SidebarVisibleFilterSummary } from "@/lib/sidebar/sidebarVisibleFilterSummary";
|
||||
import type { ConnectionConfig, TreeNode } from "@/types/database";
|
||||
|
||||
const state: {
|
||||
config: ConnectionConfig;
|
||||
summary: SidebarVisibleFilterSummary;
|
||||
} = {
|
||||
config: mysqlConnection(),
|
||||
summary: { mode: "database", isActive: true, selected: 2, total: 3 },
|
||||
};
|
||||
|
||||
function mysqlConnection(): ConnectionConfig {
|
||||
return {
|
||||
id: "connection-1",
|
||||
name: "Filtered connection",
|
||||
db_type: "mysql",
|
||||
host: "127.0.0.1",
|
||||
port: 3306,
|
||||
username: "root",
|
||||
password: "",
|
||||
database: "filter_alpha",
|
||||
} as ConnectionConfig;
|
||||
}
|
||||
|
||||
const connectionStore = {
|
||||
activeConnectionId: "connection-1",
|
||||
connectedIds: new Set(["connection-1"]),
|
||||
connectingIds: new Set<string>(),
|
||||
connectionErrors: {},
|
||||
connectionMultiSelectActive: false,
|
||||
connections: [],
|
||||
getConfig: () => state.config,
|
||||
getSidebarVisibleFilterSummary: () => state.summary,
|
||||
clearConnectionError: vi.fn(),
|
||||
isDefaultDatabase: () => false,
|
||||
isPinnedTreeNodeReorderTarget: () => false,
|
||||
isTreeNodeChildrenLoaded: () => false,
|
||||
isTreeNodePinned: () => false,
|
||||
selectedTreeNodeId: null as string | null,
|
||||
selectedTreeNodeIds: [] as string[],
|
||||
selectedTreeNodeIdsSet: new Set<string>(),
|
||||
sidebarTableSearchQueries: {},
|
||||
tableNameFilterForScope: () => undefined,
|
||||
treeNodes: [],
|
||||
treeSelectionAnchorId: null as string | null,
|
||||
};
|
||||
|
||||
vi.mock("@/stores/connectionStore", () => ({
|
||||
useConnectionStore: () => connectionStore,
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/queryStore", () => ({
|
||||
useQueryStore: () => ({ openDatabaseKeys: new Set<string>() }),
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/settingsStore", () => ({
|
||||
useSettingsStore: () => ({
|
||||
editorSettings: {
|
||||
shortcuts: { openDataInNewTab: "" },
|
||||
sidebarActivation: "double",
|
||||
sidebarAllowHorizontalScroll: false,
|
||||
sidebarHiddenTablePrefixes: [],
|
||||
sidebarObjectInfoMode: "none",
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/composables/useToast", () => ({
|
||||
useToast: () => ({ toast: vi.fn() }),
|
||||
}));
|
||||
|
||||
const mountedApps: App[] = [];
|
||||
|
||||
function runtimeHost(): SidebarTreeRuntimeHost {
|
||||
return {
|
||||
buildContextMenu: vi.fn(() => []),
|
||||
handleRowClick: vi.fn(),
|
||||
handleRowDoubleClick: vi.fn(),
|
||||
handleRowKeydown: vi.fn(),
|
||||
openPrimaryVisibleFilter: vi.fn(),
|
||||
openDataInNewTab: vi.fn(),
|
||||
requestPaste: vi.fn(() => false),
|
||||
toggleNode: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
async function openConnectionTooltip() {
|
||||
vi.useFakeTimers();
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
const node: TreeNode = {
|
||||
id: "connection-1",
|
||||
label: "Filtered connection",
|
||||
type: "connection",
|
||||
connectionId: "connection-1",
|
||||
isExpanded: true,
|
||||
children: [],
|
||||
};
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
setup: () => () => h(TreeItem, { node, depth: 0 }),
|
||||
}),
|
||||
);
|
||||
mountedApps.push(app);
|
||||
const runtime = createSidebarTreeRuntime();
|
||||
const host = runtimeHost();
|
||||
runtime.bindHost(host);
|
||||
app.use(i18n);
|
||||
app.provide(sidebarTreeRuntimeKey, runtime);
|
||||
app.mount(container);
|
||||
await nextTick();
|
||||
|
||||
const row = container.querySelector<HTMLElement>("[tabindex]");
|
||||
if (!row) throw new Error(`Connection row was not rendered: ${container.innerHTML}`);
|
||||
const trigger = row.parentElement;
|
||||
if (!trigger) throw new Error(`Connection tooltip trigger was not rendered: ${container.innerHTML}`);
|
||||
vi.spyOn(row, "matches").mockImplementation((selector) => selector === ":hover");
|
||||
trigger.dispatchEvent(new MouseEvent("mouseenter"));
|
||||
await vi.runAllTimersAsync();
|
||||
await nextTick();
|
||||
return { host, node };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const app of mountedApps.splice(0)) app.unmount();
|
||||
document.body.replaceChildren();
|
||||
vi.useRealTimers();
|
||||
state.config = mysqlConnection();
|
||||
state.summary = { mode: "database", isActive: true, selected: 2, total: 3 };
|
||||
});
|
||||
|
||||
describe("TreeItem visible filter connection detail", () => {
|
||||
it("shows an active database filter count in the connection tooltip", async () => {
|
||||
const { host, node } = await openConnectionTooltip();
|
||||
|
||||
expect(document.body.textContent).toContain("Visible databases");
|
||||
expect(document.body.textContent).toContain("2/3");
|
||||
const action = [...document.querySelectorAll<HTMLButtonElement>("button")].find((button) => button.textContent?.trim() === "2/3");
|
||||
expect(action).toBeDefined();
|
||||
expect(action?.getAttribute("aria-label")).toBe('Configure visible databases for "Filtered connection"');
|
||||
expect(action?.classList.contains("bg-primary/10")).toBe(true);
|
||||
expect(action?.classList.contains("underline")).toBe(false);
|
||||
expect(action?.classList.contains("hover:underline")).toBe(true);
|
||||
expect(action?.classList.contains("hover:bg-primary/10")).toBe(false);
|
||||
action?.click();
|
||||
expect(host.openPrimaryVisibleFilter).toHaveBeenCalledWith(node);
|
||||
});
|
||||
|
||||
it("uses the schema label for a schema-mode summary", async () => {
|
||||
state.config = { ...mysqlConnection(), db_type: "oracle", database: "ORCL" };
|
||||
state.summary = { mode: "schema", isActive: true, selected: 1, total: 4 };
|
||||
|
||||
await openConnectionTooltip();
|
||||
|
||||
expect(document.body.textContent).toContain("Visible schemas");
|
||||
expect(document.body.textContent).toContain("1/4");
|
||||
const action = [...document.querySelectorAll<HTMLButtonElement>("button")].find((button) => button.textContent?.trim() === "1/4");
|
||||
expect(action?.getAttribute("aria-label")).toBe('Configure visible schemas for "Filtered connection"');
|
||||
});
|
||||
|
||||
it("shows a clickable detail row when the effective selection is full", async () => {
|
||||
state.summary = { mode: "database", isActive: false, selected: 3, total: 3 };
|
||||
|
||||
const { host, node } = await openConnectionTooltip();
|
||||
|
||||
expect(document.body.textContent).toContain("Visible databases");
|
||||
expect(document.body.textContent).toContain("3/3");
|
||||
const action = [...document.querySelectorAll<HTMLButtonElement>("button")].find((button) => button.textContent?.trim() === "3/3");
|
||||
expect(action).toBeDefined();
|
||||
action?.click();
|
||||
expect(host.openPrimaryVisibleFilter).toHaveBeenCalledWith(node);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user