fix(web): honor hide-disabled-in-nav in the settings integrations tree (#2461)

This commit is contained in:
Clément Fiere
2026-08-09 19:41:53 +02:00
committed by GitHub
parent d6e2c358da
commit 76341f7ce3
4 changed files with 216 additions and 39 deletions
@@ -40,6 +40,20 @@ const integrationAvailability = vi.hoisted(() => ({
sentry: false,
}));
// Per-integration enable/disable state plus the "hide disabled integrations
// from left panel navigation" setting, shared by the mocks below so each test
// can flip them independently (same shape the settings-tree-render suite uses
// for `integrationAvailability`).
const integrationEnabled = vi.hoisted(() => ({
"azure-devops": true,
github: true,
gitlab: true,
jira: true,
linear: true,
sentry: true,
}));
const hideDisabled = vi.hoisted(() => ({ value: false }));
vi.mock("@/components/state-provider", () => ({
useAppStore: (selector: (s: typeof state) => unknown) => selector(state),
}));
@@ -69,6 +83,33 @@ vi.mock("@/hooks/domains/linear/use-linear-availability", () => ({
vi.mock("@/hooks/domains/sentry/use-sentry-availability", () => ({
useSentryAvailable: () => integrationAvailability.sentry,
}));
vi.mock("@/hooks/domains/azure-devops/use-azure-devops-enabled", () => ({
useAzureDevOpsEnabled: () => ({
enabled: integrationEnabled["azure-devops"],
setEnabled: vi.fn(),
}),
}));
vi.mock("@/hooks/domains/github/use-github-enabled", () => ({
useGitHubEnabled: () => ({ enabled: integrationEnabled.github, setEnabled: vi.fn() }),
}));
vi.mock("@/hooks/domains/gitlab/use-gitlab-enabled", () => ({
useGitLabEnabled: () => ({ enabled: integrationEnabled.gitlab, setEnabled: vi.fn() }),
}));
vi.mock("@/hooks/domains/jira/use-jira-enabled", () => ({
useJiraEnabled: () => ({ enabled: integrationEnabled.jira, setEnabled: vi.fn() }),
}));
vi.mock("@/hooks/domains/linear/use-linear-enabled", () => ({
useLinearEnabled: () => ({ enabled: integrationEnabled.linear, setEnabled: vi.fn() }),
}));
vi.mock("@/hooks/domains/sentry/use-sentry-enabled", () => ({
useSentryEnabled: () => ({ enabled: integrationEnabled.sentry, setEnabled: vi.fn() }),
}));
vi.mock("@/hooks/domains/integrations/use-hide-disabled-integrations-in-nav", () => ({
useHideDisabledIntegrationsInNav: () => ({
hideDisabled: hideDisabled.value,
setHideDisabled: vi.fn(),
}),
}));
vi.mock("@kandev/ui/collapsible", async () => {
const React = await vi.importActual<typeof import("react")>("react");
const CollapsibleContext = React.createContext(false);
@@ -246,6 +287,87 @@ describe("SettingsTree integration status", () => {
});
});
describe("SettingsTree hide disabled integrations", () => {
beforeEach(() => {
state.workspaces.activeId = MAIN_WORKSPACE_ID;
state.workspaces.items = [{ id: MAIN_WORKSPACE_ID, name: MAIN_WORKSPACE_NAME }];
integrationAvailability.azureDevOps = true;
integrationAvailability.github = false;
integrationAvailability.gitlab = false;
integrationAvailability.jira = false;
integrationAvailability.linear = false;
integrationAvailability.sentry = false;
integrationEnabled["azure-devops"] = true;
integrationEnabled.github = true;
integrationEnabled.gitlab = true;
integrationEnabled.jira = true;
integrationEnabled.linear = true;
integrationEnabled.sentry = true;
hideDisabled.value = false;
});
afterEach(() => {
cleanup();
// Restore the file-scoped mock state so later suites in this file never
// inherit the previous test's hideDisabled/enabled flags.
hideDisabled.value = false;
integrationEnabled["azure-devops"] = true;
integrationEnabled.github = true;
integrationEnabled.gitlab = true;
integrationEnabled.jira = true;
integrationEnabled.linear = true;
integrationEnabled.sentry = true;
});
it("keeps every integration listed when the hide-disabled setting is off (default), even disabled ones", () => {
integrationEnabled.github = false;
render(
<WorkspacesGroup pathname="/settings/workspace/ws-1/integrations/azure-devops" expanded />,
);
for (const name of [/(Azure DevOps)/, /GitHub/, /GitLab/, /Jira/, /Linear/, /Sentry/]) {
expect(screen.getByRole("link", { name })).toBeTruthy();
}
});
it("hides a disabled integration from the workspace integrations list when the setting is on", () => {
integrationEnabled.github = false;
hideDisabled.value = true;
render(
<WorkspacesGroup pathname="/settings/workspace/ws-1/integrations/azure-devops" expanded />,
);
expect(screen.queryByRole("link", { name: "GitHub" })).toBeNull();
expect(screen.getByRole("link", { name: /Azure DevOps/ })).toBeTruthy();
});
it("keeps every integration listed when the setting is on but none are disabled", () => {
hideDisabled.value = true;
render(
<WorkspacesGroup pathname="/settings/workspace/ws-1/integrations/azure-devops" expanded />,
);
for (const name of [/(Azure DevOps)/, /GitHub/, /GitLab/, /Jira/, /Linear/, /Sentry/]) {
expect(screen.getByRole("link", { name })).toBeTruthy();
}
});
it("keeps an enabled-but-unconfigured integration listed when the setting is on", () => {
// GitHub is enabled (toggle on) but has no credentials — only *disabled*
// integrations are hidden by the setting, so it must stay reachable.
hideDisabled.value = true;
render(
<WorkspacesGroup pathname="/settings/workspace/ws-1/integrations/azure-devops" expanded />,
);
expect(screen.getByRole("link", { name: "GitHub" })).toBeTruthy();
});
});
describe("SettingsTree agents group", () => {
beforeEach(() => {
state.settingsAgents.items = [
@@ -18,11 +18,18 @@ import {
import { useAppStore } from "@/components/state-provider";
import { AzureDevOpsIcon } from "@/components/icons/azure-devops-icon";
import { useAzureDevOpsAvailable } from "@/hooks/domains/azure-devops/use-azure-devops-availability";
import { useAzureDevOpsEnabled } from "@/hooks/domains/azure-devops/use-azure-devops-enabled";
import { useGitHubStatus } from "@/hooks/domains/github/use-github-status";
import { useGitHubEnabled } from "@/hooks/domains/github/use-github-enabled";
import { useGitLabAvailable } from "@/hooks/domains/gitlab/use-task-mr";
import { useGitLabEnabled } from "@/hooks/domains/gitlab/use-gitlab-enabled";
import { useHideDisabledIntegrationsInNav } from "@/hooks/domains/integrations/use-hide-disabled-integrations-in-nav";
import { useJiraAuthed } from "@/hooks/domains/jira/use-jira-availability";
import { useJiraEnabled } from "@/hooks/domains/jira/use-jira-enabled";
import { useLinearAuthed } from "@/hooks/domains/linear/use-linear-availability";
import { useLinearEnabled } from "@/hooks/domains/linear/use-linear-enabled";
import { useSentryAvailable } from "@/hooks/domains/sentry/use-sentry-availability";
import { useSentryEnabled } from "@/hooks/domains/sentry/use-sentry-enabled";
import { WORKSPACE_INTEGRATIONS } from "@/lib/settings-discovery/catalog/integrations";
import { WORKSPACES_SETTINGS_HREF } from "@/lib/settings-discovery/catalog/workspaces";
import { SettingsGroup, SettingsLeaf } from "./settings-nav-primitives";
@@ -73,7 +80,14 @@ function WorkspaceIntegrationItems({
const jira = useJiraAuthed(workspaceId);
const linear = useLinearAuthed(workspaceId);
const sentry = useSentryAvailable(workspaceId);
const enabled = new Set([
const { enabled: azureDevOpsEnabled } = useAzureDevOpsEnabled();
const { enabled: githubEnabled } = useGitHubEnabled();
const { enabled: gitlabEnabled } = useGitLabEnabled();
const { enabled: jiraEnabled } = useJiraEnabled();
const { enabled: linearEnabled } = useLinearEnabled();
const { enabled: sentryEnabled } = useSentryEnabled();
const { hideDisabled } = useHideDisabledIntegrationsInNav();
const configured = new Set([
...(azureDevOps ? ["azure-devops"] : []),
...(githubStatus?.authenticated || githubStatus?.token_configured ? ["github"] : []),
...(gitlab ? ["gitlab"] : []),
@@ -81,21 +95,35 @@ function WorkspaceIntegrationItems({
...(linear ? ["linear"] : []),
...(sentry ? ["sentry"] : []),
]);
const toggleEnabled: Record<(typeof WORKSPACE_INTEGRATIONS)[number][0], boolean> = {
"azure-devops": azureDevOpsEnabled,
github: githubEnabled,
gitlab: gitlabEnabled,
jira: jiraEnabled,
linear: linearEnabled,
sentry: sentryEnabled,
};
return WORKSPACE_INTEGRATIONS.map(([slug, label]) => {
const href = `${integrationsPath}/${slug}`;
return (
<SettingsLeaf
key={href}
href={href}
label={label}
labelSuffix={enabled.has(slug) ? <EnabledBadge /> : undefined}
icon={INTEGRATION_ICONS[slug]}
isActive={pathname === href}
depth={3}
/>
);
});
// Configured status gates the badge only, never the row itself — the tree
// always lists all six integrations regardless of credentials, so the
// filter here hides only disabled integrations (intentionally looser than
// useNavAvailability's `configured && (!hideDisabled || enabled)`).
return WORKSPACE_INTEGRATIONS.filter(([slug]) => !hideDisabled || toggleEnabled[slug]).map(
([slug, label]) => {
const href = `${integrationsPath}/${slug}`;
return (
<SettingsLeaf
key={href}
href={href}
label={label}
labelSuffix={configured.has(slug) ? <EnabledBadge /> : undefined}
icon={INTEGRATION_ICONS[slug]}
isActive={pathname === href}
depth={3}
/>
);
},
);
}
type WorkspacesGroupProps = {
@@ -37,6 +37,17 @@ test.describe("hide disabled integrations from left panel navigation", () => {
const githubNavLink = testPage.getByRole("link", { name: "GitHub" });
await expect(githubNavLink).toBeVisible({ timeout: 10_000 });
// The Settings left panel's per-workspace Integrations tree is also part
// of left-panel navigation: with "hide disabled" off (default) the
// disabled-but-configured GitHub entry must stay listed there too.
const { workspaces } = await apiClient.listWorkspaces();
const workspaceId = workspaces[0].id;
await testPage.goto(`/settings/workspace/${workspaceId}/integrations`);
const settingsTree = testPage.getByTestId("app-sidebar-settings-mode");
await expect(settingsTree.getByRole("link", { name: /GitHub/ })).toBeVisible({
timeout: 10_000,
});
// Turn "hide disabled" on.
await testPage.goto("/settings/integrations");
await expect(testPage.locator("#github-enabled")).toHaveAttribute("aria-checked", "false");
@@ -54,6 +65,13 @@ test.describe("hide disabled integrations from left panel navigation", () => {
await testPage.goto("/tasks");
await expect(testPage.getByRole("link", { name: "GitHub" })).not.toBeVisible();
// The Settings left-panel Integrations tree hides it as well.
await testPage.goto(`/settings/workspace/${workspaceId}/integrations`);
await expect(settingsTree.getByRole("link", { name: /GitHub/ })).not.toBeVisible();
await expect(settingsTree.getByRole("link", { name: /Azure DevOps/ })).toBeVisible({
timeout: 10_000,
});
// Re-enabling GitHub reveals it again even with "hide disabled" still on.
await testPage.goto("/settings/integrations");
await testPage.locator("#github-enabled").click();
@@ -43,12 +43,16 @@ this spec was written and is out of scope.)
integrations from left panel navigation"**, disabled (off) by default.
- When that setting is **off** (default), a disabled-but-configured
integration MUST still appear in the left panel navigation (the sidebar's
Integrations section and its mobile-menu equivalent) exactly as it does
today for an enabled integration — only credential/health status controls
nav visibility.
- When that setting is **on**, a disabled integration MUST be hidden from the
left panel navigation regardless of its credential/health status. An
enabled, healthy integration is unaffected.
Integrations section, its mobile-menu equivalent, and the Settings left
panel's per-workspace Integrations list under Settings → Workspaces →
<workspace>) exactly as it does today for an enabled integration — only
credential/health status controls nav visibility.
- When that setting is **on**, a disabled integration MUST be hidden from all
of those left-panel surfaces regardless of its credential/health status. An
enabled, healthy integration is unaffected. Only integrations whose
enable/disable toggle is off are hidden — an enabled-but-unconfigured
integration stays listed (the Settings tree keeps its own configured-status
badge convention).
- The new setting SHALL NOT change any other behavior gated on an
integration's existing "available" signal (e.g. Jira/Linear import
popovers, Kanban external-link buttons, task-top-bar issue buttons) — those
@@ -144,11 +148,13 @@ toggle).
false` (its documented default) under the same failure, so a storage
failure never hides an integration a user can't otherwise see or control.
- No integration ever hides from a *settings* page based on the new nav
setting — `hideDisabled` only ever affects `useNavAvailability`'s output,
never a settings route's reachability. A user can always reach a disabled
integration's own settings page directly (by URL, from the index page, or
from the sidebar's Settings section) and re-enable it even while the
left-panel nav entry is hidden.
setting — `hideDisabled` affects only left-panel navigation surfaces
(`useNavAvailability`'s output for the main sidebar and mobile menu, and
`WorkspaceIntegrationItems`' filter for the Settings left panel's
per-workspace Integrations tree), never a settings route's reachability.
A user can always reach a disabled integration's own settings page
directly (by URL, from the index page, or from the sidebar's Settings
section) and re-enable it even while the left-panel nav entry is hidden.
## Persistence guarantees
@@ -203,20 +209,23 @@ is not synced across devices/browsers.
visibility, not functional gating (unlike Jira/Linear/Sentry's
existing toggle, which already gates other surfaces and is unchanged by
this feature).
- No change to the Settings-page navigation tree
(`components/app-sidebar/sections/settings/workspaces-group.tsx`) — "left
panel navigation" in this spec refers to the main `AppSidebar`'s
Integrations section and its mobile-menu equivalent
(`components/app-sidebar/sections/integrations-section.tsx`,
`components/integrations/integrations-menu.tsx`), not the Settings
section's own workspace/integration tree, which has a different,
unrelated status-badge convention and is not user-navigable "left panel
navigation" in the product sense used by this request.
- Sentry has no left-panel nav destination today (see
- No change to the Settings-page navigation tree's status-badge convention
(`components/app-sidebar/sections/settings/workspaces-group.tsx`): its
per-workspace Integrations list keeps the configured-status badge
("Enabled" = credentials/health present), which is unrelated to the
enable/disable toggle. The "hide disabled" setting DOES filter that list
(a disabled integration is hidden from it when the setting is on), since
users reach it through left-panel navigation; the badge convention itself
is unchanged.
- Sentry has no main-sidebar nav destination today (see
`lib/navigation/core-destinations.ts` — only Azure DevOps, GitHub, GitLab,
Jira and Linear are nav-gated). The new "hide disabled" setting therefore
has no observable effect for Sentry; its existing slider (own page
+ new index-page row) still works for the enable/disable state itself.
Jira and Linear are nav-gated), so the setting has no effect for Sentry in
the main sidebar or mobile menu. Sentry DOES appear in the Settings left
panel's per-workspace Integrations tree (all six integrations are always
listed there), so with the setting on and Sentry's toggle off it is hidden
from that tree like any other disabled integration; its existing slider
(own page + new index-page row) still works for the enable/disable state
itself.
- No new command-palette entries or keyboard shortcuts for the new toggles.
## Open questions