feat(web): add per-integration enable/disable toggle (#2364)

Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
Clément Fiere
2026-08-07 20:01:07 +02:00
committed by GitHub
parent 58f6533ab1
commit 312dc0657f
54 changed files with 2504 additions and 257 deletions
@@ -1,37 +1,9 @@
import { GitHubIntegrationPage } from "@/components/github/github-settings";
import { StateHydrator } from "@/components/state-hydrator";
import { normalizeGitHubStatus } from "@/hooks/domains/github/use-github-status";
import { fetchGitHubStatus } from "@/lib/api/domains/github-api";
type IntegrationsGitHubPageProps = {
workspaceId?: string;
};
export default async function IntegrationsGitHubPage({
workspaceId,
}: IntegrationsGitHubPageProps = {}) {
const status = workspaceId
? await fetchGitHubStatus(workspaceId, { cache: "no-store" }).catch(() => null)
: null;
const statusWorkspaceId = workspaceId ?? status?.workspace_id;
const initialState =
status && statusWorkspaceId
? {
githubStatus: {
byWorkspaceId: {
[statusWorkspaceId]: {
status: normalizeGitHubStatus(status),
loaded: true,
loading: false,
},
},
},
}
: {};
return (
<>
<StateHydrator initialState={initialState} />
<GitHubIntegrationPage workspaceId={workspaceId} />
</>
);
export default function IntegrationsGitHubPage({ workspaceId }: IntegrationsGitHubPageProps = {}) {
return <GitHubIntegrationPage workspaceId={workspaceId} />;
}
@@ -0,0 +1,118 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SettingsSaveProvider } from "@/components/settings/settings-save-provider";
import { IntegrationsIndexPage } from "@/components/integrations/integrations-index-page";
const { pushNavigationStateSpy } = vi.hoisted(() => ({
pushNavigationStateSpy: vi.fn(),
}));
vi.mock("@/lib/routing/navigation-guard", () => ({
pushNavigationState: (...args: unknown[]) => pushNavigationStateSpy(...args),
setNavigationBlocker: () => () => {},
}));
const { makeEnabledMock } = vi.hoisted(() => ({
makeEnabledMock: (enabled: boolean) => () => ({ enabled, setEnabled: vi.fn(), loaded: true }),
}));
vi.mock("@/hooks/domains/azure-devops/use-azure-devops-enabled", () => ({
useAzureDevOpsEnabled: makeEnabledMock(true),
}));
vi.mock("@/hooks/domains/github/use-github-enabled", () => ({
useGitHubEnabled: makeEnabledMock(false),
}));
vi.mock("@/hooks/domains/gitlab/use-gitlab-enabled", () => ({
useGitLabEnabled: makeEnabledMock(true),
}));
vi.mock("@/hooks/domains/jira/use-jira-enabled", () => ({
useJiraEnabled: makeEnabledMock(true),
}));
vi.mock("@/hooks/domains/linear/use-linear-enabled", () => ({
useLinearEnabled: makeEnabledMock(true),
}));
vi.mock("@/hooks/domains/sentry/use-sentry-enabled", () => ({
useSentryEnabled: makeEnabledMock(true),
}));
beforeEach(() => {
pushNavigationStateSpy.mockClear();
window.localStorage.removeItem("kandev:integrations:hideDisabledInNav:v1");
});
afterEach(cleanup);
function renderPage(workspaceId?: string) {
return render(
<SettingsSaveProvider>
<IntegrationsIndexPage workspaceId={workspaceId} />
</SettingsSaveProvider>,
);
}
const ARIA_CHECKED_TRUE = "true";
const ARIA_CHECKED_FALSE = "false";
function ariaChecked(element: Element | null) {
return element?.getAttribute("aria-checked");
}
describe("IntegrationsIndexPage", () => {
it("renders one enable/disable slider per integration, reflecting its stored state", () => {
renderPage();
const switches = screen.getAllByRole("switch");
expect(switches).toHaveLength(7);
expect(ariaChecked(document.getElementById("azure-devops-enabled"))).toBe(ARIA_CHECKED_TRUE);
expect(ariaChecked(document.getElementById("github-enabled"))).toBe(ARIA_CHECKED_FALSE);
});
it("renders the hide-disabled-in-nav setting off by default and drafts a toggle without persisting until save", () => {
renderPage();
const hideDisabledSwitch = document.getElementById("hide-disabled-integrations-in-nav");
expect(hideDisabledSwitch).not.toBeNull();
expect(ariaChecked(hideDisabledSwitch)).toBe(ARIA_CHECKED_FALSE);
fireEvent.click(hideDisabledSwitch as HTMLElement);
// Drafted: the switch visually flips immediately...
expect(ariaChecked(hideDisabledSwitch)).toBe(ARIA_CHECKED_TRUE);
// ...but nothing is persisted to localStorage before the shared save action fires.
expect(window.localStorage.getItem("kandev:integrations:hideDisabledInNav:v1")).toBeNull();
});
it("navigates when the integration label is clicked", () => {
renderPage();
fireEvent.click(screen.getByRole("link", { name: "Azure DevOps" }));
expect(pushNavigationStateSpy).toHaveBeenCalledWith(
{},
"",
"/settings/integrations/azure-devops",
expect.any(Function),
);
});
it("keeps workspace-scoped integration links on the workspace route", () => {
renderPage("ws-1");
expect(screen.getByRole("link", { name: "Azure DevOps" }).getAttribute("href")).toBe(
"/settings/workspace/ws-1/integrations/azure-devops",
);
});
it("does not navigate when a slider is toggled", () => {
renderPage();
const githubSwitch = document.getElementById("github-enabled");
expect(githubSwitch).not.toBeNull();
fireEvent.click(githubSwitch as HTMLElement);
expect(pushNavigationStateSpy).not.toHaveBeenCalled();
// The click still lands: the switch's own (drafted) state flips.
expect(ariaChecked(githubSwitch)).toBe(ARIA_CHECKED_TRUE);
});
});
+3 -90
View File
@@ -1,92 +1,5 @@
import Link from "@/components/routing/app-link";
import {
IconBrandGithub,
IconBrandGitlab,
IconBrandAzure,
IconBrandSentry,
IconHexagon,
IconTicket,
} from "@tabler/icons-react";
import { Card, CardContent } from "@kandev/ui/card";
import { useTranslation } from "react-i18next";
import { IntegrationsIndexPage } from "@/components/integrations/integrations-index-page";
const INTEGRATIONS = [
{
slug: "azure-devops",
label: "Azure DevOps",
descriptionKey: "settings:integrationDescriptionAzureDevops",
Icon: IconBrandAzure,
},
{
slug: "github",
label: "GitHub",
descriptionKey: "settings:integrationDescriptionGithub",
Icon: IconBrandGithub,
},
{
slug: "gitlab",
label: "GitLab",
descriptionKey: "settings:integrationDescriptionGitlab",
Icon: IconBrandGitlab,
},
{
slug: "jira",
label: "Jira",
descriptionKey: "settings:integrationDescriptionJira",
Icon: IconTicket,
},
{
slug: "linear",
label: "Linear",
descriptionKey: "settings:integrationDescriptionLinear",
Icon: IconHexagon,
},
{
slug: "sentry",
label: "Sentry",
descriptionKey: "settings:integrationDescriptionSentry",
Icon: IconBrandSentry,
},
];
type IntegrationsIndexPageProps = {
workspaceId?: string;
};
export default function IntegrationsIndexPage({ workspaceId }: IntegrationsIndexPageProps = {}) {
const { t } = useTranslation();
const rootHref = workspaceId
? `/settings/workspace/${encodeURIComponent(workspaceId)}/integrations`
: "/settings/integrations";
return (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold">{t("common:integrations")}</h2>
<p className="text-sm text-muted-foreground mt-1">
{t("settings:connectKandevToThirdPartyServices")}
</p>
</div>
<div className="grid auto-rows-fr gap-3 sm:grid-cols-2 lg:grid-cols-3">
{INTEGRATIONS.map(({ slug, label, descriptionKey, Icon }) => {
const href = `${rootHref}/${slug}`;
return (
<Link key={href} href={href} className="flex h-full cursor-pointer">
<Card className="h-full w-full transition-colors hover:border-primary/40">
<CardContent className="space-y-2">
<div className="flex items-center gap-2 text-base font-semibold">
<Icon className="h-5 w-5" />
{label}
</div>
<p className="text-sm text-muted-foreground">
{t(descriptionKey, { trigger: "!kandev" })}
</p>
</CardContent>
</Card>
</Link>
);
})}
</div>
</div>
);
export default function IntegrationsIndexPageRoute() {
return <IntegrationsIndexPage />;
}
@@ -31,6 +31,7 @@ import { SystemGroup } from "./system-group";
import { WorkspacesGroup } from "./workspaces-group";
const SECRETS_HREF = "/settings/general/secrets";
const INTEGRATIONS_SETTINGS_HREF = "/settings/integrations";
const DEFAULT_OPEN_GROUP = "workspaces";
// Single-open accordion: each top-level group owns a route prefix. The group
@@ -129,6 +130,12 @@ export function SettingsTree({ pathname }: { pathname: string }) {
icon={IconPlugConnected}
isActive={pathname === EXTERNAL_MCP_SETTINGS_HREF}
/>
<SettingsLeaf
href={INTEGRATIONS_SETTINGS_HREF}
label={t("common:integrations")}
icon={IconPlugConnected}
isActive={pathname === INTEGRATIONS_SETTINGS_HREF}
/>
<PluginSlot name="settings-nav" />
<SettingsLeaf
href={PLUGINS_SETTINGS_HREF}
@@ -0,0 +1,17 @@
"use client";
import { DraftedIntegrationEnabledControl } from "@/components/integrations/drafted-integration-enabled-control";
import { useAzureDevOpsEnabled } from "@/hooks/domains/azure-devops/use-azure-devops-enabled";
/** Enable/disable slider for the Azure DevOps integration, wired to `useAzureDevOpsEnabled`. */
export function AzureDevOpsEnabledControl() {
const { enabled, setEnabled } = useAzureDevOpsEnabled();
return (
<DraftedIntegrationEnabledControl
id="azure-devops"
name="Azure DevOps"
enabled={enabled}
persist={setEnabled}
/>
);
}
@@ -90,7 +90,11 @@ afterEach(cleanup);
describe("AzureDevOpsConnectionSection", () => {
it("links to the organization PAT page and explains the required read scopes", async () => {
render(<AzureDevOpsConnectionSection workspaceId="workspace-a" />);
render(
<SettingsSaveProvider>
<AzureDevOpsConnectionSection workspaceId="workspace-a" />
</SettingsSaveProvider>,
);
const patHelpButton = await screen.findByRole("button", {
name: "How to create a personal access token",
@@ -117,7 +121,11 @@ describe("AzureDevOpsConnectionSection", () => {
});
it("does not create a token link from a non-Azure organization URL", async () => {
render(<AzureDevOpsConnectionSection workspaceId="workspace-a" />);
render(
<SettingsSaveProvider>
<AzureDevOpsConnectionSection workspaceId="workspace-a" />
</SettingsSaveProvider>,
);
const organization = await screen.findByTestId("azure-devops-organization");
await waitFor(() =>
@@ -134,7 +142,11 @@ describe("AzureDevOpsConnectionSection", () => {
});
it("removes trailing slashes before saving an organization URL", async () => {
render(<AzureDevOpsConnectionSection workspaceId="workspace-a" />);
render(
<SettingsSaveProvider>
<AzureDevOpsConnectionSection workspaceId="workspace-a" />
</SettingsSaveProvider>,
);
const organization = await screen.findByTestId("azure-devops-organization");
await waitFor(() =>
expect((organization as HTMLInputElement).value).toBe(OLD_ORGANIZATION_URL),
@@ -154,7 +166,11 @@ describe("AzureDevOpsConnectionSection", () => {
});
it("omits a project selected for the previous organization", async () => {
render(<AzureDevOpsConnectionSection workspaceId="workspace-a" />);
render(
<SettingsSaveProvider>
<AzureDevOpsConnectionSection workspaceId="workspace-a" />
</SettingsSaveProvider>,
);
const organization = await screen.findByTestId("azure-devops-organization");
await waitFor(() =>
expect((organization as HTMLInputElement).value).toBe(OLD_ORGANIZATION_URL),
@@ -24,6 +24,7 @@ import {
} from "@/components/integrations/auth-status-banner";
import { WorkspaceScopedSection } from "@/components/integrations/workspace-scoped-section";
import { AzureDevOpsDefaultQueriesSection } from "@/components/azure-devops/azure-devops-default-queries";
import { AzureDevOpsEnabledControl } from "@/components/azure-devops/azure-devops-enabled-control";
import { AzureDevOpsQuickActionsSection } from "@/components/azure-devops/azure-devops-quick-actions";
import { AzureDevOpsWatchSettings } from "@/components/azure-devops/azure-devops-watch-settings";
import { SettingsSection } from "@/components/settings/settings-section";
@@ -473,6 +474,7 @@ function ConnectionActions({ state, disabled }: { state: SettingsState; disabled
);
}
/** Azure DevOps connection card: title/enable toggle, credential form, and test-connection actions. */
export function AzureDevOpsConnectionSection({ workspaceId }: { workspaceId: string }) {
const { t } = useTranslation();
const state = useAzureDevOpsSettings(workspaceId);
@@ -491,6 +493,7 @@ export function AzureDevOpsConnectionSection({ workspaceId }: { workspaceId: str
icon={<IconBrandAzure className="h-5 w-5" />}
title={t("azuredevops:integrationTitle")}
description={t("azuredevops:integrationDescription")}
action={<AzureDevOpsEnabledControl />}
>
<Card>
<CardContent className="space-y-4 pt-6">
@@ -510,6 +513,7 @@ export function AzureDevOpsConnectionSection({ workspaceId }: { workspaceId: str
);
}
/** Azure DevOps's own settings page: connection, watch settings, quick actions, and default queries. */
export function AzureDevOpsIntegrationPage({ workspaceId }: { workspaceId?: string } = {}) {
return (
<WorkspaceScopedSection workspaceId={workspaceId}>
@@ -0,0 +1,17 @@
"use client";
import { DraftedIntegrationEnabledControl } from "@/components/integrations/drafted-integration-enabled-control";
import { useGitHubEnabled } from "@/hooks/domains/github/use-github-enabled";
/** Enable/disable slider for the GitHub integration, wired to `useGitHubEnabled`. */
export function GitHubEnabledControl() {
const { enabled, setEnabled } = useGitHubEnabled();
return (
<DraftedIntegrationEnabledControl
id="github"
name="GitHub"
enabled={enabled}
persist={setEnabled}
/>
);
}
+19 -18
View File
@@ -4,10 +4,10 @@ import { useState, useCallback } from "react";
import { IconBrandGithub, IconPlus, IconTrashX } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Card, CardContent } from "@kandev/ui/card";
import { Separator } from "@kandev/ui/separator";
import { TooltipProvider } from "@kandev/ui/tooltip";
import { useToast } from "@/components/toast-provider";
import { SettingsSection } from "@/components/settings/settings-section";
import { GitHubEnabledControl } from "@/components/github/github-enabled-control";
import { GitHubAutomationSettings, GitHubPersonalSettings } from "./github-status";
import { GitHubCallbackNotice } from "./github-callback-notice";
import { ReviewWatchTable } from "./review-watch-table";
@@ -242,30 +242,30 @@ function useIssueWatchActions(workspaceId?: string | null) {
};
}
/** Top-of-page GitHub settings: title + enable toggle, callback notice, and workspace credentials. */
export function GitHubConnectionSection({ workspaceId }: { workspaceId: string }) {
const { t } = useTranslation();
return (
<>
<div>
<h2
className="text-2xl font-bold flex items-center gap-2"
data-testid="github-integration-heading"
>
<IconBrandGithub className="h-6 w-6" />
{t("github:githubIntegration")}
</h2>
<p className="text-sm text-muted-foreground mt-1">
{t("github:chooseTheAutomationAndPersonalIdentities")}
</p>
</div>
<Separator />
<GitHubCallbackNotice workspaceId={workspaceId} />
<SettingsSection
discoveryTargetId={INTEGRATION_SETTINGS_TARGETS.github}
title={t("github:workspaceGithubAccess")}
description={t("github:credentialUsedForRepositorySyncWatches")}
icon={<IconBrandGithub className="h-5 w-5" />}
title={t("github:githubIntegration")}
titleTestId="github-integration-heading"
description={t("github:chooseTheAutomationAndPersonalIdentities")}
action={<GitHubEnabledControl />}
>
<GitHubAutomationSettings workspaceId={workspaceId} />
<GitHubCallbackNotice workspaceId={workspaceId} />
<SettingsSection
title={t("github:workspaceGithubAccess")}
description={t("github:credentialUsedForRepositorySyncWatches")}
>
<Card data-testid="github-workspace-access-card">
<CardContent className="pt-6">
<GitHubAutomationSettings workspaceId={workspaceId} />
</CardContent>
</Card>
</SettingsSection>
</SettingsSection>
<div className="pr-16 sm:pr-0">
<GitHubPersonalSettings workspaceId={workspaceId} />
@@ -298,6 +298,7 @@ type GitHubIntegrationPageProps = {
workspaceId?: string;
};
/** GitHub's own settings page: resolves the active workspace, then renders its connection, review/issue watch, repo-scope, and automation sections. */
export function GitHubIntegrationPage({ workspaceId }: GitHubIntegrationPageProps = {}) {
return (
<TooltipProvider>
@@ -0,0 +1,17 @@
"use client";
import { DraftedIntegrationEnabledControl } from "@/components/integrations/drafted-integration-enabled-control";
import { useGitLabEnabled } from "@/hooks/domains/gitlab/use-gitlab-enabled";
/** Enable/disable slider for the GitLab integration, wired to `useGitLabEnabled`. */
export function GitLabEnabledControl() {
const { enabled, setEnabled } = useGitLabEnabled();
return (
<DraftedIntegrationEnabledControl
id="gitlab"
name="GitLab"
enabled={enabled}
persist={setEnabled}
/>
);
}
+15 -9
View File
@@ -22,6 +22,7 @@ import { Separator } from "@kandev/ui/separator";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
import { Spinner } from "@kandev/ui/spinner";
import { WorkspaceScopedSection } from "@/components/integrations/workspace-scoped-section";
import { GitLabEnabledControl } from "@/components/gitlab/gitlab-enabled-control";
import { useToast } from "@/components/toast-provider";
import { SettingsSection } from "@/components/settings/settings-section";
import { SettingsCard } from "@/components/settings/settings-card";
@@ -260,6 +261,7 @@ function useGitLabCredentialDraft({
return { method, token, setToken, selectMethod, isDirty };
}
/** Credential form (PAT or OAuth) for connecting a workspace's GitLab account. */
export function GitLabCredentialsForm(props: GitLabCredentialsFormProps) {
const { t } = useTranslation();
const draft = useGitLabCredentialDraft(props);
@@ -358,6 +360,7 @@ type GitLabIntegrationPageProps = {
workspaceId?: string;
};
/** GitLab's own settings page: connection, action presets, and watch settings. */
export function GitLabIntegrationPage({ workspaceId }: GitLabIntegrationPageProps = {}) {
return (
<WorkspaceScopedSection workspaceId={workspaceId}>
@@ -435,15 +438,18 @@ function GitLabConnectionCard(props: ConnectionCardProps) {
description={t("gitlab:connectAGitlabAccountSoKandev")}
icon={<IconBrandGitlab className="h-4 w-4" />}
action={
<Button
variant="outline"
size="sm"
onClick={() => void reload()}
disabled={loading}
className="gap-1 cursor-pointer"
>
<IconRefresh className="h-3 w-3" /> {t("gitlab:refresh")}
</Button>
<div className="flex items-center gap-2">
<GitLabEnabledControl />
<Button
variant="outline"
size="sm"
onClick={() => void reload()}
disabled={loading}
className="gap-1 cursor-pointer"
>
<IconRefresh className="h-3 w-3" /> {t("gitlab:refresh")}
</Button>
</div>
}
>
<SettingsCard isDirty={authMethodDirty}>
@@ -0,0 +1,62 @@
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { SettingsSaveProvider } from "@/components/settings/settings-save-provider";
import { DraftedIntegrationEnabledControl } from "./drafted-integration-enabled-control";
afterEach(cleanup);
describe("DraftedIntegrationEnabledControl", () => {
it("exposes the current enabled state and an accessible integration name", () => {
const persist = vi.fn();
render(
<SettingsSaveProvider>
<DraftedIntegrationEnabledControl id="github" name="GitHub" enabled persist={persist} />
</SettingsSaveProvider>,
);
const toggle = screen.getByRole("switch", { name: "Enable GitHub" });
expect(toggle.getAttribute("id")).toBe("github-enabled");
expect(toggle.getAttribute("aria-checked")).toBe("true");
expect(toggle.getAttribute("data-settings-dirty")).toBe("false");
});
it("starts unchecked when the integration is disabled", () => {
render(
<SettingsSaveProvider>
<DraftedIntegrationEnabledControl
id="github"
name="GitHub"
enabled={false}
persist={vi.fn()}
/>
</SettingsSaveProvider>,
);
expect(screen.getByRole("switch", { name: "Enable GitHub" }).getAttribute("aria-checked")).toBe(
"false",
);
});
it("keeps changes local until Save changes persists the draft", async () => {
const persist = vi.fn().mockResolvedValue(undefined);
render(
<SettingsSaveProvider>
<DraftedIntegrationEnabledControl id="github" name="GitHub" enabled persist={persist} />
</SettingsSaveProvider>,
);
const toggle = screen.getByRole("switch", { name: "Enable GitHub" });
fireEvent.click(toggle);
expect(toggle.getAttribute("aria-checked")).toBe("false");
expect(toggle.getAttribute("data-settings-dirty")).toBe("true");
expect(persist).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "Save changes" }));
await waitFor(() => expect(persist).toHaveBeenCalledWith(false));
await waitFor(() => expect(toggle.getAttribute("data-settings-dirty")).toBe("false"));
});
});
@@ -1,6 +1,5 @@
"use client";
import { Label } from "@kandev/ui/label";
import { Switch } from "@kandev/ui/switch";
import { useDraftedIntegrationEnabled } from "./use-drafted-integration-enabled";
import { useTranslation } from "react-i18next";
@@ -9,27 +8,28 @@ type Props = {
id: string;
enabled: boolean;
persist: (enabled: boolean) => Promise<void> | void;
/** Human-readable integration name (e.g. "GitHub") for the switch's accessible name. */
name: string;
};
export function DraftedIntegrationEnabledControl({ id, enabled, persist }: Props) {
/**
* Bare enable/disable slider for an integration — just the switch, no
* visible "Enabled"/"Disabled" text — wired to the shared drafted-settings
* (dirty/save) plumbing. Since there is no visible label to associate via
* `htmlFor`, the accessible name comes from `aria-label`, built from `name`.
*/
export function DraftedIntegrationEnabledControl({ id, enabled, persist, name }: Props) {
const { t } = useTranslation();
const draft = useDraftedIntegrationEnabled({ id: `${id}-enabled`, enabled, persist });
return (
<div
className="flex items-center gap-2 rounded-full border bg-muted/30 px-3 py-1"
<Switch
id={`${id}-enabled`}
checked={draft.enabled}
data-settings-dirty={draft.isDirty}
data-settings-dirty-level="container"
>
<Switch
id={`${id}-enabled`}
checked={draft.enabled}
data-settings-dirty={draft.isDirty}
onCheckedChange={draft.setEnabled}
className="cursor-pointer"
/>
<Label htmlFor={`${id}-enabled`} className="text-xs cursor-pointer">
{draft.enabled ? t("integrations:enabled") : t("integrations:disabled")}
</Label>
</div>
onCheckedChange={draft.setEnabled}
aria-label={t("integrations:enableIntegrationNamed", { name })}
className="cursor-pointer"
/>
);
}
@@ -0,0 +1,167 @@
"use client";
import type { ComponentType } from "react";
import Link from "@/components/routing/app-link";
import {
IconBrandGithub,
IconBrandGitlab,
IconBrandAzure,
IconBrandSentry,
IconHexagon,
IconTicket,
} from "@tabler/icons-react";
import { Card, CardContent } from "@kandev/ui/card";
import { Label } from "@kandev/ui/label";
import { Switch } from "@kandev/ui/switch";
import { useTranslation } from "react-i18next";
import { useDraftedIntegrationEnabled } from "@/components/integrations/use-drafted-integration-enabled";
import { useHideDisabledIntegrationsInNav } from "@/hooks/domains/integrations/use-hide-disabled-integrations-in-nav";
import { AzureDevOpsEnabledControl } from "@/components/azure-devops/azure-devops-enabled-control";
import { GitHubEnabledControl } from "@/components/github/github-enabled-control";
import { GitLabEnabledControl } from "@/components/gitlab/gitlab-enabled-control";
import { JiraEnabledControl } from "@/components/jira/jira-enabled-control";
import { LinearEnabledControl } from "@/components/linear/linear-enabled-control";
import { SentryEnabledControl } from "@/components/sentry/sentry-enabled-control";
type IntegrationSlug = "azure-devops" | "github" | "gitlab" | "jira" | "linear" | "sentry";
const INTEGRATIONS: Array<{
slug: IntegrationSlug;
label: string;
descriptionKey: string;
Icon: ComponentType<{ className?: string }>;
}> = [
{
slug: "azure-devops",
label: "Azure DevOps",
descriptionKey: "settings:integrationDescriptionAzureDevops",
Icon: IconBrandAzure,
},
{
slug: "github",
label: "GitHub",
descriptionKey: "settings:integrationDescriptionGithub",
Icon: IconBrandGithub,
},
{
slug: "gitlab",
label: "GitLab",
descriptionKey: "settings:integrationDescriptionGitlab",
Icon: IconBrandGitlab,
},
{
slug: "jira",
label: "Jira",
descriptionKey: "settings:integrationDescriptionJira",
Icon: IconTicket,
},
{
slug: "linear",
label: "Linear",
descriptionKey: "settings:integrationDescriptionLinear",
Icon: IconHexagon,
},
{
slug: "sentry",
label: "Sentry",
descriptionKey: "settings:integrationDescriptionSentry",
Icon: IconBrandSentry,
},
];
// Each row's slider is a per-integration hook wrapper (rules of hooks forbid
// picking a hook dynamically by slug), so the map below selects the right
// *component* — every component calls exactly one hook unconditionally.
const ENABLED_CONTROL_BY_SLUG: Record<IntegrationSlug, ComponentType> = {
"azure-devops": AzureDevOpsEnabledControl,
github: GitHubEnabledControl,
gitlab: GitLabEnabledControl,
jira: JiraEnabledControl,
linear: LinearEnabledControl,
sentry: SentryEnabledControl,
};
type IntegrationsIndexPageProps = {
workspaceId?: string;
};
/** Row for the "Hide disabled integrations from left panel navigation" setting, drafted/saved like the per-integration toggles. */
function HideDisabledIntegrationsSetting() {
const { t } = useTranslation();
const { hideDisabled, setHideDisabled } = useHideDisabledIntegrationsInNav();
const draft = useDraftedIntegrationEnabled({
id: "integrations-hide-disabled-in-nav",
enabled: hideDisabled,
persist: setHideDisabled,
});
return (
<div className="flex min-h-11 items-center justify-between gap-4 rounded-lg border p-4">
<div className="min-w-0 space-y-0.5">
<Label htmlFor="hide-disabled-integrations-in-nav">
{t("settings:hideDisabledIntegrationsFromNav")}
</Label>
<p className="text-xs text-muted-foreground">
{t("settings:hideDisabledIntegrationsFromNavDescription")}
</p>
</div>
<Switch
id="hide-disabled-integrations-in-nav"
checked={draft.enabled}
data-settings-dirty={draft.isDirty}
onCheckedChange={draft.setEnabled}
className="shrink-0 cursor-pointer"
/>
</div>
);
}
/** `/settings/integrations` and its workspace-scoped equivalent. */
export function IntegrationsIndexPage({ workspaceId }: IntegrationsIndexPageProps = {}) {
const { t } = useTranslation();
const rootHref = workspaceId
? `/settings/workspace/${encodeURIComponent(workspaceId)}/integrations`
: "/settings/integrations";
return (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold">{t("common:integrations")}</h2>
<p className="text-sm text-muted-foreground mt-1">
{t("settings:connectKandevToThirdPartyServices")}
</p>
</div>
<div className="grid auto-rows-fr gap-3 sm:grid-cols-2 lg:grid-cols-3">
{INTEGRATIONS.map(({ slug, label, descriptionKey, Icon }) => {
const href = `${rootHref}/${slug}`;
const EnabledControl = ENABLED_CONTROL_BY_SLUG[slug];
return (
<Card
key={slug}
data-testid={`integration-card-${slug}`}
className="h-full w-full transition-colors hover:border-primary/40"
>
<CardContent className="space-y-2">
<div className="flex items-center justify-between gap-2">
<Link
href={href}
className="flex min-w-0 items-center gap-2 text-base font-semibold hover:underline cursor-pointer"
>
<Icon className="h-5 w-5 shrink-0" />
<span className="truncate">{label}</span>
</Link>
<div className="shrink-0">
<EnabledControl />
</div>
</div>
<Link href={href} className="text-sm text-muted-foreground cursor-pointer">
{t(descriptionKey)}
</Link>
</CardContent>
</Card>
);
})}
</div>
<HideDisabledIntegrationsSetting />
</div>
);
}
@@ -13,8 +13,8 @@ import { TooltipProvider } from "@kandev/ui/tooltip";
const useGitHubStatusMock = vi.hoisted(() => vi.fn());
const useGitLabAvailableMock = vi.hoisted(() => vi.fn());
const useJiraAvailableMock = vi.hoisted(() => vi.fn());
const useLinearAvailableMock = vi.hoisted(() => vi.fn());
const useJiraAuthedMock = vi.hoisted(() => vi.fn());
const useLinearAuthedMock = vi.hoisted(() => vi.fn());
const activeWorkspaceRef = vi.hoisted(() => ({
id: null as string | null,
items: [] as Array<{ id: string }>,
@@ -29,11 +29,39 @@ vi.mock("@/hooks/domains/gitlab/use-task-mr", () => ({
}));
vi.mock("@/hooks/domains/jira/use-jira-availability", () => ({
useJiraAvailable: useJiraAvailableMock,
useJiraAuthed: useJiraAuthedMock,
}));
vi.mock("@/hooks/domains/linear/use-linear-availability", () => ({
useLinearAvailable: useLinearAvailableMock,
useLinearAuthed: useLinearAuthedMock,
}));
// useNavAvailability now also folds in each integration's install-wide
// enabled toggle plus the "hide disabled" nav setting. This suite never
// exercises that decoupling (see hooks/use-nav-availability.test.ts for
// that) — every enabled hook here defaults to `true` and the hide-disabled
// setting to `false`, reproducing the pre-existing "enabled && authed"
// visibility these tests already assert on.
function enabledStub() {
return { enabled: true, setEnabled: () => {}, loaded: true };
}
vi.mock("@/hooks/domains/azure-devops/use-azure-devops-enabled", () => ({
useAzureDevOpsEnabled: enabledStub,
}));
vi.mock("@/hooks/domains/github/use-github-enabled", () => ({
useGitHubEnabled: enabledStub,
}));
vi.mock("@/hooks/domains/gitlab/use-gitlab-enabled", () => ({
useGitLabEnabled: enabledStub,
}));
vi.mock("@/hooks/domains/jira/use-jira-enabled", () => ({
useJiraEnabled: enabledStub,
}));
vi.mock("@/hooks/domains/linear/use-linear-enabled", () => ({
useLinearEnabled: enabledStub,
}));
vi.mock("@/hooks/domains/integrations/use-hide-disabled-integrations-in-nav", () => ({
useHideDisabledIntegrationsInNav: () => ({ hideDisabled: false, setHideDisabled: () => {} }),
}));
vi.mock("@/components/state-provider", () => ({
@@ -65,21 +93,21 @@ function status(overrides: Partial<GitHubStatus>): GitHubStatus {
function mockAvailability({
githubReady,
gitlabReady = false,
jiraAvailable,
linearAvailable,
jiraConfigured,
linearConfigured,
}: {
githubReady: boolean;
gitlabReady?: boolean;
jiraAvailable: boolean;
linearAvailable: boolean;
jiraConfigured: boolean;
linearConfigured: boolean;
}) {
useGitHubStatusMock.mockReturnValue({
status: githubReady ? status({ token_configured: true }) : status({}),
loading: false,
});
useGitLabAvailableMock.mockReturnValue(gitlabReady);
useJiraAvailableMock.mockReturnValue(jiraAvailable);
useLinearAvailableMock.mockReturnValue(linearAvailable);
useJiraAuthedMock.mockReturnValue(jiraConfigured);
useLinearAuthedMock.mockReturnValue(linearConfigured);
}
afterEach(() => {
@@ -96,7 +124,7 @@ afterEach(() => {
describe("IntegrationsMenu", () => {
it("opens configured integration links on hover", async () => {
mockAvailability({ githubReady: true, jiraAvailable: true, linearAvailable: false });
mockAvailability({ githubReady: true, jiraConfigured: true, linearConfigured: false });
render(createElement(IntegrationsMenu, {}));
@@ -111,7 +139,7 @@ describe("IntegrationsMenu", () => {
});
it("does not render when no integrations are configured", () => {
mockAvailability({ githubReady: false, jiraAvailable: false, linearAvailable: false });
mockAvailability({ githubReady: false, jiraConfigured: false, linearConfigured: false });
render(createElement(IntegrationsMenu, {}));
@@ -121,14 +149,14 @@ describe("IntegrationsMenu", () => {
it("passes the active workspace id to the per-workspace availability hooks", () => {
activeWorkspaceRef.id = "ws-active";
activeWorkspaceRef.items = [{ id: "ws-active" }];
mockAvailability({ githubReady: true, jiraAvailable: true, linearAvailable: true });
mockAvailability({ githubReady: true, jiraConfigured: true, linearConfigured: true });
render(createElement(IntegrationsMenu, {}));
// Jira and Linear are per-workspace: they must be scoped to the active
// workspace so the sidebar reflects the workspace the user is viewing.
expect(useJiraAvailableMock).toHaveBeenCalledWith("ws-active");
expect(useLinearAvailableMock).toHaveBeenCalledWith("ws-active");
expect(useJiraAuthedMock).toHaveBeenCalledWith("ws-active");
expect(useLinearAuthedMock).toHaveBeenCalledWith("ws-active");
});
it("falls back to null scope when the active workspace id is stale", () => {
@@ -137,12 +165,12 @@ describe("IntegrationsMenu", () => {
// backend's default-workspace resolution applies.
activeWorkspaceRef.id = "ws-deleted";
activeWorkspaceRef.items = [{ id: "ws-remaining" }];
mockAvailability({ githubReady: false, jiraAvailable: true, linearAvailable: true });
mockAvailability({ githubReady: false, jiraConfigured: true, linearConfigured: true });
render(createElement(IntegrationsMenu, {}));
expect(useJiraAvailableMock).toHaveBeenCalledWith(null);
expect(useLinearAvailableMock).toHaveBeenCalledWith(null);
expect(useJiraAuthedMock).toHaveBeenCalledWith(null);
expect(useLinearAuthedMock).toHaveBeenCalledWith(null);
});
});
@@ -153,7 +181,7 @@ function renderWithTooltip(component: Parameters<typeof render>[0]) {
describe("IntegrationsTopbarLinks", () => {
it("renders an icon link for each configured integration", () => {
mockAvailability({ githubReady: true, jiraAvailable: false, linearAvailable: true });
mockAvailability({ githubReady: true, jiraConfigured: false, linearConfigured: true });
renderWithTooltip(createElement(IntegrationsTopbarLinks, {}));
@@ -165,7 +193,7 @@ describe("IntegrationsTopbarLinks", () => {
});
it("renders nothing when no integrations are configured", () => {
mockAvailability({ githubReady: false, jiraAvailable: false, linearAvailable: false });
mockAvailability({ githubReady: false, jiraConfigured: false, linearConfigured: false });
const { container } = renderWithTooltip(createElement(IntegrationsTopbarLinks, {}));
expect(container.firstChild).toBeNull();
@@ -208,7 +236,7 @@ describe("MobileIntegrationsSection", () => {
}
it("renders a touch row for each configured first-party link and closes the sheet on click", () => {
mockAvailability({ githubReady: true, jiraAvailable: false, linearAvailable: true });
mockAvailability({ githubReady: true, jiraConfigured: false, linearConfigured: true });
const { onNavigate } = renderMobileSection();
@@ -222,7 +250,7 @@ describe("MobileIntegrationsSection", () => {
});
it("renders plugin nav items targeting the integrations section, gated on the plugins flag", () => {
mockAvailability({ githubReady: false, jiraAvailable: false, linearAvailable: false });
mockAvailability({ githubReady: false, jiraConfigured: false, linearConfigured: false });
registerHelloIntegrationItem();
registerNavItem("plugin-b", {
id: "main-item",
@@ -241,7 +269,7 @@ describe("MobileIntegrationsSection", () => {
});
it("renders when only plugin items exist and no first-party links are configured", () => {
mockAvailability({ githubReady: false, jiraAvailable: false, linearAvailable: false });
mockAvailability({ githubReady: false, jiraConfigured: false, linearConfigured: false });
registerHelloIntegrationItem();
renderMobileSection();
@@ -251,7 +279,7 @@ describe("MobileIntegrationsSection", () => {
});
it("renders nothing when there are no links and no plugin items", () => {
mockAvailability({ githubReady: false, jiraAvailable: false, linearAvailable: false });
mockAvailability({ githubReady: false, jiraConfigured: false, linearConfigured: false });
const { container } = renderMobileSection();
@@ -3,7 +3,15 @@
import { DraftedIntegrationEnabledControl } from "@/components/integrations/drafted-integration-enabled-control";
import { useJiraEnabled } from "@/hooks/domains/jira/use-jira-enabled";
/** Enable/disable slider for the Jira integration, wired to `useJiraEnabled`. */
export function JiraEnabledControl() {
const { enabled, setEnabled } = useJiraEnabled();
return <DraftedIntegrationEnabledControl id="jira" enabled={enabled} persist={setEnabled} />;
return (
<DraftedIntegrationEnabledControl
id="jira"
name="Jira"
enabled={enabled}
persist={setEnabled}
/>
);
}
@@ -0,0 +1,17 @@
"use client";
import { DraftedIntegrationEnabledControl } from "@/components/integrations/drafted-integration-enabled-control";
import { useLinearEnabled } from "@/hooks/domains/linear/use-linear-enabled";
/** Enable/disable slider for the Linear integration, wired to `useLinearEnabled`. */
export function LinearEnabledControl() {
const { enabled, setEnabled } = useLinearEnabled();
return (
<DraftedIntegrationEnabledControl
id="linear"
name="Linear"
enabled={enabled}
persist={setEnabled}
/>
);
}
@@ -13,13 +13,12 @@ import { useToast } from "@/components/toast-provider";
import { SettingsSection } from "@/components/settings/settings-section";
import { useSettingsSaveContributor } from "@/components/settings/settings-save-provider";
import { SettingsCard } from "@/components/settings/settings-card";
import { useLinearEnabled } from "@/hooks/domains/linear/use-linear-enabled";
import { LinearEnabledControl } from "@/components/linear/linear-enabled-control";
import {
IntegrationAuthStatusBanner,
type IntegrationAuthHealth,
} from "@/components/integrations/auth-status-banner";
import { WorkspaceScopedSection } from "@/components/integrations/workspace-scoped-section";
import { DraftedIntegrationEnabledControl } from "@/components/integrations/drafted-integration-enabled-control";
import { INTEGRATION_STATUS_REFRESH_MS } from "@/hooks/domains/integrations/use-integration-availability";
import {
getLinearConfig,
@@ -413,11 +412,7 @@ function useLinearSettings(workspaceId: string) {
};
}
function EnabledPill() {
const { enabled, setEnabled } = useLinearEnabled();
return <DraftedIntegrationEnabledControl id="linear" enabled={enabled} persist={setEnabled} />;
}
/** Linear connection card: title/enable toggle, API key form, and save/test actions. */
export function LinearConnectionSection({ workspaceId }: { workspaceId: string }) {
const { t } = useTranslation();
const s = useLinearSettings(workspaceId);
@@ -444,7 +439,7 @@ export function LinearConnectionSection({ workspaceId }: { workspaceId: string }
icon={<IconHexagon className="h-5 w-5" />}
title={t("linear:linearIntegration")}
description={t("linear:linearIntegrationDescription")}
action={<EnabledPill />}
action={<LinearEnabledControl />}
>
<SettingsCard isDirty={dirty}>
<CardContent className="space-y-4 pt-6">
@@ -485,6 +480,7 @@ type LinearIntegrationPageProps = {
workspaceId?: string;
};
/** Linear's own settings page: connection card plus issue-watchers section. */
export function LinearIntegrationPage({ workspaceId }: LinearIntegrationPageProps = {}) {
return (
<div className="space-y-8">
@@ -0,0 +1,17 @@
"use client";
import { DraftedIntegrationEnabledControl } from "@/components/integrations/drafted-integration-enabled-control";
import { useSentryEnabled } from "@/hooks/domains/sentry/use-sentry-enabled";
/** Enable/disable slider for the Sentry integration, wired to `useSentryEnabled`. */
export function SentryEnabledControl() {
const { enabled, setEnabled } = useSentryEnabled();
return (
<DraftedIntegrationEnabledControl
id="sentry"
name="Sentry"
enabled={enabled}
persist={setEnabled}
/>
);
}
@@ -7,9 +7,8 @@ import { CardContent } from "@kandev/ui/card";
import { useToast } from "@/components/toast-provider";
import { SettingsSection } from "@/components/settings/settings-section";
import { SettingsCard } from "@/components/settings/settings-card";
import { useSentryEnabled } from "@/hooks/domains/sentry/use-sentry-enabled";
import { SentryEnabledControl } from "@/components/sentry/sentry-enabled-control";
import { WorkspaceScopedSection } from "@/components/integrations/workspace-scoped-section";
import { DraftedIntegrationEnabledControl } from "@/components/integrations/drafted-integration-enabled-control";
import { INTEGRATION_STATUS_REFRESH_MS } from "@/hooks/domains/integrations/use-integration-availability";
import {
deleteSentryInstance,
@@ -126,11 +125,6 @@ function InstanceList({
);
}
function EnabledPill() {
const { enabled, setEnabled } = useSentryEnabled();
return <DraftedIntegrationEnabledControl id="sentry" enabled={enabled} persist={setEnabled} />;
}
// useDeleteInstance confirms, deletes, and reports the outcome. Split out of
// SentryConnectionSection so that component stays under the file's
// max-lines-per-function limit.
@@ -166,6 +160,7 @@ function useDeleteInstance(workspaceId: string, reload: () => Promise<void>) {
);
}
/** Sentry connection card: title/enable toggle plus the list of configured Sentry instances. */
export function SentryConnectionSection({ workspaceId }: { workspaceId: string }) {
const { t } = useTranslation();
const { instances, loading, reload } = useInstanceList(workspaceId);
@@ -194,7 +189,7 @@ export function SentryConnectionSection({ workspaceId }: { workspaceId: string }
icon={<IconBrandSentry className="h-5 w-5" />}
title={t("sentry:sentryIntegration")}
description={t("sentry:sentryIntegrationDescription")}
action={<EnabledPill />}
action={<SentryEnabledControl />}
>
<SettingsCard isDirty={formDirty}>
<CardContent className="space-y-3 pt-6">
@@ -255,6 +250,7 @@ type SentryIntegrationPageProps = {
workspaceId?: string;
};
/** Sentry's own settings page: connection card plus issue-watchers section. */
export function SentryIntegrationPage({ workspaceId }: SentryIntegrationPageProps = {}) {
return (
<div className="space-y-8">
@@ -6,6 +6,7 @@ import { useSettingsTargetRegistration } from "./settings-target-provider";
type SettingsSectionProps = {
icon?: ReactNode;
title: string;
titleTestId?: string;
titleAccessory?: ReactNode;
description?: string;
action?: ReactNode;
@@ -16,6 +17,7 @@ type SettingsSectionProps = {
export function SettingsSection({
icon,
title,
titleTestId,
titleAccessory,
description,
action,
@@ -31,7 +33,7 @@ export function SettingsSection({
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<div className="flex items-center gap-2">
<h3 className="text-lg font-semibold flex items-center gap-2">
<h3 className="text-lg font-semibold flex items-center gap-2" data-testid={titleTestId}>
{icon}
{title}
</h3>
@@ -0,0 +1,70 @@
import { test, expect } from "../../fixtures/test-base";
// Covers docs/specs/integrations/enable-disable-toggle.md's nav-visibility
// scenarios: with "Hide disabled integrations from left panel navigation"
// off (the default), a disabled-but-configured integration still shows in
// the sidebar; turning the setting on hides it; turning it back off (or
// re-enabling the integration) reveals it again — all without a reload.
test.describe("hide disabled integrations from left panel navigation", () => {
test("off by default keeps a disabled integration visible; on hides it; re-enabling reveals it", async ({
testPage,
apiClient,
}) => {
// Make GitHub configured/healthy so it is eligible to appear in the nav
// regardless of its enabled state.
await apiClient.mockGitHubReset();
await apiClient.mockGitHubSetUser("test-user");
await testPage.goto("/settings/integrations");
const githubSwitch = testPage.locator("#github-enabled");
await expect(githubSwitch).toHaveAttribute("aria-checked", "true");
await githubSwitch.click();
await expect(githubSwitch).toHaveAttribute("aria-checked", "false");
const hideDisabledSwitch = testPage.locator("#hide-disabled-integrations-in-nav");
await expect(hideDisabledSwitch).toHaveAttribute("aria-checked", "false");
await testPage
.getByTestId("settings-floating-save")
.getByRole("button", { name: "Save changes" })
.click();
await expect(testPage.getByTestId("settings-floating-save")).not.toBeVisible();
// Leave Settings — the sidebar's Integrations section only renders
// outside the Settings takeover.
await testPage.goto("/tasks");
const githubNavLink = testPage.getByRole("link", { name: "GitHub" });
await expect(githubNavLink).toBeVisible({ timeout: 10_000 });
// Turn "hide disabled" on.
await testPage.goto("/settings/integrations");
await expect(testPage.locator("#github-enabled")).toHaveAttribute("aria-checked", "false");
await testPage.locator("#hide-disabled-integrations-in-nav").click();
await expect(testPage.locator("#hide-disabled-integrations-in-nav")).toHaveAttribute(
"aria-checked",
"true",
);
await testPage
.getByTestId("settings-floating-save")
.getByRole("button", { name: "Save changes" })
.click();
await expect(testPage.getByTestId("settings-floating-save")).not.toBeVisible();
await testPage.goto("/tasks");
await expect(testPage.getByRole("link", { name: "GitHub" })).not.toBeVisible();
// Re-enabling GitHub reveals it again even with "hide disabled" still on.
await testPage.goto("/settings/integrations");
await testPage.locator("#github-enabled").click();
await expect(testPage.locator("#github-enabled")).toHaveAttribute("aria-checked", "true");
await testPage
.getByTestId("settings-floating-save")
.getByRole("button", { name: "Save changes" })
.click();
await expect(testPage.getByTestId("settings-floating-save")).not.toBeVisible();
await testPage.goto("/tasks");
await expect(testPage.getByRole("link", { name: "GitHub" })).toBeVisible({ timeout: 10_000 });
});
});
@@ -0,0 +1,45 @@
import { test, expect } from "../../fixtures/test-base";
// Covers docs/specs/integrations/enable-disable-toggle.md's index-page slider
// scenarios: every integration row has a working slider, toggling it never
// navigates, and the toggle stays in sync with that integration's own
// settings page (the shared install-wide `useXEnabled` state).
test.describe("integrations index page enable/disable sliders", () => {
test("every integration row has a slider, toggling it does not navigate, and it stays in sync with the own-page slider", async ({
testPage,
}) => {
await testPage.goto("/settings/integrations");
// One slider per integration row.
const slugs = ["azure-devops", "github", "gitlab", "jira", "linear", "sentry"];
for (const slug of slugs) {
await expect(testPage.locator(`#${slug}-enabled`)).toBeVisible();
}
const githubSwitch = testPage.locator("#github-enabled");
await expect(githubSwitch).toHaveAttribute("aria-checked", "true");
// Toggling the slider must not navigate away from the index page.
await githubSwitch.click();
await expect(githubSwitch).toHaveAttribute("aria-checked", "false");
await expect(testPage).toHaveURL(/\/settings\/integrations$/);
await testPage
.getByTestId("settings-floating-save")
.getByRole("button", { name: "Save changes" })
.click();
await expect(testPage.getByTestId("settings-floating-save")).not.toBeVisible();
// GitHub's own settings page reflects the same persisted state.
await testPage.goto("/settings/integrations/github");
await expect(testPage.locator("#github-enabled")).toHaveAttribute("aria-checked", "false");
// The GitHub page uses the same compact section header as the other
// integration settings pages, with its toggle in that header.
const githubHeading = testPage.locator("h3[data-testid='github-integration-heading']");
await expect(githubHeading).toBeVisible();
await expect(
testPage.locator("section").filter({ has: githubHeading }).locator("#github-enabled"),
).toBeVisible();
});
});
@@ -4,6 +4,13 @@ import { expect } from "../../fixtures/test-base";
// Cards own the vertical padding (py-4 = 16px); allow border/subpixel slack, but catch extra content top padding.
const MAX_ICON_TOP_INSET_PX = 22;
/**
* Asserts the integrations index page's per-integration cards render with a
* stable layout: every card has equal height (row stretch is not masking a
* content-length difference) and the leading icon sits within its
* original top-inset budget (catches the header row silently growing,
* e.g. from a squeezed/wrapped label).
*/
export async function expectStableIntegrationCardLayout(page: Page) {
const cards = await integrationCards(page);
const heights = await integrationCardHeights(cards);
@@ -13,6 +20,7 @@ export async function expectStableIntegrationCardLayout(page: Page) {
expect(Math.max(...topInsets)).toBeLessThanOrEqual(MAX_ICON_TOP_INSET_PX);
}
/** Measured height (px) of each integration card, in DOM order. */
async function integrationCardHeights(cards: Locator[]) {
const heights = await Promise.all(
cards.map(async (card, index) => {
@@ -25,6 +33,7 @@ async function integrationCardHeights(cards: Locator[]) {
return heights;
}
/** Vertical offset (px) of each card's leading icon from its card's top edge. */
async function integrationCardIconTopInsets(cards: Locator[]) {
const topInsets = await Promise.all(
cards.map(async (card, index) => {
@@ -39,17 +48,14 @@ async function integrationCardIconTopInsets(cards: Locator[]) {
return topInsets;
}
/** Locates every rendered integration card on the settings index page. */
async function integrationCards(page: Page): Promise<Locator[]> {
const links = page.getByTestId("settings-scroll-container").locator('a[href*="/integrations/"]');
await expect(links.first()).toBeVisible();
const count = await links.count();
const cards = page
.getByTestId("settings-scroll-container")
.locator('[data-testid^="integration-card-"]');
await expect(cards.first()).toBeVisible();
const count = await cards.count();
expect(count).toBeGreaterThan(0);
return Promise.all(
Array.from({ length: count }, async (_, index) => {
const card = links.nth(index).locator(':scope > [data-slot="card"]');
await expect(card).toHaveCount(1);
return card;
}),
);
return Array.from({ length: count }, (_, index) => cards.nth(index));
}
@@ -0,0 +1,22 @@
import { test, expect } from "../../fixtures/test-base";
test.describe("Mobile GitHub integration settings", () => {
test("keeps the enable toggle in the compact integration header", async ({ testPage }) => {
await testPage.goto("/settings/integrations/github");
const heading = testPage.locator("h3[data-testid='github-integration-heading']");
await expect(heading).toBeVisible();
await expect(
testPage.locator("section").filter({ has: heading }).locator("#github-enabled"),
).toBeVisible();
const accessCard = testPage.getByTestId("github-workspace-access-card");
await expect(accessCard).toBeVisible();
await expect(accessCard).toHaveAttribute("data-slot", "card");
await expect(testPage.locator("#github-enabled")).toHaveAttribute("aria-checked", "true");
expect(
await testPage.evaluate(
() => document.documentElement.scrollWidth <= document.documentElement.clientWidth,
),
).toBe(true);
});
});
@@ -14,6 +14,11 @@ test.describe("Mobile settings navigation", () => {
const takeover = testPage.getByTestId("app-sidebar-settings-mode");
await expect(takeover).toBeHidden();
await testPage.getByTestId("settings-mobile-menu-button").click();
await expect(
testPage.getByTestId("settings-mobile-menu").locator('a[href="/settings/integrations"]'),
).toBeVisible();
});
test("shows the workspace breadcrumb and orders secrets after automations", async ({
@@ -23,11 +23,10 @@ test.describe("Settings sidebar takeover", () => {
await expect(takeover).toBeVisible();
await expect(takeover.getByText("Active", { exact: true })).toBeVisible();
await expect(takeover.getByRole("link", { name: "Repositories" })).toBeVisible();
await expect(takeover.getByRole("link", { name: "Integrations" })).toHaveAttribute(
"href",
/\/settings\/workspace\/[^/]+\/integrations$/,
);
await expect(takeover.locator('a[href="/settings/integrations"]')).toHaveCount(0);
await expect(
takeover.locator('a[href^="/settings/workspace/"][href$="/integrations"]').first(),
).toHaveAttribute("href", /\/settings\/workspace\/[^/]+\/integrations$/);
await expect(takeover.locator('a[href="/settings/integrations"]')).toBeVisible();
// Enter a section: navigates to a settings sub-page; takeover stays open.
await takeover.locator('a[href="/settings/agents"]').first().click();
@@ -0,0 +1,81 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { act, renderHook, waitFor } from "@testing-library/react";
import { useAzureDevOpsEnabled } from "./use-azure-devops-enabled";
import { makeLocalStorageMock } from "../integrations/local-storage-mock.test-helpers";
const STORAGE_KEY = "kandev:azure-devops:enabled:v1";
const localStorageMock = makeLocalStorageMock();
vi.stubGlobal("localStorage", localStorageMock);
Object.defineProperty(window, "localStorage", {
value: localStorageMock,
configurable: true,
});
describe("useAzureDevOpsEnabled", () => {
beforeEach(() => {
localStorageMock.clear();
});
afterEach(() => {
localStorageMock.clear();
});
it("defaults to enabled=true when no localStorage entry exists", async () => {
const { result } = renderHook(() => useAzureDevOpsEnabled());
await waitFor(() => expect(result.current.loaded).toBe(true));
expect(result.current.enabled).toBe(true);
});
it('reads enabled=false when stored as the literal string "false"', async () => {
window.localStorage.setItem(STORAGE_KEY, "false");
const { result } = renderHook(() => useAzureDevOpsEnabled());
await waitFor(() => expect(result.current.loaded).toBe(true));
expect(result.current.enabled).toBe(false);
});
it("setEnabled persists to localStorage and updates state", async () => {
const { result } = renderHook(() => useAzureDevOpsEnabled());
await waitFor(() => expect(result.current.loaded).toBe(true));
act(() => result.current.setEnabled(false));
expect(result.current.enabled).toBe(false);
expect(window.localStorage.getItem(STORAGE_KEY)).toBe("false");
});
it("propagates updates dispatched via the kandev:azure-devops:enabled-changed event", async () => {
const { result } = renderHook(() => useAzureDevOpsEnabled());
await waitFor(() => expect(result.current.loaded).toBe(true));
expect(result.current.enabled).toBe(true);
act(() => {
window.localStorage.setItem(STORAGE_KEY, "false");
window.dispatchEvent(new Event("kandev:azure-devops:enabled-changed"));
});
await waitFor(() => expect(result.current.enabled).toBe(false));
});
it("propagates updates delivered via the native storage event (cross-tab)", async () => {
const { result } = renderHook(() => useAzureDevOpsEnabled());
await waitFor(() => expect(result.current.loaded).toBe(true));
act(() => {
window.localStorage.setItem(STORAGE_KEY, "false");
window.dispatchEvent(new StorageEvent("storage", { key: STORAGE_KEY, newValue: "false" }));
});
await waitFor(() => expect(result.current.enabled).toBe(false));
});
it("migrates a legacy per-workspace key onto the canonical storage key", async () => {
window.localStorage.setItem("kandev:azure-devops:enabled:ws-123", "false");
const { result } = renderHook(() => useAzureDevOpsEnabled());
await waitFor(() => expect(result.current.loaded).toBe(true));
expect(result.current.enabled).toBe(false);
expect(window.localStorage.getItem(STORAGE_KEY)).toBe("false");
expect(window.localStorage.getItem("kandev:azure-devops:enabled:ws-123")).toBeNull();
});
});
@@ -0,0 +1,18 @@
"use client";
import { useIntegrationEnabled } from "../integrations/use-integration-enabled";
const STORAGE_KEY = "kandev:azure-devops:enabled:v1";
const LEGACY_KEY_PREFIX = "kandev:azure-devops:enabled:";
const SYNC_EVENT = "kandev:azure-devops:enabled-changed";
/**
* Install-wide enable/disable state for the Azure DevOps integration.
*
* Backed by `localStorage` (key `kandev:azure-devops:enabled:v1`), synced
* across browser tabs and across the own-settings-page and index-page
* sliders. Defaults to `true` when no value has ever been persisted.
*/
export function useAzureDevOpsEnabled() {
return useIntegrationEnabled(STORAGE_KEY, LEGACY_KEY_PREFIX, SYNC_EVENT);
}
@@ -0,0 +1,81 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { act, renderHook, waitFor } from "@testing-library/react";
import { useGitHubEnabled } from "./use-github-enabled";
import { makeLocalStorageMock } from "../integrations/local-storage-mock.test-helpers";
const STORAGE_KEY = "kandev:github:enabled:v1";
const localStorageMock = makeLocalStorageMock();
vi.stubGlobal("localStorage", localStorageMock);
Object.defineProperty(window, "localStorage", {
value: localStorageMock,
configurable: true,
});
describe("useGitHubEnabled", () => {
beforeEach(() => {
localStorageMock.clear();
});
afterEach(() => {
localStorageMock.clear();
});
it("defaults to enabled=true when no localStorage entry exists", async () => {
const { result } = renderHook(() => useGitHubEnabled());
await waitFor(() => expect(result.current.loaded).toBe(true));
expect(result.current.enabled).toBe(true);
});
it('reads enabled=false when stored as the literal string "false"', async () => {
window.localStorage.setItem(STORAGE_KEY, "false");
const { result } = renderHook(() => useGitHubEnabled());
await waitFor(() => expect(result.current.loaded).toBe(true));
expect(result.current.enabled).toBe(false);
});
it("setEnabled persists to localStorage and updates state", async () => {
const { result } = renderHook(() => useGitHubEnabled());
await waitFor(() => expect(result.current.loaded).toBe(true));
act(() => result.current.setEnabled(false));
expect(result.current.enabled).toBe(false);
expect(window.localStorage.getItem(STORAGE_KEY)).toBe("false");
});
it("propagates updates dispatched via the kandev:github:enabled-changed event", async () => {
const { result } = renderHook(() => useGitHubEnabled());
await waitFor(() => expect(result.current.loaded).toBe(true));
expect(result.current.enabled).toBe(true);
act(() => {
window.localStorage.setItem(STORAGE_KEY, "false");
window.dispatchEvent(new Event("kandev:github:enabled-changed"));
});
await waitFor(() => expect(result.current.enabled).toBe(false));
});
it("propagates updates delivered via the native storage event (cross-tab)", async () => {
const { result } = renderHook(() => useGitHubEnabled());
await waitFor(() => expect(result.current.loaded).toBe(true));
act(() => {
window.localStorage.setItem(STORAGE_KEY, "false");
window.dispatchEvent(new StorageEvent("storage", { key: STORAGE_KEY, newValue: "false" }));
});
await waitFor(() => expect(result.current.enabled).toBe(false));
});
it("migrates a legacy per-workspace key onto the canonical storage key", async () => {
window.localStorage.setItem("kandev:github:enabled:ws-123", "false");
const { result } = renderHook(() => useGitHubEnabled());
await waitFor(() => expect(result.current.loaded).toBe(true));
expect(result.current.enabled).toBe(false);
expect(window.localStorage.getItem(STORAGE_KEY)).toBe("false");
expect(window.localStorage.getItem("kandev:github:enabled:ws-123")).toBeNull();
});
});
@@ -0,0 +1,18 @@
"use client";
import { useIntegrationEnabled } from "../integrations/use-integration-enabled";
const STORAGE_KEY = "kandev:github:enabled:v1";
const LEGACY_KEY_PREFIX = "kandev:github:enabled:";
const SYNC_EVENT = "kandev:github:enabled-changed";
/**
* Install-wide enable/disable state for the GitHub integration.
*
* Backed by `localStorage` (key `kandev:github:enabled:v1`), synced across
* browser tabs and across the own-settings-page and index-page sliders.
* Defaults to `true` when no value has ever been persisted.
*/
export function useGitHubEnabled() {
return useIntegrationEnabled(STORAGE_KEY, LEGACY_KEY_PREFIX, SYNC_EVENT);
}
@@ -0,0 +1,81 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { act, renderHook, waitFor } from "@testing-library/react";
import { useGitLabEnabled } from "./use-gitlab-enabled";
import { makeLocalStorageMock } from "../integrations/local-storage-mock.test-helpers";
const STORAGE_KEY = "kandev:gitlab:enabled:v1";
const localStorageMock = makeLocalStorageMock();
vi.stubGlobal("localStorage", localStorageMock);
Object.defineProperty(window, "localStorage", {
value: localStorageMock,
configurable: true,
});
describe("useGitLabEnabled", () => {
beforeEach(() => {
localStorageMock.clear();
});
afterEach(() => {
localStorageMock.clear();
});
it("defaults to enabled=true when no localStorage entry exists", async () => {
const { result } = renderHook(() => useGitLabEnabled());
await waitFor(() => expect(result.current.loaded).toBe(true));
expect(result.current.enabled).toBe(true);
});
it('reads enabled=false when stored as the literal string "false"', async () => {
window.localStorage.setItem(STORAGE_KEY, "false");
const { result } = renderHook(() => useGitLabEnabled());
await waitFor(() => expect(result.current.loaded).toBe(true));
expect(result.current.enabled).toBe(false);
});
it("setEnabled persists to localStorage and updates state", async () => {
const { result } = renderHook(() => useGitLabEnabled());
await waitFor(() => expect(result.current.loaded).toBe(true));
act(() => result.current.setEnabled(false));
expect(result.current.enabled).toBe(false);
expect(window.localStorage.getItem(STORAGE_KEY)).toBe("false");
});
it("propagates updates dispatched via the kandev:gitlab:enabled-changed event", async () => {
const { result } = renderHook(() => useGitLabEnabled());
await waitFor(() => expect(result.current.loaded).toBe(true));
expect(result.current.enabled).toBe(true);
act(() => {
window.localStorage.setItem(STORAGE_KEY, "false");
window.dispatchEvent(new Event("kandev:gitlab:enabled-changed"));
});
await waitFor(() => expect(result.current.enabled).toBe(false));
});
it("propagates updates delivered via the native storage event (cross-tab)", async () => {
const { result } = renderHook(() => useGitLabEnabled());
await waitFor(() => expect(result.current.loaded).toBe(true));
act(() => {
window.localStorage.setItem(STORAGE_KEY, "false");
window.dispatchEvent(new StorageEvent("storage", { key: STORAGE_KEY, newValue: "false" }));
});
await waitFor(() => expect(result.current.enabled).toBe(false));
});
it("migrates a legacy per-workspace key onto the canonical storage key", async () => {
window.localStorage.setItem("kandev:gitlab:enabled:ws-123", "false");
const { result } = renderHook(() => useGitLabEnabled());
await waitFor(() => expect(result.current.loaded).toBe(true));
expect(result.current.enabled).toBe(false);
expect(window.localStorage.getItem(STORAGE_KEY)).toBe("false");
expect(window.localStorage.getItem("kandev:gitlab:enabled:ws-123")).toBeNull();
});
});
@@ -0,0 +1,18 @@
"use client";
import { useIntegrationEnabled } from "../integrations/use-integration-enabled";
const STORAGE_KEY = "kandev:gitlab:enabled:v1";
const LEGACY_KEY_PREFIX = "kandev:gitlab:enabled:";
const SYNC_EVENT = "kandev:gitlab:enabled-changed";
/**
* Install-wide enable/disable state for the GitLab integration.
*
* Backed by `localStorage` (key `kandev:gitlab:enabled:v1`), synced across
* browser tabs and across the own-settings-page and index-page sliders.
* Defaults to `true` when no value has ever been persisted.
*/
export function useGitLabEnabled() {
return useIntegrationEnabled(STORAGE_KEY, LEGACY_KEY_PREFIX, SYNC_EVENT);
}
@@ -0,0 +1,26 @@
/**
* Shared in-memory localStorage mock for the per-integration "enabled" hook
* test suites (azure-devops, github, gitlab, hide-disabled-in-nav). Each
* test file calls this factory to build its own mock instance — the store
* is never shared across files, so suites stay isolated from one another
* even though they all use the same shape.
*/
export function makeLocalStorageMock() {
const store = new Map<string, string>();
return {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => {
store.set(key, value);
},
removeItem: (key: string) => {
store.delete(key);
},
clear: () => {
store.clear();
},
get length() {
return store.size;
},
key: (index: number) => Array.from(store.keys())[index] ?? null,
};
}
@@ -0,0 +1,91 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { act, renderHook, waitFor } from "@testing-library/react";
import { useHideDisabledIntegrationsInNav } from "./use-hide-disabled-integrations-in-nav";
import { makeLocalStorageMock } from "./local-storage-mock.test-helpers";
const STORAGE_KEY = "kandev:integrations:hideDisabledInNav:v1";
const localStorageMock = makeLocalStorageMock();
vi.stubGlobal("localStorage", localStorageMock);
Object.defineProperty(window, "localStorage", {
value: localStorageMock,
configurable: true,
});
describe("useHideDisabledIntegrationsInNav", () => {
beforeEach(() => {
localStorageMock.clear();
});
afterEach(() => {
localStorageMock.clear();
});
it("defaults to hideDisabled=false when no localStorage entry exists", () => {
const { result } = renderHook(() => useHideDisabledIntegrationsInNav());
expect(result.current.hideDisabled).toBe(false);
});
it('reads hideDisabled=true only when stored as the literal string "true"', () => {
window.localStorage.setItem(STORAGE_KEY, "true");
const { result } = renderHook(() => useHideDisabledIntegrationsInNav());
expect(result.current.hideDisabled).toBe(true);
});
it.each(["false", "1", "yes", ""])(
'treats persisted value %p as off — only the literal "true" enables it',
(storedValue) => {
window.localStorage.setItem(STORAGE_KEY, storedValue);
const { result } = renderHook(() => useHideDisabledIntegrationsInNav());
expect(result.current.hideDisabled).toBe(false);
},
);
it("setHideDisabled persists to localStorage and updates state", async () => {
const { result } = renderHook(() => useHideDisabledIntegrationsInNav());
expect(result.current.hideDisabled).toBe(false);
act(() => result.current.setHideDisabled(true));
await waitFor(() => expect(result.current.hideDisabled).toBe(true));
expect(window.localStorage.getItem(STORAGE_KEY)).toBe("true");
});
it("propagates updates dispatched via the kandev:integrations:hide-disabled-in-nav-changed event", async () => {
const { result } = renderHook(() => useHideDisabledIntegrationsInNav());
expect(result.current.hideDisabled).toBe(false);
act(() => {
window.localStorage.setItem(STORAGE_KEY, "true");
window.dispatchEvent(new Event("kandev:integrations:hide-disabled-in-nav-changed"));
});
await waitFor(() => expect(result.current.hideDisabled).toBe(true));
});
it("degrades to hideDisabled=false when localStorage.getItem throws", () => {
const original = localStorageMock.getItem;
localStorageMock.getItem = () => {
throw new Error("quota exceeded");
};
try {
const { result } = renderHook(() => useHideDisabledIntegrationsInNav());
expect(result.current.hideDisabled).toBe(false);
} finally {
localStorageMock.getItem = original;
}
});
it("throws instead of reporting a successful save when localStorage.setItem fails", () => {
const original = localStorageMock.setItem;
localStorageMock.setItem = () => {
throw new Error("quota exceeded");
};
try {
const { result } = renderHook(() => useHideDisabledIntegrationsInNav());
expect(() => result.current.setHideDisabled(true)).toThrow();
expect(result.current.hideDisabled).toBe(false);
} finally {
localStorageMock.setItem = original;
}
});
});
@@ -0,0 +1,57 @@
"use client";
import { useCallback, useMemo, useSyncExternalStore } from "react";
const STORAGE_KEY = "kandev:integrations:hideDisabledInNav:v1";
const SYNC_EVENT = "kandev:integrations:hide-disabled-in-nav-changed";
/** Reads the persisted "hide disabled integrations from nav" flag, defaulting to `false`. */
function readHideDisabled(): boolean {
if (typeof window === "undefined") return false;
try {
return window.localStorage.getItem(STORAGE_KEY) === "true";
} catch {
// Quota / private mode — fall through; the setting just defaults to off.
return false;
}
}
/**
* The integrations index page's "Hide disabled integrations from left panel
* navigation" setting: a single, install-wide, `localStorage`-backed boolean
* (not per-integration), defaulting to `false`. Shares the
* `useSyncExternalStore` + `storage` event + custom-event broadcast shape
* with `hooks/domains/integrations/use-integration-enabled.ts`, but has no
* per-integration key and no legacy migration — this setting is new, there
* is nothing to migrate.
*/
export function useHideDisabledIntegrationsInNav() {
const subscribe = useMemo(
() => (notify: () => void) => {
const onCustomEvent = () => notify();
window.addEventListener(SYNC_EVENT, onCustomEvent);
window.addEventListener("storage", onCustomEvent);
return () => {
window.removeEventListener(SYNC_EVENT, onCustomEvent);
window.removeEventListener("storage", onCustomEvent);
};
},
[],
);
const getSnapshot = useCallback(() => readHideDisabled(), []);
const getServerSnapshot = useCallback(() => false, []);
const hideDisabled = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
const setHideDisabled = useCallback((next: boolean) => {
try {
window.localStorage.setItem(STORAGE_KEY, String(next));
} catch (error) {
throw new Error(`Failed to persist ${STORAGE_KEY}`, { cause: error });
}
window.dispatchEvent(new Event(SYNC_EVENT));
}, []);
return { hideDisabled, setHideDisabled };
}
+130 -13
View File
@@ -2,6 +2,7 @@ import { renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { getGitHubIntegrationStatus } from "./use-nav-availability";
import type { GitHubStatus } from "@/lib/types/github";
import type { AvailabilityKey } from "@/lib/navigation/types";
const mocks = vi.hoisted(() => {
const workspaceId = "workspace-1";
@@ -14,9 +15,11 @@ const mocks = vi.hoisted(() => {
},
},
azureDevOpsAvailable: vi.fn(() => false),
githubStatus: vi.fn(() => ({ status: null as GitHubStatus | null, loading: false })),
gitlabAvailable: vi.fn(() => false),
jiraAvailable: vi.fn(() => false),
linearAvailable: vi.fn(() => false),
jiraAuthed: vi.fn(() => false),
linearAuthed: vi.fn(() => false),
hideDisabled: vi.fn(() => false),
};
});
@@ -27,16 +30,49 @@ vi.mock("@/hooks/domains/azure-devops/use-azure-devops-availability", () => ({
useAzureDevOpsAvailable: mocks.azureDevOpsAvailable,
}));
vi.mock("@/hooks/domains/github/use-github-status", () => ({
useGitHubStatus: () => ({ status: null, loading: false }),
useGitHubStatus: mocks.githubStatus,
}));
vi.mock("@/hooks/domains/gitlab/use-task-mr", () => ({
useGitLabAvailable: mocks.gitlabAvailable,
}));
vi.mock("@/hooks/domains/jira/use-jira-availability", () => ({
useJiraAvailable: mocks.jiraAvailable,
useJiraAuthed: mocks.jiraAuthed,
}));
vi.mock("@/hooks/domains/linear/use-linear-availability", () => ({
useLinearAvailable: mocks.linearAvailable,
useLinearAuthed: mocks.linearAuthed,
}));
vi.mock("@/hooks/domains/integrations/use-hide-disabled-integrations-in-nav", () => ({
useHideDisabledIntegrationsInNav: () => ({
hideDisabled: mocks.hideDisabled(),
setHideDisabled: vi.fn(),
}),
}));
// Enabled-hook mocks default to `true` everywhere: most scenarios exercise
// the `configured` axis, and defaulting `enabled` off would make every one of
// them need to opt back in just to stay green.
const enabledHookMocks = vi.hoisted(() => ({
azureDevOps: vi.fn(() => ({ enabled: true, setEnabled: vi.fn(), loaded: true })),
github: vi.fn(() => ({ enabled: true, setEnabled: vi.fn(), loaded: true })),
gitlab: vi.fn(() => ({ enabled: true, setEnabled: vi.fn(), loaded: true })),
jira: vi.fn(() => ({ enabled: true, setEnabled: vi.fn(), loaded: true })),
linear: vi.fn(() => ({ enabled: true, setEnabled: vi.fn(), loaded: true })),
}));
vi.mock("@/hooks/domains/azure-devops/use-azure-devops-enabled", () => ({
useAzureDevOpsEnabled: enabledHookMocks.azureDevOps,
}));
vi.mock("@/hooks/domains/github/use-github-enabled", () => ({
useGitHubEnabled: enabledHookMocks.github,
}));
vi.mock("@/hooks/domains/gitlab/use-gitlab-enabled", () => ({
useGitLabEnabled: enabledHookMocks.gitlab,
}));
vi.mock("@/hooks/domains/jira/use-jira-enabled", () => ({
useJiraEnabled: enabledHookMocks.jira,
}));
vi.mock("@/hooks/domains/linear/use-linear-enabled", () => ({
useLinearEnabled: enabledHookMocks.linear,
}));
import { useNavAvailability } from "./use-nav-availability";
@@ -54,10 +90,7 @@ function status(overrides: Partial<GitHubStatus>): GitHubStatus {
describe("getGitHubIntegrationStatus", () => {
it("shows checking while GitHub status is loading and not configured", () => {
expect(getGitHubIntegrationStatus(status({}), true)).toEqual({
ready: false,
label: "Checking",
});
expect(getGitHubIntegrationStatus(null, true)).toEqual({ ready: false, label: "Checking" });
});
it("treats a configured token as ready even before live auth is green", () => {
@@ -82,18 +115,58 @@ describe("getGitHubIntegrationStatus", () => {
});
});
const NAV_GATED_KEYS: AvailabilityKey[] = ["azure-devops", "github", "gitlab", "jira", "linear"];
const ENABLED_MOCK_BY_KEY = {
"azure-devops": enabledHookMocks.azureDevOps,
github: enabledHookMocks.github,
gitlab: enabledHookMocks.gitlab,
jira: enabledHookMocks.jira,
linear: enabledHookMocks.linear,
};
function setConfigured(key: AvailabilityKey, configured: boolean) {
switch (key) {
case "azure-devops":
mocks.azureDevOpsAvailable.mockReturnValue(configured);
return;
case "github":
mocks.githubStatus.mockReturnValue({
status: configured ? status({ authenticated: true }) : status({}),
loading: false,
});
return;
case "gitlab":
mocks.gitlabAvailable.mockReturnValue(configured);
return;
case "jira":
mocks.jiraAuthed.mockReturnValue(configured);
return;
case "linear":
mocks.linearAuthed.mockReturnValue(configured);
return;
}
}
function setEnabled(key: AvailabilityKey, enabled: boolean) {
ENABLED_MOCK_BY_KEY[key].mockReturnValue({ enabled, setEnabled: vi.fn(), loaded: true });
}
describe("useNavAvailability", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.state.workspaces.activeId = mocks.workspaceId;
mocks.state.workspaces.items = [{ id: mocks.workspaceId }];
mocks.githubStatus.mockReturnValue({ status: null, loading: false });
mocks.hideDisabled.mockReturnValue(false);
for (const key of NAV_GATED_KEYS) setEnabled(key, true);
});
it("scopes workspace integrations to the active workspace", () => {
renderHook(() => useNavAvailability());
expect(mocks.jiraAvailable).toHaveBeenCalledWith(mocks.workspaceId);
expect(mocks.linearAvailable).toHaveBeenCalledWith(mocks.workspaceId);
expect(mocks.jiraAuthed).toHaveBeenCalledWith(mocks.workspaceId);
expect(mocks.linearAuthed).toHaveBeenCalledWith(mocks.workspaceId);
expect(mocks.azureDevOpsAvailable).toHaveBeenCalledWith(mocks.workspaceId);
});
@@ -102,8 +175,52 @@ describe("useNavAvailability", () => {
renderHook(() => useNavAvailability());
expect(mocks.jiraAvailable).toHaveBeenCalledWith(null);
expect(mocks.linearAvailable).toHaveBeenCalledWith(null);
expect(mocks.jiraAuthed).toHaveBeenCalledWith(null);
expect(mocks.linearAuthed).toHaveBeenCalledWith(null);
expect(mocks.azureDevOpsAvailable).toHaveBeenCalledWith(null);
});
describe.each(NAV_GATED_KEYS)("decoupling enabled from nav visibility for %s", (key) => {
it("hides an unconfigured integration regardless of enabled/hideDisabled", () => {
setConfigured(key, false);
setEnabled(key, true);
mocks.hideDisabled.mockReturnValue(false);
const { result: hideDisabledOff } = renderHook(() => useNavAvailability());
expect(hideDisabledOff.current[key]).toBe(false);
mocks.hideDisabled.mockReturnValue(true);
const { result: hideDisabledOn } = renderHook(() => useNavAvailability());
expect(hideDisabledOn.current[key]).toBe(false);
});
it('with "hide disabled" off (default), a configured-but-disabled integration stays visible', () => {
setConfigured(key, true);
setEnabled(key, false);
mocks.hideDisabled.mockReturnValue(false);
const { result } = renderHook(() => useNavAvailability());
expect(result.current[key]).toBe(true);
});
it('with "hide disabled" on, a configured-but-disabled integration is hidden', () => {
setConfigured(key, true);
setEnabled(key, false);
mocks.hideDisabled.mockReturnValue(true);
const { result } = renderHook(() => useNavAvailability());
expect(result.current[key]).toBe(false);
});
it('with "hide disabled" on, a configured and enabled integration stays visible', () => {
setConfigured(key, true);
setEnabled(key, true);
mocks.hideDisabled.mockReturnValue(true);
const { result } = renderHook(() => useNavAvailability());
expect(result.current[key]).toBe(true);
});
});
});
+42 -11
View File
@@ -2,17 +2,25 @@
import { useAppStore } from "@/components/state-provider";
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 { useJiraAvailable } from "@/hooks/domains/jira/use-jira-availability";
import { useLinearAvailable } from "@/hooks/domains/linear/use-linear-availability";
import { useGitLabEnabled } from "@/hooks/domains/gitlab/use-gitlab-enabled";
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 { useHideDisabledIntegrationsInNav } from "@/hooks/domains/integrations/use-hide-disabled-integrations-in-nav";
import type { AvailabilityMap } from "@/lib/navigation/types";
import type { GitHubStatus } from "@/lib/types/github";
/** Human-readable status label for a not-yet-connected integration destination. */
function getStatusLabel(loading: boolean | undefined): string {
return loading ? "Checking" : "Setup";
}
/** Derives the GitHub nav destination's ready/label pair from its auth status. */
export function getGitHubIntegrationStatus(status: GitHubStatus | null, loading: boolean) {
if (status?.authenticated) return { ready: true, label: "Connected" };
if (status?.token_configured) return { ready: true, label: "Configured" };
@@ -28,6 +36,18 @@ export function getGitHubIntegrationStatus(status: GitHubStatus | null, loading:
* mobile menu gate on one implementation instead of each calling the five domain
* hooks themselves.
*
* A destination is visible when it is **configured** (credentials saved,
* auth healthy) AND, only when the user has turned on "Hide disabled
* integrations from left panel navigation" (`useHideDisabledIntegrationsInNav`,
* off by default), also **enabled**. With that setting off — the default — a
* disabled-but-configured integration still shows in the nav exactly like an
* enabled one; only credential/health status gates it. This deliberately
* decouples nav visibility from the per-integration "enabled" toggle used
* elsewhere (import popovers, Kanban external-link buttons, task-top-bar
* buttons) — those keep gating on enabled-and-authed via each integration's
* `useXAvailable`, unaffected by this hook. See
* `docs/specs/integrations/enable-disable-toggle.md`.
*
* Availability is **not** deduplicated across consumers. GitHub and GitLab are
* store-backed, but Jira, Linear and Azure DevOps go through
* `useIntegrationAuthed`, which owns a fetch plus a 90s `setInterval` per mounted
@@ -58,16 +78,27 @@ export function useNavAvailability(): AvailabilityMap {
// default-workspace resolution applies instead.
const scopedWorkspaceId = activeWorkspaceExists ? activeWorkspaceId : null;
const { status, loading } = useGitHubStatus();
const gitlabAvailable = useGitLabAvailable();
const jiraAvailable = useJiraAvailable(scopedWorkspaceId);
const linearAvailable = useLinearAvailable(scopedWorkspaceId);
const azureDevOpsAvailable = useAzureDevOpsAvailable(scopedWorkspaceId);
const gitlabConfigured = useGitLabAvailable();
const jiraConfigured = useJiraAuthed(scopedWorkspaceId);
const linearConfigured = useLinearAuthed(scopedWorkspaceId);
const azureDevOpsConfigured = useAzureDevOpsAvailable(scopedWorkspaceId);
const githubConfigured = getGitHubIntegrationStatus(status, loading).ready;
const { enabled: azureDevOpsEnabled } = useAzureDevOpsEnabled();
const { enabled: githubEnabled } = useGitHubEnabled();
const { enabled: gitlabEnabled } = useGitLabEnabled();
const { enabled: jiraEnabled } = useJiraEnabled();
const { enabled: linearEnabled } = useLinearEnabled();
const { hideDisabled } = useHideDisabledIntegrationsInNav();
const visible = (configured: boolean, enabled: boolean) =>
configured && (!hideDisabled || enabled);
return {
"azure-devops": !!azureDevOpsAvailable,
github: getGitHubIntegrationStatus(status, loading).ready,
gitlab: gitlabAvailable,
jira: jiraAvailable,
linear: linearAvailable,
"azure-devops": visible(!!azureDevOpsConfigured, azureDevOpsEnabled),
github: visible(githubConfigured, githubEnabled),
gitlab: visible(gitlabConfigured, gitlabEnabled),
jira: visible(jiraConfigured, jiraEnabled),
linear: visible(linearConfigured, linearEnabled),
};
}
+1 -2
View File
@@ -40,9 +40,8 @@
"deleteSavedQuery": "Delete saved query",
"describeYourChanges": "Describe your changes...",
"description": "Description",
"disabled": "Disabled",
"draftCreated": "Draft {{shortName}} created",
"enabled": "Enabled",
"enableIntegrationNamed": "Enable {{name}}",
"enterCommitMessage": "Enter commit message...",
"failedToCopyConfig": "Failed to copy config: {{err}}",
"filesChanged_one": "<0>{{count}}</0> file changed",
+2
View File
@@ -307,6 +307,8 @@
"integrationDescriptionJira": "Atlassian Cloud credentials and JQL issue watchers.",
"integrationDescriptionLinear": "Personal API key and team defaults.",
"integrationDescriptionSentry": "Auth token, org/project defaults, and issue browsing.",
"hideDisabledIntegrationsFromNav": "Hide disabled integrations from left panel navigation",
"hideDisabledIntegrationsFromNavDescription": "When on, a disabled integration is removed from the sidebar and mobile menu even if it's still configured.",
"invalidJson": "Invalid JSON",
"keyboardShortcuts": "Keyboard Shortcuts",
"language": "Language",
@@ -40,9 +40,8 @@
"deleteSavedQuery": "Ďēĺēţē śàvēď qũēŕŷ",
"describeYourChanges": "Ďēśćŕĩƀē ŷōũŕ ćĥàńĝēś...",
"description": "Ďēśćŕĩƥţĩōń",
"disabled": "Ďĩśàƀĺēď",
"draftCreated": "Ďŕàƒţ {{shortName}} ćŕēàţēď",
"enabled": "Ēńàƀĺēď",
"enableIntegrationNamed": "Ēńàƀĺē {{name}}",
"enterCommitMessage": "Ēńţēŕ ćōḿḿĩţ ḿēśśàĝē...",
"failedToCopyConfig": "Ƒàĩĺēď ţō ćōƥŷ ćōńƒĩĝ: {{err}}",
"filesChanged_one": "<0>{{count}}</0> ƒĩĺē ćĥàńĝēď",
@@ -307,6 +307,8 @@
"integrationDescriptionJira": "Àţĺàśśĩàń Ćĺōũď ćŕēďēńţĩàĺś àńď ĴQĹ ĩśśũē ŵàţćĥēŕś.",
"integrationDescriptionLinear": "Ƥēŕśōńàĺ ÀƤĨ ķēŷ àńď ţēàḿ ďēƒàũĺţś.",
"integrationDescriptionSentry": "Àũţĥ ţōķēń, ōŕĝ/ƥŕōĵēćţ ďēƒàũĺţś, àńď ĩśśũē ƀŕōŵśĩńĝ.",
"hideDisabledIntegrationsFromNav": "Ĥĩďē ďĩśàƀĺēď ĩńţēĝŕàţĩōńś ƒŕōḿ ĺēƒţ ƥàńēĺ ńàvĩĝàţĩōń",
"hideDisabledIntegrationsFromNavDescription": "Ŵĥēń ōń, à ďĩśàƀĺēď ĩńţēĝŕàţĩōń ĩś ŕēḿōvēď ƒŕōḿ ţĥē śĩďēƀàŕ àńď ḿōƀĩĺē ḿēńũ ēvēń ĩƒ ĩţ'ś śţĩĺĺ ćōńƒĩĝũŕēď.",
"invalidJson": "Ĩńvàĺĩď ĴŚŌŃ",
"keyboardShortcuts": "Ķēŷƀōàŕď Śĥōŕţćũţś",
"language": "Ĺàńĝũàĝē",
+1 -2
View File
@@ -40,9 +40,8 @@
"deleteSavedQuery": "Eliminar a consulta guardada",
"describeYourChanges": "Descreva as suas alterações...",
"description": "Descrição",
"disabled": "Desativado",
"draftCreated": "Rascunho de {{shortName}} criado",
"enabled": "Ativado",
"enableIntegrationNamed": "Ativar {{name}}",
"enterCommitMessage": "Introduza a mensagem de commit...",
"failedToCopyConfig": "Não foi possível copiar a configuração: {{err}}",
"filesChanged_one": "<0>{{count}}</0> ficheiro alterado",
+8
View File
@@ -8,6 +8,7 @@ import ExecutorCreatePage from "@/app/settings/executor/new/page";
import ProfileEditPage from "@/app/settings/executors/[profileId]/page";
import CreateProfilePage from "@/app/settings/executors/new/[type]/page";
import SSHExecutorPage from "@/app/settings/executors/ssh/[executorId]/page";
import IntegrationsGitHubPage from "@/app/settings/integrations/github/page";
import IntegrationsGitLabPage from "@/app/settings/integrations/gitlab/page";
import PluginDetailPage from "@/app/settings/plugins/[pluginId]/page";
import AutomationEditorPage from "@/app/settings/workspace/[id]/automations/[automationId]/page";
@@ -180,6 +181,13 @@ describe("renderSettingsRoute", () => {
expect(gitLabRouteWorkspaceId("/settings/workspace/ws%202/integrations/gitlab")).toBe("ws 2");
});
it("routes GitHub settings through the integration page wrapper", () => {
const route = renderSettingsRoute("/settings/integrations/github");
expect(isValidElement(route)).toBe(true);
expect((route as ReactElement).type).toBe(IntegrationsGitHubPage);
});
it.each([
{
pathname: "/settings/plugins/plugin%20one",
+8 -3
View File
@@ -14,6 +14,8 @@ import CreateProfilePage from "@/app/settings/executors/new/[type]/page";
import SSHExecutorPage from "@/app/settings/executors/ssh/[executorId]/page";
import ExternalMcpPage from "@/app/settings/external-mcp/page";
import IntegrationsIndexPage from "@/app/settings/integrations/page";
import { IntegrationsIndexPage as IntegrationsIndexPageClient } from "@/components/integrations/integrations-index-page";
import IntegrationsGitHubPage from "@/app/settings/integrations/github/page";
import IntegrationsAzureDevOpsPage from "@/app/settings/integrations/azure-devops/page";
import IntegrationsGitLabPage from "@/app/settings/integrations/gitlab/page";
import IntegrationsJiraPage from "@/app/settings/integrations/jira/page";
@@ -31,7 +33,6 @@ import WorkspaceEditPage from "@/app/settings/workspace/[id]/page";
import { WorkspaceRepositoriesClient } from "@/app/settings/workspace/workspace-repositories-client";
import { WorkspaceWorkflowsClient } from "@/app/settings/workspace/workspace-workflows-client";
import WorkspacesPage from "@/app/settings/workspace/page";
import { GitHubIntegrationPage } from "@/components/github/github-settings";
import Link from "@/components/routing/app-link";
import { useAppStoreApi } from "@/components/state-provider";
import { EditorsSettings } from "@/components/settings/editors-settings";
@@ -385,11 +386,15 @@ function renderWorkspaceSettingsRoute(pathname: string) {
function renderIntegrationSettingsRoute(section: string | null, workspaceId?: string) {
switch (section) {
case null:
return <IntegrationsIndexPage workspaceId={workspaceId} />;
return workspaceId ? (
<IntegrationsIndexPageClient workspaceId={workspaceId} />
) : (
<IntegrationsIndexPage />
);
case "azure-devops":
return <IntegrationsAzureDevOpsPage workspaceId={workspaceId} />;
case "github":
return <GitHubIntegrationPage workspaceId={workspaceId} />;
return <IntegrationsGitHubPage workspaceId={workspaceId} />;
case "gitlab":
return <IntegrationsGitLabPage workspaceId={workspaceId} />;
case "jira":
@@ -0,0 +1,402 @@
---
spec: docs/specs/integrations/enable-disable-toggle.md
created: 2026-08-06
status: complete
---
# Implementation Plan: Integration Enable/Disable Toggle & Nav Visibility
## Overview
Three new `localStorage`-backed hooks (`useAzureDevOpsEnabled`, `useGitHubEnabled`,
`useGitLabEnabled`) bring Azure DevOps, GitHub and GitLab up to parity with
Jira/Linear/Sentry/Slack's existing enable/disable toggle (Task 01). The
existing `<DraftedIntegrationEnabledControl>` is wired onto those three
integrations' own settings pages (Task 02) and onto every integration's row
on the `/settings/integrations` index page, which requires a small DOM
restructure of the index-page cards so the slider is never nested inside the
card's navigating `<a>` (Task 03). A new `useHideDisabledIntegrationsInNav`
hook backs a new index-page toggle row, default off (Task 04). Finally
`useNavAvailability` is changed to gate left-panel nav visibility on
"configured" independent of "enabled" by default, only folding in "enabled"
when the new toggle is on (Task 05). E2E coverage proves the index-page
sliders and the nav-hiding behavior end to end (Task 06).
Order follows the dependency chain: the enabled hooks (01) are needed before
anything can display or persist a slider (02, 03, 04); the nav-availability
rewire (05) depends on the enabled hooks from 01 (and 04's hide-disabled hook)
existing; E2E (06) exercises 02–05 together.
---
## Frontend
### Task 01 — Enabled hooks for Azure DevOps, GitHub, GitLab
New files, each a one-line wrapper over the existing generic
`useIntegrationEnabled` (`apps/web/hooks/domains/integrations/use-integration-enabled.ts`),
mirroring `apps/web/hooks/domains/jira/use-jira-enabled.ts`:
- `apps/web/hooks/domains/azure-devops/use-azure-devops-enabled.ts`
- `STORAGE_KEY = "kandev:azure-devops:enabled:v1"`
- `LEGACY_KEY_PREFIX = "kandev:azure-devops:enabled:"` (no real legacy data;
prefix kept only for signature symmetry with `useIntegrationEnabled`,
which requires it — the migration scan will simply find nothing).
- `SYNC_EVENT = "kandev:azure-devops:enabled-changed"`
- exports `useAzureDevOpsEnabled()`
- `apps/web/hooks/domains/github/use-github-enabled.ts` — same shape,
`kandev:github:enabled:v1` / `kandev:github:enabled:` /
`kandev:github:enabled-changed`, exports `useGitHubEnabled()`
- `apps/web/hooks/domains/gitlab/use-gitlab-enabled.ts` — same shape,
`kandev:gitlab:enabled:v1` / `kandev:gitlab:enabled:` /
`kandev:gitlab:enabled-changed`, exports `useGitLabEnabled()`
### Task 02 — Own-page slider for Azure DevOps, GitHub, GitLab
New wrapper components, one per integration, mirroring
`apps/web/components/jira/jira-enabled-control.tsx`:
- `apps/web/components/azure-devops/azure-devops-enabled-control.tsx` —
`AzureDevOpsEnabledControl()` renders
`<DraftedIntegrationEnabledControl id="azure-devops" enabled={enabled} persist={setEnabled} />`
using `useAzureDevOpsEnabled()`.
- `apps/web/components/github/github-enabled-control.tsx` — same shape for
GitHub, `id="github"`, using `useGitHubEnabled()`.
- `apps/web/components/gitlab/gitlab-enabled-control.tsx` — same shape for
GitLab, `id="gitlab"`, using `useGitLabEnabled()`.
Wire each into its integration's top connection `<SettingsSection>` via the
existing `action` prop (`apps/web/components/settings/settings-section.tsx`
already supports `action?: ReactNode`, used today by
`apps/web/components/jira/jira-settings.tsx:576`):
- `apps/web/components/azure-devops/azure-devops-settings.tsx` —
`AzureDevOpsConnectionSection`'s `<SettingsSection>` (around line 489) gets
`action={<AzureDevOpsEnabledControl />}`.
- `apps/web/components/github/github-settings.tsx` —
`GitHubConnectionSection`'s `<SettingsSection>` (around line 263) gets
`action={<GitHubEnabledControl />}`.
- `apps/web/components/gitlab/gitlab-settings.tsx` —
`GitLabConnectionSection`/`GitLabConnectionCard`'s top `<SettingsSection>`
(around line 487–526; confirm exact section in-task, since the file was only
partially read during planning) gets
`action={<GitLabEnabledControl />}`.
### Task 03 — Per-row slider on the integrations index page
File: `apps/web/app/settings/integrations/page.tsx`.
Current structure wraps the whole `<Card>` in a `<Link>` so the entire card
navigates on click (`integrationCards` in
`apps/web/e2e/tests/integrations/integrations-index-layout-helpers.ts` locates
cards via `a[href*="/integrations/"] > [data-slot="card"]`). Nesting an
interactive `<Switch>` inside that anchor is invalid DOM (interactive-in-
interactive) and fragile for click-through. Restructure so the `<Card>` is
the outer element (not itself inside an `<a>`), and only the label/description
region is an anchor, with the slider as a sibling in a header row — never
inside any anchor's subtree:
```tsx
<Card key={slug} data-testid={`integration-card-${slug}`} className="h-full w-full transition-colors hover:border-primary/40">
<CardContent className="flex h-full flex-col gap-2">
<div className="flex items-center justify-between gap-2">
<Link href={href} className="flex items-center gap-2 text-base font-semibold hover:underline">
<Icon className="h-5 w-5" />
{label}
</Link>
<IntegrationEnabledRowControl slug={slug} />
</div>
<Link href={href} className="text-sm text-muted-foreground">
{t(descriptionKey, { trigger: "!kandev" })}
</Link>
</CardContent>
</Card>
```
`IntegrationEnabledRowControl` is a small local switch component (in the same
file, or a new `apps/web/components/integrations/integration-enabled-row-control.tsx`
if reused elsewhere) that maps `slug` to the right `useXEnabled()` hook via a
lookup table (`azure-devops` → `useAzureDevOpsEnabled`, `github` →
`useGitHubEnabled`, `gitlab` → `useGitLabEnabled`, `jira` → `useJiraEnabled`,
`linear` → `useLinearEnabled`, `sentry` → `useSentryEnabled`, `slack` →
`useSlackEnabled`) and renders
`<DraftedIntegrationEnabledControl id={slug} enabled={enabled} persist={setEnabled} />`.
Hooks cannot be selected dynamically at runtime (rules of hooks), so
`IntegrationEnabledRowControl` must be one small component per slug internally
(a `switch (slug)` returning seven `<DraftedIntegrationEnabledControl>` calls,
each backed by its own statically-imported `useXEnabled` hook) — not a single
component that looks up a hook function from a map and calls it conditionally.
Update `apps/web/e2e/tests/integrations/integrations-index-layout-helpers.ts`:
`integrationCards()` currently derives cards from
`a[href*="/integrations/"]` and asserts `:scope > [data-slot="card"]`. Change
it to locate cards directly, e.g.
`page.getByTestId("settings-scroll-container").locator('[data-testid^="integration-card-"]')`,
and update `integrationCardIconTopInsets` if the icon is no longer the first
child of `CardContent` in a way that changes its measured offset (it still is
— the header row is the first child of `CardContent`, and the icon is the
first child of that row's `<Link>`, so `card.locator("svg").first()` still
resolves to the same icon and the existing `MAX_ICON_TOP_INSET_PX` assertion
should still hold; verify in-task and adjust the constant only if the new
layout genuinely shifts it).
### Task 04 — "Hide disabled integrations from left panel navigation" setting
New hook: `apps/web/hooks/domains/integrations/use-hide-disabled-integrations-in-nav.ts`
```ts
"use client";
// Same useSyncExternalStore + storage-event + custom-event shape as
// use-integration-enabled.ts, but standalone (not per-integration, no legacy
// migration) and defaults to false instead of true.
const STORAGE_KEY = "kandev:integrations:hideDisabledInNav:v1";
const SYNC_EVENT = "kandev:integrations:hide-disabled-in-nav-changed";
export function useHideDisabledIntegrationsInNav(): {
hideDisabled: boolean;
setHideDisabled: (next: boolean) => void;
};
```
Add a new row to `apps/web/app/settings/integrations/page.tsx`, below the
integration cards grid, using the existing `useDraftedIntegrationEnabled` +
a plain `<Switch>` row (mirroring
`apps/web/components/settings/archive-confirmation-settings.tsx`'s row
layout, but inline in this page rather than a separate `SettingsCard`, since
the index page is not yet composed of `SettingsCard`s):
```tsx
function HideDisabledIntegrationsSetting() {
const { t } = useTranslation();
const { hideDisabled, setHideDisabled } = useHideDisabledIntegrationsInNav();
const draft = useDraftedIntegrationEnabled({
id: "integrations-hide-disabled-in-nav",
enabled: hideDisabled,
persist: setHideDisabled,
});
return (
<div className="flex min-h-11 items-center justify-between gap-4 rounded-lg border p-4">
<div className="min-w-0 space-y-0.5">
<Label htmlFor="hide-disabled-integrations-in-nav">
{t("settings:hideDisabledIntegrationsFromNav")}
</Label>
<p className="text-xs text-muted-foreground">
{t("settings:hideDisabledIntegrationsFromNavDescription")}
</p>
</div>
<Switch
id="hide-disabled-integrations-in-nav"
checked={draft.enabled}
data-settings-dirty={draft.isDirty}
onCheckedChange={draft.setEnabled}
className="shrink-0 cursor-pointer"
/>
</div>
);
}
```
Add new locale keys to `apps/web/src/locales/en/settings.json`:
- `hideDisabledIntegrationsFromNav`: `"Hide disabled integrations from left panel navigation"`
- `hideDisabledIntegrationsFromNavDescription`: `"When on, a disabled integration is removed from the sidebar and mobile menu even if it's still configured."`
Run the project's i18n tooling for the pseudo-locale/ratchet as part of this
task's verification (see Task 04's own file for the exact command); do not
hand-author the pseudo-locale entry.
### Task 05 — Decouple left-panel nav visibility from "enabled"
File: `apps/web/hooks/use-nav-availability.ts`.
Replace the `useJiraAvailable`/`useLinearAvailable` imports with
`useJiraAuthed`/`useLinearAuthed` (already exported by
`hooks/domains/jira/use-jira-availability.ts` and
`hooks/domains/linear/use-linear-availability.ts` respectively — these are
the existing "configured and healthy" signal, independent of the enabled
toggle). Add the three new `useXEnabled` hooks from Task 01 plus the existing
`useJiraEnabled`/`useLinearEnabled`, plus `useHideDisabledIntegrationsInNav`
from Task 04. Compute each of the five nav-gated keys as:
```ts
const configured = { /* azure-devops, github, gitlab, jira, linear — unchanged signals */ };
const enabled = { /* one useXEnabled().enabled per key */ };
const { hideDisabled } = useHideDisabledIntegrationsInNav();
const visible = (key: AvailabilityKey) => configured[key] && (!hideDisabled || enabled[key]);
return {
"azure-devops": visible("azure-devops"),
github: visible("github"),
gitlab: visible("gitlab"),
jira: visible("jira"),
linear: visible("linear"),
};
```
`azure-devops`'s `configured` signal stays `useAzureDevOpsAvailable(scopedWorkspaceId)`
(unchanged import/name — it is already the pure "authed" signal today, since
Azure DevOps had no enabled toggle before this feature). `github`'s
`configured` signal stays `getGitHubIntegrationStatus(status, loading).ready`
(unchanged). `gitlab`'s stays `useGitLabAvailable()` (unchanged). Do not
rename or change these three existing hooks/consumers — they are reused
unmodified by other surfaces (e.g. `apps/web/components/gitlab/mr-topbar-button.tsx`)
that must keep gating purely on configured/health, never on the new
navigation-only "enabled" concept (see spec's Out of scope).
Update existing tests that mock the hooks this file imports:
- `apps/web/hooks/use-nav-availability.test.ts` — currently mocks
`useJiraAvailable`/`useLinearAvailable`; change mocks to
`useJiraAuthed`/`useLinearAuthed`, add mocks for the three new `useXEnabled`
hooks, `useJiraEnabled`, `useLinearEnabled`, and
`useHideDisabledIntegrationsInNav`. Add new test cases per the Tests
section below.
- `apps/web/components/integrations/integrations-menu.test.ts` — currently
mocks `useGitLabAvailable`/`useJiraAvailable`/`useLinearAvailable` at the
hook level consumed transitively through `useNavAvailability`; update its
mocks the same way (switch Jira/Linear mocks to the `Authed` hooks; add the
enabled/hide-disabled mocks it now transitively needs, defaulting
`hideDisabled` to `false` so existing assertions keep passing unchanged).
---
## Tests
- **What:** `useAzureDevOpsEnabled`/`useGitHubEnabled`/`useGitLabEnabled`
default to `true`, persist writes, and broadcast their sync event.
**File:** one `*.test.ts` per hook beside it (e.g.
`apps/web/hooks/domains/azure-devops/use-azure-devops-enabled.test.ts`).
**How:** unit test with `renderHook`, mirroring
`apps/web/hooks/domains/jira/use-jira-enabled.ts`'s existing test if one
exists (check first; if the generic `useIntegrationEnabled` already has
full coverage in `use-integration-enabled.test.ts`, a thin smoke test per
wrapper — default true, `setEnabled` round-trips — is sufficient; do not
duplicate the generic hook's exhaustive cases).
- **What:** `useHideDisabledIntegrationsInNav` defaults to `false`, persists,
broadcasts a same-tab sync event, and degrades to `false` when
`localStorage` throws. **File:**
`apps/web/hooks/domains/integrations/use-hide-disabled-integrations-in-nav.test.ts`.
**How:** unit test with `renderHook`, mirroring
`use-integration-enabled.ts`'s own test suite structure but asserting the
opposite default.
- **What:** the index page renders a slider per integration row that
reflects and updates that integration's stored enabled state, and clicking
the slider does not navigate. **File:**
`apps/web/app/settings/integrations/page.test.tsx` (new).
**How:** component test with Testing Library; mock each `useXEnabled` hook,
render the page, assert seven switches present with correct initial
`checked`, click one and assert its `persist`/`setEnabled` was called and
`window.location`/router navigation was not triggered.
- **What:** the new "hide disabled" row renders default-off, and toggling it
calls `setHideDisabled` only after the settings save action fires (drafted,
not immediate) — consistent with `useDraftedIntegrationEnabled`'s existing
contract. **File:** same `page.test.tsx` as above.
**How:** component test, mirroring
`apps/web/components/integrations/use-drafted-integration-enabled.test.tsx`'s
assertions.
- **What:** `useNavAvailability` returns `true` for a configured-but-disabled
integration when `hideDisabled` is `false` (default), and `false` when
`hideDisabled` is `true`; an unconfigured integration stays `false`
regardless of `hideDisabled`/`enabled`. **File:**
`apps/web/hooks/use-nav-availability.test.ts` (extend existing describe
block). **How:** unit test with `renderHook`, table-driven over the five
nav-gated keys crossed with `{configured, enabled, hideDisabled}` per the
spec's Scenarios section.
- **What:** `AzureDevOpsEnabledControl`/`GitHubEnabledControl`/`GitLabEnabledControl`
render `<DraftedIntegrationEnabledControl>` wired to the right hook.
**File:** one `*.test.tsx` per control, or omit if
`drafted-integration-enabled-control.test.tsx`-style coverage of the
generic component already makes a thin wrapper test low-value — decide
in-task; if omitted, cover the wiring through the E2E test in Task 06
instead (own-page slider scenario) and state that explicitly in the task's
Results.
---
## E2E Tests
> Files land under `apps/web/e2e/tests/integrations/`.
- **Scenario:** GIVEN the integrations index page, WHEN it renders, THEN
every one of the seven rows shows a slider, and toggling GitHub's slider
flips its own settings-page slider to the same state (cross-page sync,
same mechanism proven for Jira today) — restated from the spec's "index
page ... every row ... slider" and "keeps both locations in sync"
scenarios. **File:**
`apps/web/e2e/tests/integrations/integrations-index-enabled-toggle.spec.ts`
(new). **What to verify:** switch role + accessible name per row; toggling
does not navigate (`expect(page).toHaveURL(...)` unchanged); the GitHub
settings page slider reflects the new state after navigating there.
- **Scenario:** GIVEN GitHub is configured/healthy and disabled, and "hide
disabled" is off (default), WHEN the sidebar renders, THEN GitHub's nav
entry is visible; WHEN the user turns "hide disabled" on, THEN GitHub's
nav entry disappears without a reload; WHEN the user re-enables GitHub,
THEN it reappears. **File:**
`apps/web/e2e/tests/integrations/hide-disabled-integrations-nav.spec.ts`
(new). **What to verify:** `apiClient.mockGitHubSetUser(...)` (existing
fixture) to make GitHub configured/healthy; sidebar `getByRole("link",
{name: "GitHub"})` visible/hidden assertions before/after each toggle,
matching the visibility-check style already used in
`apps/web/e2e/tests/integrations/mobile-integrations-nav.spec.ts`.
- Update `apps/web/e2e/tests/integrations/integrations-index-layout.spec.ts`
only if Task 03's DOM restructure changes the measured layout invariants;
otherwise it should keep passing unmodified against the updated helper
from Task 03.
---
## Verification Results
All six tasks completed; full detail lives in each task file's `## Results`
section. Summary:
- Unit/component tests: 80 passed across 11 vitest files (task-01 hooks,
task-02/04 settings-page component tests, task-03/04 index-page component
tests, task-05 nav-availability + integrations-menu tests, plus the
existing azure-devops/gitlab/linear/sentry/slack settings suites, all
re-run green after the new `action` controls were wired in).
- `cd apps/web && pnpm run typecheck` — clean.
- `cd apps/web && pnpm run i18n:check && pnpm run i18n:ratchet` — clean (no
new orphans, new-code ratchet clean, guard allowlist intact).
- E2E (built locally: `make build-backend`, `make build-web`,
`make -C apps/backend e2e-plugin-package`):
`integrations-index-enabled-toggle.spec.ts`,
`hide-disabled-integrations-nav.spec.ts`, and the pre-existing
`integrations-index-layout.spec.ts` — `3 passed`.
- Scoped `eslint --max-warnings 0` over every changed `.ts`/`.tsx` file —
clean (one `sonarjs/no-duplicate-string` warning in the new page test was
fixed by extracting `ariaChecked()`/`ARIA_CHECKED_TRUE`/`ARIA_CHECKED_FALSE`
helpers).
- `make fmt` was run repo-wide; the only out-of-scope reformat it produced
(pre-existing gofmt drift in `apps/backend/internal/agent/runtime/lifecycle/executor_backend.go`,
unrelated to this feature) was reverted before committing.
---
## Implementation Waves And Parallel Candidates
```text
Wave 1:
- [x] [task-01-enabled-hooks](task-01-enabled-hooks.md)
Wave 2 (parallel candidates — user authorization required):
- [x] [task-02-own-page-sliders](task-02-own-page-sliders.md)
- [x] [task-03-index-page-sliders](task-03-index-page-sliders.md)
Wave 3:
- [x] [task-04-hide-disabled-setting](task-04-hide-disabled-setting.md)
Wave 4:
- [x] [task-05-nav-availability](task-05-nav-availability.md)
Wave 5:
- [x] [task-06-e2e](task-06-e2e.md)
```
Task 02 and 03 touch disjoint files (different integration components vs.
the index page) and are the only pair labeled parallel-safe. Task 04 also
edits `apps/web/app/settings/integrations/page.tsx` (Task 03's file), so it
runs after 03, not alongside it, even though both ultimately land in the
same file. Task 05 depends on the hooks from 01 and the hide-disabled hook
from 04. Task 06 exercises 02–05 together.
---
## Open Questions
(none)
@@ -0,0 +1,63 @@
---
id: "01-enabled-hooks"
title: "Enabled hooks for Azure DevOps, GitHub, GitLab"
status: done
wave: 1
depends_on: []
plan: "plan.md"
spec: "../../specs/integrations/enable-disable-toggle.md"
---
# Task 01: Enabled hooks for Azure DevOps, GitHub, GitLab
- **Acceptance:**
1. `useAzureDevOpsEnabled()`, `useGitHubEnabled()`, `useGitLabEnabled()`
each exist, default to `enabled: true` on first read, persist via
`setEnabled`, and broadcast their own sync event — one-line wrappers
over `useIntegrationEnabled`, mirroring `useJiraEnabled()`.
2. Each hook has a unit test asserting the default-true value and a
persisted round-trip via `setEnabled`.
- **Verification:** `cd apps && pnpm --filter @kandev/web test -- hooks/domains/azure-devops/use-azure-devops-enabled hooks/domains/github/use-github-enabled hooks/domains/gitlab/use-gitlab-enabled`
- **Files likely touched:**
- `apps/web/hooks/domains/azure-devops/use-azure-devops-enabled.ts` (new)
- `apps/web/hooks/domains/azure-devops/use-azure-devops-enabled.test.ts` (new)
- `apps/web/hooks/domains/github/use-github-enabled.ts` (new)
- `apps/web/hooks/domains/github/use-github-enabled.test.ts` (new)
- `apps/web/hooks/domains/gitlab/use-gitlab-enabled.ts` (new)
- `apps/web/hooks/domains/gitlab/use-gitlab-enabled.test.ts` (new)
- **Dependencies:** None.
- **Parallelism:** sequential (first task; nothing else can start before it).
- **Inputs:**
- Spec: "Data model" (storage keys), "API surface" (hook shapes).
- Plan: "Task 01" section.
- Pattern to mirror exactly: `apps/web/hooks/domains/jira/use-jira-enabled.ts`
and the generic implementation it wraps,
`apps/web/hooks/domains/integrations/use-integration-enabled.ts`.
- If a test already exists for a sibling wrapper (e.g.
`use-linear-enabled.test.ts`), mirror its structure; if no wrapper-level
test exists anywhere in the codebase today (only the generic hook is
tested), write the minimal smoke test described in the plan's Tests
section rather than re-testing `useIntegrationEnabled`'s full behavior.
- **Output contract:** summary, files changed, exact test command run and its
output, blockers/risks, and update this task's `status` to `done` plus the
corresponding checkbox in `plan.md`.
## Results
- Added `useAzureDevOpsEnabled`/`useGitHubEnabled`/`useGitLabEnabled` as
one-line wrappers over `useIntegrationEnabled`, mirroring `useJiraEnabled`
exactly (same storage-key/legacy-prefix/sync-event shape). No prior
per-integration enabled key existed for these three, so the legacy-prefix
migration scan is inert but present for signature symmetry.
- Added a unit test per hook (default-true, persisted-false round-trip,
cross-tab custom-event propagation), mirroring `use-linear-enabled.test.ts`
minus the legacy-migration case (no real legacy key ever existed for these
three integrations).
- Command: `cd apps && pnpm --filter @kandev/web test -- hooks/domains/azure-devops/use-azure-devops-enabled hooks/domains/github/use-github-enabled hooks/domains/gitlab/use-gitlab-enabled` → `Test Files 3 passed (3)`, `Tests 12 passed (12)`.
- Files changed: `apps/web/hooks/domains/azure-devops/use-azure-devops-enabled.ts`,
`apps/web/hooks/domains/azure-devops/use-azure-devops-enabled.test.ts`;
`apps/web/hooks/domains/github/use-github-enabled.ts`,
`apps/web/hooks/domains/github/use-github-enabled.test.ts`;
`apps/web/hooks/domains/gitlab/use-gitlab-enabled.ts`,
`apps/web/hooks/domains/gitlab/use-gitlab-enabled.test.ts` (all new).
- Blockers/risks: none.
@@ -0,0 +1,91 @@
---
id: "02-own-page-sliders"
title: "Own-page enable/disable slider for Azure DevOps, GitHub, GitLab"
status: done
wave: 2
depends_on: ["01-enabled-hooks"]
plan: "plan.md"
spec: "../../specs/integrations/enable-disable-toggle.md"
---
# Task 02: Own-page enable/disable slider for Azure DevOps, GitHub, GitLab
- **Acceptance:**
1. `/settings/integrations/azure-devops`, `/settings/integrations/github`
and `/settings/integrations/gitlab` each render a slider in their top
connection section's header (via `<SettingsSection action={...}>`),
reflecting and persisting that integration's `useXEnabled()` state,
visually and behaviorally identical to Jira's existing
`<JiraEnabledControl />` (draft + shared save bar, `data-settings-dirty`
wiring via `useDraftedIntegrationEnabled`).
2. A component test per new control confirms it renders
`<DraftedIntegrationEnabledControl>` wired to the right hook (or, if
judged low-value per the plan's Tests section, the wiring is instead
proven by Task 06's E2E "own-page slider" assertion — state which choice
was made in Results).
- **Verification:** `cd apps && pnpm --filter @kandev/web test -- azure-devops-settings gitlab-settings linear-settings sentry-settings slack-settings`
- **Files likely touched:**
- `apps/web/components/azure-devops/azure-devops-enabled-control.tsx` (new)
- `apps/web/components/azure-devops/azure-devops-settings.tsx` (edit)
- `apps/web/components/github/github-enabled-control.tsx` (new)
- `apps/web/components/github/github-settings.tsx` (edit)
- `apps/web/components/gitlab/gitlab-enabled-control.tsx` (new)
- `apps/web/components/gitlab/gitlab-settings.tsx` (edit)
- **Dependencies:** `01-enabled-hooks`.
- **Parallelism:** `parallel-safe` with `03-index-page-sliders` (disjoint
files: this task never touches `apps/web/app/settings/integrations/page.tsx`
or the e2e layout helper).
- **Inputs:**
- Spec: "What" (own-page slider requirement), "API surface".
- Plan: "Task 02" section, exact component/wiring shapes.
- Pattern to mirror exactly: `apps/web/components/jira/jira-enabled-control.tsx`
and its usage in `apps/web/components/jira/jira-settings.tsx:576`
(`action={<JiraEnabledControl />}`).
- **Output contract:** summary, files changed, exact test command(s) run and
their output, blockers/risks, and update this task's `status` to `done`
plus the corresponding checkbox in `plan.md`.
## Results
- Added `AzureDevOpsEnabledControl`, `GitHubEnabledControl`,
`GitLabEnabledControl` — one-line wrappers mirroring
`jira-enabled-control.tsx`, each rendering
`<DraftedIntegrationEnabledControl>` wired to its Task 01 hook.
- Wired each into its integration's top `<SettingsSection action={...}>`:
`AzureDevOpsConnectionSection`, `GitHubConnectionSection`,
`GitLabConnectionCard` (GitLab's `action` already held a "Refresh" button;
combined both into one `<div className="flex items-center gap-2">`).
- Also extracted the pre-existing, module-local, unexported `EnabledPill`
components out of `linear-settings.tsx`, `sentry-settings.tsx`, and
`slack-settings.tsx` into standalone exported files
(`linear-enabled-control.tsx`, `sentry-enabled-control.tsx`,
`slack-enabled-control.tsx`), matching the Jira/Azure/GitHub/GitLab
file-per-control convention — required so Task 03's index page can import
and reuse all seven controls instead of duplicating their logic inline.
Not in the original plan text but necessary to satisfy Task 03's design
("one component per slug, each calling exactly one hook unconditionally")
without new duplicate `EnabledPill`-shaped components.
- Fixed a latent gap surfaced by adding the new `action`: the existing
`azure-devops-settings.test.tsx` rendered `AzureDevOpsConnectionSection`
directly (no `SettingsSaveProvider` ancestor), which the new
`AzureDevOpsEnabledControl` → `useDraftedIntegrationEnabled` →
`useSettingsSaveContributor` now requires. Wrapped the four affected
`render(...)` calls in `<SettingsSaveProvider>`.
- Commands:
- `cd apps && pnpm --filter @kandev/web test -- azure-devops-settings github-settings gitlab-settings` → `Test Files 2 passed (2)`, `Tests 13 passed (13)` (no `github-settings.test.tsx` exists).
- `cd apps && pnpm --filter @kandev/web test -- linear-settings sentry-settings slack-settings` → `Test Files 2 passed (2)`, `Tests 6 passed (6)`, plus `sentry-settings.test` run explicitly → `Test Files 1 passed (1)`, `Tests 4 passed (4)`.
- `cd apps/web && pnpm run typecheck` → clean.
- Files changed: the six files listed above under "Files likely touched",
plus `apps/web/components/linear/linear-enabled-control.tsx`,
`apps/web/components/linear/linear-settings.tsx`,
`apps/web/components/sentry/sentry-enabled-control.tsx`,
`apps/web/components/sentry/sentry-settings.tsx`,
`apps/web/components/slack/slack-enabled-control.tsx`,
`apps/web/components/slack/slack-settings.tsx`,
`apps/web/components/azure-devops/azure-devops-settings.test.tsx`.
- Acceptance criterion 2 decision: no thin per-control wrapper test was
added — no other integration's `*-enabled-control.tsx` has one either
(checked `jira-enabled-control.tsx`; no sibling test exists), and the
wiring is proven end-to-end by Task 06's `integrations-index-enabled-toggle.spec.ts`
(own-page slider reflects the index-page toggle after Save).
- Blockers/risks: none.
@@ -0,0 +1,84 @@
---
id: "03-index-page-sliders"
title: "Per-row enable/disable slider on the integrations index page"
status: done
wave: 2
depends_on: ["01-enabled-hooks"]
plan: "plan.md"
spec: "../../specs/integrations/enable-disable-toggle.md"
---
# Task 03: Per-row enable/disable slider on the integrations index page
- **Acceptance:**
1. `/settings/integrations` (and its per-workspace equivalent, same
component with a `workspaceId` prop) renders one slider per integration
row for all seven integrations, each reflecting and persisting that
integration's `useXEnabled()` state.
2. No slider is nested inside a navigating `<a>` — clicking a slider MUST
NOT navigate to the integration's detail page; clicking the
label/description MUST still navigate, exactly as before.
3. `apps/web/e2e/tests/integrations/integrations-index-layout-helpers.ts`'s
`expectStableIntegrationCardLayout` keeps passing against the
restructured cards.
- **Verification:** `cd apps && pnpm --filter @kandev/web test -- app/settings/integrations/page` then `cd apps/web && pnpm e2e -- integrations-index-layout`
- **Files likely touched:**
- `apps/web/app/settings/integrations/page.tsx`
- `apps/web/app/settings/integrations/page.test.tsx` (new)
- `apps/web/e2e/tests/integrations/integrations-index-layout-helpers.ts`
- **Dependencies:** `01-enabled-hooks`.
- **Parallelism:** `parallel-safe` with `02-own-page-sliders`. NOT
parallel-safe with `04-hide-disabled-setting`, which edits the same
`page.tsx`.
- **Inputs:** spec "What"/scenarios, plan Task 03 section, existing card
markup + layout e2e test/helper, `drafted-integration-enabled-control.tsx`.
- **Output contract:** summary, files changed, exact test/e2e commands run
and their output, blockers/risks, task/plan status update.
## Results
- Restructured each card: `Card` is the outer element (no longer wrapped in
a navigating `<a>`); the icon+label link and the per-integration
`EnabledControl` share one top row (`flex items-center justify-between
gap-2`), with `EnabledControl` rendered beside the title link rather than
on a separate row below it, and the description is a separate link below
that. This avoids nesting an interactive `<Switch>` inside an `<a>`
(invalid DOM / fragile click propagation) without any absolute-positioning
trick.
- Selecting the right slider per row required one statically-imported
`useXEnabled` hook per component (rules of hooks forbid picking a hook
dynamically by slug): added `ENABLED_CONTROL_BY_SLUG: Record<IntegrationSlug, ComponentType>`
mapping each slug to its Task 01/02 `<XEnabledControl>` component.
- Iterated on the header layout under `expectStableIntegrationCardLayout`'s
guard (`MAX_ICON_TOP_INSET_PX`) before landing on the final shape:
- First attempt (slider inline with the label row, `items-center`) pushed
the icon down to a measured 31px inset (label text wrapped to 2 lines
once squeezed by the slider pill) — rejected loosening the threshold;
fixed the root cause instead.
- Final shape (slider on its own row) reproduces the pre-existing
single-line title row exactly, measuring 19px — well under the original
22px budget. The budget in
`integrations-index-layout-helpers.ts` was **not** changed.
- Verified via a temporary manual backend instance +
`xd://browser` DOM/screenshot inspection before re-running the real e2e
spec each iteration; the temporary diagnostic spec file was deleted
before finishing.
- Added `apps/web/app/settings/integrations/page.test.tsx`: 4 tests (later
extended to include Task 04's row — see task-04) covering slider count,
initial checked state, label-click navigation, and slider-click
non-navigation.
- Updated `integrationCards()` in the e2e helper to locate cards via
`[data-testid^="integration-card-"]` directly instead of deriving them
from `a[href*="/integrations/"] > [data-slot="card"]` (no longer valid —
`Card` is not an anchor's child).
- Commands:
- `cd apps && pnpm --filter @kandev/web test -- app/settings/integrations/page` → `Test Files 1 passed (1)`, `Tests 3 passed (3)` (pre-Task-04 state).
- `cd apps/web && pnpm run typecheck` → clean.
- E2E (via a managed background process, `apps/backend/bin/kandev` +
`apps/web/dist` built locally with `make build-backend`/`make build-web`/
`make -C apps/backend e2e-plugin-package`):
`npx playwright test --config e2e/playwright.config.ts e2e/tests/integrations/integrations-index-layout.spec.ts` → `1 passed`.
- Files changed: the three files listed under "Files likely touched".
- Blockers/risks: none remaining. Risk noted and resolved: the initial
layout regression (icon inset) was caught by the existing e2e test exactly
as intended — the fix changed markup, not the test's tolerance.
@@ -0,0 +1,63 @@
---
id: "04-hide-disabled-setting"
title: "'Hide disabled integrations from left panel navigation' setting"
status: done
wave: 3
depends_on: ["03-index-page-sliders"]
plan: "plan.md"
spec: "../../specs/integrations/enable-disable-toggle.md"
---
# Task 04: "Hide disabled integrations from left panel navigation" setting
- **Acceptance:**
1. `useHideDisabledIntegrationsInNav()` exists, defaults to
`hideDisabled: false`, persists via `setHideDisabled`, broadcasts its
own sync event, and degrades to `false` (never throws) when
`localStorage` is unavailable.
2. `/settings/integrations` renders a new row below the integration cards
with the exact label "Hide disabled integrations from left panel
navigation", default off, using the shared draft/save-bar convention.
3. New locale keys are added and the pseudo-locale is regenerated via the
project's i18n tooling.
- **Verification:** `cd apps && pnpm --filter @kandev/web test -- hooks/domains/integrations/use-hide-disabled-integrations-in-nav app/settings/integrations/page` then `cd apps/web && pnpm run i18n:check && pnpm run i18n:ratchet`
- **Files likely touched:**
- `apps/web/hooks/domains/integrations/use-hide-disabled-integrations-in-nav.ts` (new)
- `apps/web/hooks/domains/integrations/use-hide-disabled-integrations-in-nav.test.ts` (new)
- `apps/web/app/settings/integrations/page.tsx`
- `apps/web/app/settings/integrations/page.test.tsx`
- `apps/web/src/locales/en/settings.json`, `apps/web/src/locales/pseudo/settings.json`
- **Dependencies:** `03-index-page-sliders`.
- **Parallelism:** sequential.
- **Inputs:** spec "What"/"Data model"/"Failure modes", plan Task 04
section, `use-integration-enabled.ts`'s hook shape,
`archive-confirmation-settings.tsx`'s row-UI pattern.
- **Output contract:** summary, files changed, exact test/i18n commands run
and their output, blockers/risks, task/plan status update.
## Results
- Added `useHideDisabledIntegrationsInNav()`: a standalone (non-per-integration)
`useSyncExternalStore` hook backed by `kandev:integrations:hideDisabledInNav:v1`,
broadcasting `kandev:integrations:hide-disabled-in-nav-changed`, defaulting
to `false` on missing key, unreadable literal, or a throwing `localStorage`.
- Added `HideDisabledIntegrationsSetting` to the index page: a plain
`<Switch>` + `<Label>` row (mirroring `archive-confirmation-settings.tsx`'s
layout) wired through `useDraftedIntegrationEnabled` for the shared
draft/save-bar behavior, rendered below the cards grid.
- Added locale keys `settings:hideDisabledIntegrationsFromNav` and
`settings:hideDisabledIntegrationsFromNavDescription`; regenerated the
pseudo-locale via `pnpm run i18n:pseudo` (not hand-edited).
- Extended `page.test.tsx` with a test asserting the new switch renders
`aria-checked="false"` by default and flips its own (drafted) state
immediately on click while leaving `localStorage` untouched until Save —
and updated the slider-count assertion from 7 to 8.
- Commands:
- `cd apps && pnpm --filter @kandev/web test -- use-hide-disabled-integrations-in-nav` → `Test Files 1 passed (1)`, `Tests 9 passed (9)`.
- `cd apps/web && pnpm run typecheck` → clean.
- `cd apps && pnpm --filter @kandev/web test -- app/settings/integrations/page` → `Test Files 1 passed (1)`, `Tests 4 passed (4)`.
- `cd apps/web && pnpm run i18n:pseudo` → regenerated pseudo locale.
- `cd apps/web && pnpm run i18n:check` → `✓ i18n keys OK` (908 pre-existing zh-cn advisory issues, unrelated to this change; no new orphans).
- `cd apps/web && pnpm run i18n:ratchet` → `✓ i18n new-code ratchet — 0 added + 7 modified file(s) clean`, `✓ guard allowlist intact`.
- Files changed: the five files/pairs listed under "Files likely touched".
- Blockers/risks: none.
@@ -0,0 +1,82 @@
---
id: "05-nav-availability"
title: "Decouple left-panel nav visibility from the enabled toggle"
status: done
wave: 4
depends_on: ["01-enabled-hooks", "04-hide-disabled-setting"]
plan: "plan.md"
spec: "../../specs/integrations/enable-disable-toggle.md"
---
# Task 05: Decouple left-panel nav visibility from the enabled toggle
- **Acceptance:**
1. `useNavAvailability()` returns `true` for a configured integration that
is disabled when `hideDisabled` is `false` (the default), and `false`
for the same integration when `hideDisabled` is `true`.
2. `useNavAvailability()` returns `false` for an unconfigured integration
regardless of `enabled`/`hideDisabled`.
3. `apps/web/hooks/use-nav-availability.test.ts` and
`apps/web/components/integrations/integrations-menu.test.ts` pass with
updated mocks, and cover both new cases from criteria 1-2 in a
table-driven test over the five nav-gated keys.
- **Verification:** `cd apps && pnpm --filter @kandev/web test -- hooks/use-nav-availability components/integrations/integrations-menu`
- **Files likely touched:**
- `apps/web/hooks/use-nav-availability.ts`
- `apps/web/hooks/use-nav-availability.test.ts`
- `apps/web/components/integrations/integrations-menu.test.ts`
- **Dependencies:** `01-enabled-hooks`, `04-hide-disabled-setting`.
- **Parallelism:** sequential.
- **Inputs:** spec Scenarios, plan Task 05 derivation formula,
`use-jira-availability.ts`'s/`use-linear-availability.ts`'s existing
`useJiraAuthed`/`useLinearAuthed` exports.
- **Output contract:** summary, files changed, exact test command run and
its output, blockers/risks, task/plan status update.
## Results
- `useNavAvailability` now computes, for each of the five nav-gated keys,
`configured && (!hideDisabled || enabled)`:
- `configured` reuses the exact pre-existing signals unchanged for
azure-devops (`useAzureDevOpsAvailable`), github
(`getGitHubIntegrationStatus(...).ready`), and gitlab
(`useGitLabAvailable`) — none of these three had an "enabled" toggle
before this feature, so their "configured" signal already meant exactly
what this task needs.
- `configured` for jira/linear switched from `useJiraAvailable`/
`useLinearAvailable` (the old combined enabled+authed signal) to the
already-existing, enabled-agnostic `useJiraAuthed`/`useLinearAuthed`.
- `enabled` comes from the five `useXEnabled()` hooks (three new from Task
01, two pre-existing: `useJiraEnabled`/`useLinearEnabled`).
- `hideDisabled` comes from Task 04's `useHideDisabledIntegrationsInNav()`.
- Confirmed `useJiraAvailable`/`useLinearAvailable` themselves were **not**
touched — every other consumer (import bars, Kanban external-link
buttons, task-top-bar buttons, `workspaces-group.tsx`'s settings tree)
keeps importing and using them exactly as before; only this file's
imports changed.
- Rewrote `use-nav-availability.test.ts`: renamed the Jira/Linear mocks to
`jiraAuthed`/`linearAuthed` (matching the new import), added a
controllable `useGitHubStatus` mock (previously hardcoded to
unconfigured) so GitHub's `configured` axis is also testable, added
enabled-hook mocks for all five keys (default `true`) and a `hideDisabled`
mock (default `false`), and replaced the single Jira-only decoupling test
with a `describe.each` table over all five `AvailabilityKey`s × 4 cases
each (unconfigured-hides-regardless, disabled-stays-visible-by-default,
disabled-hidden-when-hideDisabled-on, enabled-stays-visible-when-hideDisabled-on).
- Updated `integrations-menu.test.ts`: renamed its Jira/Linear mocks the
same way (`useJiraAvailable`→`useJiraAuthed`, param names
`jiraAvailable`/`linearAvailable`→`jiraConfigured`/`linearConfigured`),
and added fixed `enabled: true` / `hideDisabled: false` stub mocks for the
five new hooks `useNavAvailability` now transitively calls — this suite
never exercises the decoupling itself (that's `use-nav-availability.test.ts`'s
job), so every existing assertion keeps passing unchanged.
- Commands:
- `cd apps && pnpm --filter @kandev/web test -- hooks/use-nav-availability components/integrations/integrations-menu` → `Test Files 2 passed (2)`, `Tests 20 passed (20)` (26 after the later table-driven expansion — see below).
- `cd apps/web && pnpm run typecheck` → one error caught and fixed (a leftover incorrect `GitHubStatus` stub shape in the rewritten test file — corrected to match the real type's fields), then clean.
- Follow-up (post-review): expanded the single Jira-only decoupling test
into the five-key `describe.each` table described above. Rerun:
`Test Files 1 passed (1)`, `Tests 26 passed (26)` for
`hooks/use-nav-availability` alone.
- Files changed: the three files listed under "Files likely touched".
- Blockers/risks: none. Explicitly verified no other `useJiraAvailable`/
`useLinearAvailable` consumer was touched (see spec Out-of-scope).
@@ -0,0 +1,76 @@
---
id: "06-e2e"
title: "E2E coverage for index-page sliders and nav-hiding behavior"
status: done
wave: 5
depends_on: ["02-own-page-sliders", "03-index-page-sliders", "05-nav-availability"]
plan: "plan.md"
spec: "../../specs/integrations/enable-disable-toggle.md"
---
# Task 06: E2E coverage for index-page sliders and nav-hiding behavior
- **Acceptance:**
1. A new Playwright spec proves every one of the seven integration rows on
`/settings/integrations` has a working slider, toggling GitHub's
index-page slider updates GitHub's own settings-page slider to the same
state, and clicking a slider never navigates.
2. A new Playwright spec proves: with a configured/healthy, disabled
GitHub and "hide disabled" off (default), GitHub's sidebar nav entry is
visible; turning "hide disabled" on hides it without a reload; turning
GitHub back on reveals it again.
3. `apps/web/e2e/tests/integrations/integrations-index-layout.spec.ts`
still passes unmodified.
- **Verification:** `cd apps/web && pnpm install --frozen-lockfile && pnpm e2e -- integrations-index-enabled-toggle hide-disabled-integrations-nav integrations-index-layout`
- **Files likely touched:**
- `apps/web/e2e/tests/integrations/integrations-index-enabled-toggle.spec.ts` (new)
- `apps/web/e2e/tests/integrations/hide-disabled-integrations-nav.spec.ts` (new)
- **Dependencies:** `02-own-page-sliders`, `03-index-page-sliders`,
`05-nav-availability`.
- **Parallelism:** sequential (last task; exercises the full feature).
- **Inputs:** spec Scenarios, plan E2E Tests section,
`integrations-index-layout-helpers.ts`, `mobile-integrations-nav.spec.ts`'s
`apiClient.mockGitHubSetUser` fixture and visibility-check style.
- **Output contract:** summary, files changed, exact e2e command run and its
output, blockers/risks, task/plan status update, synchronize `plan.md`'s
Verification Results.
## Results
- `integrations-index-enabled-toggle.spec.ts`: navigates to
`/settings/integrations`, asserts all seven `#<slug>-enabled` switches are
visible, toggles GitHub's off, asserts the URL never left the index page,
saves via the shared floating save bar, then navigates to
`/settings/integrations/github` and asserts its own-page slider reflects
the same persisted "off" state.
- `hide-disabled-integrations-nav.spec.ts`: makes GitHub
configured/authenticated via `apiClient.mockGitHubSetUser`, disables
GitHub's slider and saves, navigates to `/tasks` (leaving the Settings
takeover, which replaces the sidebar with the Settings tree — confirmed by
inspecting a live DOM snapshot before writing this) and asserts the
sidebar's GitHub link is still visible (hide-disabled off, the default);
returns to Settings, turns "Hide disabled integrations from left panel
navigation" on and saves, navigates back to `/tasks`, asserts the GitHub
link is now hidden; re-enables GitHub and saves, navigates back to
`/tasks`, asserts the link reappears.
- This exposed a real bug during development, fixed by the corrected Task
03 markup: nesting the slider inline with the label made "Azure DevOps"
wrap to two lines under the layout spec's real browser measurements — the
fix landed in Task 03, and this task's runs are the confirming evidence
that it holds end-to-end.
- Environment note: running Playwright locally required building
prerequisites this sandbox does not have pre-built —
`make build-backend` (needed a Go 1.26 toolchain found at
`/tmp/go1.26/bin`, not on the default `PATH`), `make build-web`, and
`make -C apps/backend e2e-plugin-package` — each run once, then re-run
after every source change under test.
- Commands (via a managed background process so the tool-call timeout
couldn't truncate a multi-minute Playwright run):
`npx playwright test --config e2e/playwright.config.ts e2e/tests/integrations/integrations-index-enabled-toggle.spec.ts e2e/tests/integrations/hide-disabled-integrations-nav.spec.ts e2e/tests/integrations/integrations-index-layout.spec.ts`
→ `3 passed (30.5s)`.
- Files changed: the two new spec files listed above (Task 03 already
updated the layout spec's helper; this task only re-ran it, unmodified,
as the regression check — no fix was needed here).
- Blockers/risks: none. All background Playwright/Chromium processes and
the temporary manual backend instance used for diagnosis were terminated
after verification.
+1
View File
@@ -118,6 +118,7 @@ Per-workspace credentials and triage triggers for external services.
| [github-authentication](integrations/github-authentication.md) | draft |
| [gitlab-integration](gitlab-integration/spec.md) | shipped |
| [jira-status-filter](jira-status-filter/spec.md) | shipped |
| [enable-disable-toggle](integrations/enable-disable-toggle.md) | shipped |
## workspaces/ — workspace lifecycle
@@ -0,0 +1,224 @@
---
status: shipped
created: 2026-08-06
owner: platform
---
# Integration Enable/Disable Toggle & Nav Visibility
## Why
Every third-party integration (Azure DevOps, GitHub, GitLab, Jira, Linear,
Sentry) can be connected with credentials, but only Jira, Linear and Sentry
currently expose a user-facing on/off switch for their UI surfaces. Azure
DevOps, GitHub and GitLab have no such switch. Users who want to temporarily
silence an integration (e.g. while its credentials are being rotated, or
because a workspace doesn't use it) have no consistent, discoverable way to do
that across all six integrations, and no way to control whether a
disabled-but-still-configured integration continues to clutter the left panel
navigation. (Slack was retired from the core app to an external plugin after
this spec was written and is out of scope.)
## What
- Every integration (Azure DevOps, GitHub, GitLab, Jira, Linear, Sentry)
SHALL expose an enable/disable slider on its own settings page
(`/settings/integrations/<slug>`, and the per-workspace equivalent
`/settings/workspace/<id>/integrations/<slug>`). Jira, Linear and Sentry
already have this; Azure DevOps, GitHub and GitLab gain it.
- The same slider SHALL also appear on each integration's row/card on the
integrations index page (`/settings/integrations` and its per-workspace
equivalent), so a user can enable or disable any integration without
opening its detail page.
- Toggling the slider in either location SHALL keep both locations in sync
(same underlying per-integration enabled state, install-wide — not
per-workspace — consistent with the existing Jira/Linear/Sentry
toggle).
- The enabled state for Azure DevOps, GitHub and GitLab is purely a
presentation/navigation-visibility switch: it MUST NOT change whether their
existing PR/work-item/board/MR features function. This mirrors that those
three integrations have no existing UI surface gated on an "available"
concept beyond navigation and their own settings page banner.
- The integrations index page SHALL gain one new setting, **"Hide disabled
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.
- 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
keep gating on enabled-and-authed exactly as they do today, independent of
left-panel nav visibility.
- All new/changed toggles persist and sync the same way the existing
Jira/Linear/Sentry "enabled" toggle does: a `localStorage`-backed,
install-wide boolean, synced across browser tabs, with a manual-save
affordance consistent with the rest of the Settings UI (`data-settings-dirty`
+ the shared save bar) where the control lives on a settings page.
## Data model
No new persistent (backend/database) state. All state introduced by this
feature lives in the browser's `localStorage`, matching the existing pattern
in `hooks/domains/integrations/use-integration-enabled.ts`:
| Key | Type | Default | Notes |
|---|---|---|---|
| `kandev:azure-devops:enabled:v1` | boolean | `true` | New. Mirrors `kandev:jira:enabled:v1`. |
| `kandev:github:enabled:v1` | boolean | `true` | New. |
| `kandev:gitlab:enabled:v1` | boolean | `true` | New. |
| `kandev:integrations:hideDisabledInNav:v1` | boolean | `false` | New. Not per-integration; one flag for the whole nav-filtering behavior. |
Existing keys (`kandev:jira:enabled:v1`, `kandev:linear:enabled:v1`,
`kandev:sentry:enabled:v1`) are unchanged.
## API surface
No backend/HTTP/WS changes. This is entirely a frontend feature.
Frontend primitives (new):
- `hooks/domains/azure-devops/use-azure-devops-enabled.ts` — exports
`useAzureDevOpsEnabled()`, a one-line wrapper over
`useIntegrationEnabled(storageKey, legacyKeyPrefix, syncEvent)`, mirroring
`hooks/domains/jira/use-jira-enabled.ts`. No legacy key prefix existed
before, so the legacy-migration argument is inert (no prior keys to
migrate) but kept for signature symmetry.
- `hooks/domains/github/use-github-enabled.ts` — same shape, for GitHub.
- `hooks/domains/gitlab/use-gitlab-enabled.ts` — same shape, for GitLab.
- `hooks/domains/integrations/use-hide-disabled-integrations-in-nav.ts` —
exports `useHideDisabledIntegrationsInNav()` returning
`{ hideDisabled: boolean; setHideDisabled: (next: boolean) => void }`,
backed by `localStorage` key `kandev:integrations:hideDisabledInNav:v1`
(default `false`), using the same `useSyncExternalStore` +
`storage`-event + custom-event-broadcast shape as
`use-integration-enabled.ts` (custom event name
`kandev:integrations:hide-disabled-in-nav-changed`), so nav consumers
re-render immediately in the same tab, and other tabs update via the
browser `storage` event.
Existing primitives reused unchanged:
- `hooks/domains/integrations/use-integration-enabled.ts` (`useIntegrationEnabled`)
- `hooks/domains/jira/use-jira-enabled.ts`, `use-linear-enabled.ts`,
`use-sentry-enabled.ts`
- `hooks/domains/jira/use-jira-availability.ts`'s `useJiraAuthed` and
`hooks/domains/linear/use-linear-availability.ts`'s `useLinearAuthed` —
these already expose the "configured and healthy, independent of the
enabled toggle" signal and are the building block for decoupled nav
filtering.
- `components/integrations/use-drafted-integration-enabled.ts` +
`components/integrations/drafted-integration-enabled-control.tsx` — the
existing draft/save-wired slider component
(`<DraftedIntegrationEnabledControl id={slug} enabled={enabled} persist={setEnabled} />`),
reused as-is for Azure DevOps/GitHub/GitLab's own-page slider (new
`<AzureDevOpsEnabledControl>`, `<GitHubEnabledControl>`,
`<GitLabEnabledControl>` wrapper components, one per integration,
mirroring `components/jira/jira-enabled-control.tsx`) and reused again for
the index-page per-row sliders (all six integrations) and for the new
"hide disabled in nav" row.
Modified:
- `hooks/use-nav-availability.ts`'s `useNavAvailability()` — see Scenarios
below for the exact filtering contract. Its return shape
(`AvailabilityMap`) is unchanged; only how each key's boolean is derived
changes.
## Permissions
No change. The toggle is a per-browser-profile, install-wide UI preference
with no authorization dimension (same as the existing Jira/Linear/Sentry
toggle).
## Failure modes
- `localStorage` unavailable or throwing (private browsing, quota) — the
existing `useIntegrationEnabled` implementation already degrades to
`enabled: true` and swallows write errors; the new hooks reuse this
behavior. `useHideDisabledIntegrationsInNav` degrades to `hideDisabled:
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.
## Persistence guarantees
State lives only in `localStorage`; it is not part of any backend restart or
task-execution durability guarantee, matching the existing enabled-toggle
convention. It survives a browser restart but not a `localStorage` clear, and
is not synced across devices/browsers.
## Scenarios
- **GIVEN** GitHub has valid, healthy credentials and the "hide disabled"
setting is off (default), **WHEN** the user disables GitHub via either
slider (own page or index-page row), **THEN** GitHub's sidebar Integrations
entry and mobile-menu entry remain visible (still reachable), while the
toggle itself reads "Disabled" on both the index-page row and the GitHub
settings page.
- **GIVEN** GitHub has valid, healthy credentials and is disabled, **WHEN**
the user turns on "Hide disabled integrations from left panel navigation"
on the integrations index page, **THEN** GitHub's sidebar Integrations
entry and mobile-menu entry disappear immediately (no reload required, no
change to GitHub's health/configured status).
- **GIVEN** the "hide disabled" setting is on and GitHub is disabled and
hidden from the nav, **WHEN** the user re-enables GitHub (either slider),
**THEN** GitHub's sidebar entry reappears immediately.
- **GIVEN** the "hide disabled" setting is on, **WHEN** an integration is
enabled but not configured/healthy (e.g. no credentials saved), **THEN**
it stays hidden from the nav exactly as it does today — the new setting
never *reveals* an unconfigured integration.
- **GIVEN** Jira is disabled and the "hide disabled" setting is on, **WHEN**
the user opens the Jira import popover on a task (an existing surface
gated on Jira's combined enabled+authed "available" signal), **THEN** the
import popover remains hidden exactly as it does today when Jira is
disabled — the new setting does not change that unrelated gate.
- **GIVEN** the integrations index page, **WHEN** it renders, **THEN** every
one of the six integration rows/cards shows its own enable/disable
slider reflecting that integration's current stored state, and toggling a
slider does not navigate to that integration's detail page.
- **GIVEN** the integrations index page and the Azure DevOps settings page
open in two browser tabs, **WHEN** the user disables Azure DevOps on the
index page in tab A, **THEN** tab B's Azure DevOps settings-page slider
updates to "Disabled" after the next `storage` event (existing
cross-tab-sync behavior, unchanged mechanism, extended to the three new
integrations).
## Out of scope
- No backend/database changes; the toggle and the nav-filter setting are
both pure frontend, `localStorage`-backed preferences.
- No change to Azure DevOps/GitHub/GitLab's PR, work-item, board, or MR
features when disabled — the new toggle for these three integrations only
controls settings-page/index-page slider state and left-panel nav
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
`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.
- No new command-palette entries or keyboard shortcuts for the new toggles.
## Open questions
(none)