diff --git a/apps/web/components/app-sidebar/sections/settings/settings-tree-render.test.tsx b/apps/web/components/app-sidebar/sections/settings/settings-tree-render.test.tsx index f6301c874b..408371bdb1 100644 --- a/apps/web/components/app-sidebar/sections/settings/settings-tree-render.test.tsx +++ b/apps/web/components/app-sidebar/sections/settings/settings-tree-render.test.tsx @@ -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("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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + expect(screen.getByRole("link", { name: "GitHub" })).toBeTruthy(); + }); +}); + describe("SettingsTree agents group", () => { beforeEach(() => { state.settingsAgents.items = [ diff --git a/apps/web/components/app-sidebar/sections/settings/workspaces-group.tsx b/apps/web/components/app-sidebar/sections/settings/workspaces-group.tsx index e981d8dfc7..1bbf71b4fc 100644 --- a/apps/web/components/app-sidebar/sections/settings/workspaces-group.tsx +++ b/apps/web/components/app-sidebar/sections/settings/workspaces-group.tsx @@ -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 ( - : 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 ( + : undefined} + icon={INTEGRATION_ICONS[slug]} + isActive={pathname === href} + depth={3} + /> + ); + }, + ); } type WorkspacesGroupProps = { diff --git a/apps/web/e2e/tests/integrations/hide-disabled-integrations-nav.spec.ts b/apps/web/e2e/tests/integrations/hide-disabled-integrations-nav.spec.ts index ce19433109..e67b6d7ea2 100644 --- a/apps/web/e2e/tests/integrations/hide-disabled-integrations-nav.spec.ts +++ b/apps/web/e2e/tests/integrations/hide-disabled-integrations-nav.spec.ts @@ -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(); diff --git a/docs/specs/integrations/enable-disable-toggle.md b/docs/specs/integrations/enable-disable-toggle.md index f59088c233..d493d95743 100644 --- a/docs/specs/integrations/enable-disable-toggle.md +++ b/docs/specs/integrations/enable-disable-toggle.md @@ -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 → + ) 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