diff --git a/apps/web/app/settings/integrations/github/page.tsx b/apps/web/app/settings/integrations/github/page.tsx index 5dc1ab7470..9ad8b188a5 100644 --- a/apps/web/app/settings/integrations/github/page.tsx +++ b/apps/web/app/settings/integrations/github/page.tsx @@ -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 ( - <> - - - - ); +export default function IntegrationsGitHubPage({ workspaceId }: IntegrationsGitHubPageProps = {}) { + return ; } diff --git a/apps/web/app/settings/integrations/page.test.tsx b/apps/web/app/settings/integrations/page.test.tsx new file mode 100644 index 0000000000..4b286e3125 --- /dev/null +++ b/apps/web/app/settings/integrations/page.test.tsx @@ -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( + + + , + ); +} + +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); + }); +}); diff --git a/apps/web/app/settings/integrations/page.tsx b/apps/web/app/settings/integrations/page.tsx index 32f952e6b3..f0b58241f3 100644 --- a/apps/web/app/settings/integrations/page.tsx +++ b/apps/web/app/settings/integrations/page.tsx @@ -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 ( -
-
-

{t("common:integrations")}

-

- {t("settings:connectKandevToThirdPartyServices")} -

-
-
- {INTEGRATIONS.map(({ slug, label, descriptionKey, Icon }) => { - const href = `${rootHref}/${slug}`; - return ( - - - -
- - {label} -
-

- {t(descriptionKey, { trigger: "!kandev" })} -

-
-
- - ); - })} -
-
- ); +export default function IntegrationsIndexPageRoute() { + return ; } diff --git a/apps/web/components/app-sidebar/sections/settings/settings-tree.tsx b/apps/web/components/app-sidebar/sections/settings/settings-tree.tsx index 634a037dbf..4aa3e53c7d 100644 --- a/apps/web/components/app-sidebar/sections/settings/settings-tree.tsx +++ b/apps/web/components/app-sidebar/sections/settings/settings-tree.tsx @@ -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} /> + + ); +} diff --git a/apps/web/components/azure-devops/azure-devops-settings.test.tsx b/apps/web/components/azure-devops/azure-devops-settings.test.tsx index 1739865aca..1f62b8a9ce 100644 --- a/apps/web/components/azure-devops/azure-devops-settings.test.tsx +++ b/apps/web/components/azure-devops/azure-devops-settings.test.tsx @@ -90,7 +90,11 @@ afterEach(cleanup); describe("AzureDevOpsConnectionSection", () => { it("links to the organization PAT page and explains the required read scopes", async () => { - render(); + render( + + + , + ); 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(); + render( + + + , + ); 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(); + render( + + + , + ); 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(); + render( + + + , + ); const organization = await screen.findByTestId("azure-devops-organization"); await waitFor(() => expect((organization as HTMLInputElement).value).toBe(OLD_ORGANIZATION_URL), diff --git a/apps/web/components/azure-devops/azure-devops-settings.tsx b/apps/web/components/azure-devops/azure-devops-settings.tsx index 0ff8719612..4d5cb33ea3 100644 --- a/apps/web/components/azure-devops/azure-devops-settings.tsx +++ b/apps/web/components/azure-devops/azure-devops-settings.tsx @@ -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={} title={t("azuredevops:integrationTitle")} description={t("azuredevops:integrationDescription")} + action={} > @@ -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 ( diff --git a/apps/web/components/github/github-enabled-control.tsx b/apps/web/components/github/github-enabled-control.tsx new file mode 100644 index 0000000000..8fa063284f --- /dev/null +++ b/apps/web/components/github/github-enabled-control.tsx @@ -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 ( + + ); +} diff --git a/apps/web/components/github/github-settings.tsx b/apps/web/components/github/github-settings.tsx index 22b34a8b0f..dff279b42e 100644 --- a/apps/web/components/github/github-settings.tsx +++ b/apps/web/components/github/github-settings.tsx @@ -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 ( <> -
-

- - {t("github:githubIntegration")} -

-

- {t("github:chooseTheAutomationAndPersonalIdentities")} -

-
- - } + title={t("github:githubIntegration")} + titleTestId="github-integration-heading" + description={t("github:chooseTheAutomationAndPersonalIdentities")} + action={} > - + + + + + + + +
@@ -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 ( diff --git a/apps/web/components/gitlab/gitlab-enabled-control.tsx b/apps/web/components/gitlab/gitlab-enabled-control.tsx new file mode 100644 index 0000000000..14ca9a38fa --- /dev/null +++ b/apps/web/components/gitlab/gitlab-enabled-control.tsx @@ -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 ( + + ); +} diff --git a/apps/web/components/gitlab/gitlab-settings.tsx b/apps/web/components/gitlab/gitlab-settings.tsx index 1831172a57..3b52cb7b13 100644 --- a/apps/web/components/gitlab/gitlab-settings.tsx +++ b/apps/web/components/gitlab/gitlab-settings.tsx @@ -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 ( @@ -435,15 +438,18 @@ function GitLabConnectionCard(props: ConnectionCardProps) { description={t("gitlab:connectAGitlabAccountSoKandev")} icon={} action={ - +
+ + +
} > diff --git a/apps/web/components/integrations/drafted-integration-enabled-control.test.tsx b/apps/web/components/integrations/drafted-integration-enabled-control.test.tsx new file mode 100644 index 0000000000..a11d8d8706 --- /dev/null +++ b/apps/web/components/integrations/drafted-integration-enabled-control.test.tsx @@ -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( + + + , + ); + + 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( + + + , + ); + + 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( + + + , + ); + + 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")); + }); +}); diff --git a/apps/web/components/integrations/drafted-integration-enabled-control.tsx b/apps/web/components/integrations/drafted-integration-enabled-control.tsx index 49507a9efd..d439751ebd 100644 --- a/apps/web/components/integrations/drafted-integration-enabled-control.tsx +++ b/apps/web/components/integrations/drafted-integration-enabled-control.tsx @@ -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; + /** 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 ( -
- - -
+ onCheckedChange={draft.setEnabled} + aria-label={t("integrations:enableIntegrationNamed", { name })} + className="cursor-pointer" + /> ); } diff --git a/apps/web/components/integrations/integrations-index-page.tsx b/apps/web/components/integrations/integrations-index-page.tsx new file mode 100644 index 0000000000..24731d5b43 --- /dev/null +++ b/apps/web/components/integrations/integrations-index-page.tsx @@ -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 = { + "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 ( +
+
+ +

+ {t("settings:hideDisabledIntegrationsFromNavDescription")} +

+
+ +
+ ); +} + +/** `/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 ( +
+
+

{t("common:integrations")}

+

+ {t("settings:connectKandevToThirdPartyServices")} +

+
+
+ {INTEGRATIONS.map(({ slug, label, descriptionKey, Icon }) => { + const href = `${rootHref}/${slug}`; + const EnabledControl = ENABLED_CONTROL_BY_SLUG[slug]; + return ( + + +
+ + + {label} + +
+ +
+
+ + {t(descriptionKey)} + +
+
+ ); + })} +
+ +
+ ); +} diff --git a/apps/web/components/integrations/integrations-menu.test.ts b/apps/web/components/integrations/integrations-menu.test.ts index 8f39e360e8..f5bd385ef7 100644 --- a/apps/web/components/integrations/integrations-menu.test.ts +++ b/apps/web/components/integrations/integrations-menu.test.ts @@ -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 { 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[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(); diff --git a/apps/web/components/jira/jira-enabled-control.tsx b/apps/web/components/jira/jira-enabled-control.tsx index 92fe6927e7..5d90e09268 100644 --- a/apps/web/components/jira/jira-enabled-control.tsx +++ b/apps/web/components/jira/jira-enabled-control.tsx @@ -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 ; + return ( + + ); } diff --git a/apps/web/components/linear/linear-enabled-control.tsx b/apps/web/components/linear/linear-enabled-control.tsx new file mode 100644 index 0000000000..6cc0321ef7 --- /dev/null +++ b/apps/web/components/linear/linear-enabled-control.tsx @@ -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 ( + + ); +} diff --git a/apps/web/components/linear/linear-settings.tsx b/apps/web/components/linear/linear-settings.tsx index daed66a966..d1143a00dc 100644 --- a/apps/web/components/linear/linear-settings.tsx +++ b/apps/web/components/linear/linear-settings.tsx @@ -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 ; -} - +/** 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={} title={t("linear:linearIntegration")} description={t("linear:linearIntegrationDescription")} - action={} + action={} > @@ -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 (
diff --git a/apps/web/components/sentry/sentry-enabled-control.tsx b/apps/web/components/sentry/sentry-enabled-control.tsx new file mode 100644 index 0000000000..6f01879448 --- /dev/null +++ b/apps/web/components/sentry/sentry-enabled-control.tsx @@ -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 ( + + ); +} diff --git a/apps/web/components/sentry/sentry-settings.tsx b/apps/web/components/sentry/sentry-settings.tsx index d03c54e609..8ed7bbaca7 100644 --- a/apps/web/components/sentry/sentry-settings.tsx +++ b/apps/web/components/sentry/sentry-settings.tsx @@ -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 ; -} - // 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) { ); } +/** 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={} title={t("sentry:sentryIntegration")} description={t("sentry:sentryIntegrationDescription")} - action={} + action={} > @@ -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 (
diff --git a/apps/web/components/settings/settings-section.tsx b/apps/web/components/settings/settings-section.tsx index f796bf2505..0fefcdf214 100644 --- a/apps/web/components/settings/settings-section.tsx +++ b/apps/web/components/settings/settings-section.tsx @@ -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({
-

+

{icon} {title}

diff --git a/apps/web/e2e/tests/integrations/hide-disabled-integrations-nav.spec.ts b/apps/web/e2e/tests/integrations/hide-disabled-integrations-nav.spec.ts new file mode 100644 index 0000000000..ce19433109 --- /dev/null +++ b/apps/web/e2e/tests/integrations/hide-disabled-integrations-nav.spec.ts @@ -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 }); + }); +}); diff --git a/apps/web/e2e/tests/integrations/integrations-index-enabled-toggle.spec.ts b/apps/web/e2e/tests/integrations/integrations-index-enabled-toggle.spec.ts new file mode 100644 index 0000000000..f0e5407ac5 --- /dev/null +++ b/apps/web/e2e/tests/integrations/integrations-index-enabled-toggle.spec.ts @@ -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(); + }); +}); diff --git a/apps/web/e2e/tests/integrations/integrations-index-layout-helpers.ts b/apps/web/e2e/tests/integrations/integrations-index-layout-helpers.ts index 96915a741b..14e16c96e9 100644 --- a/apps/web/e2e/tests/integrations/integrations-index-layout-helpers.ts +++ b/apps/web/e2e/tests/integrations/integrations-index-layout-helpers.ts @@ -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 { - 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)); } diff --git a/apps/web/e2e/tests/settings/mobile-github-integration-layout.spec.ts b/apps/web/e2e/tests/settings/mobile-github-integration-layout.spec.ts new file mode 100644 index 0000000000..cb6d147886 --- /dev/null +++ b/apps/web/e2e/tests/settings/mobile-github-integration-layout.spec.ts @@ -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); + }); +}); diff --git a/apps/web/e2e/tests/settings/mobile-settings-sidebar.spec.ts b/apps/web/e2e/tests/settings/mobile-settings-sidebar.spec.ts index 9175c9b6eb..357aedb967 100644 --- a/apps/web/e2e/tests/settings/mobile-settings-sidebar.spec.ts +++ b/apps/web/e2e/tests/settings/mobile-settings-sidebar.spec.ts @@ -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 ({ diff --git a/apps/web/e2e/tests/settings/settings-gear-only.spec.ts b/apps/web/e2e/tests/settings/settings-gear-only.spec.ts index 0cfd87e049..cfa4ab6012 100644 --- a/apps/web/e2e/tests/settings/settings-gear-only.spec.ts +++ b/apps/web/e2e/tests/settings/settings-gear-only.spec.ts @@ -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(); diff --git a/apps/web/hooks/domains/azure-devops/use-azure-devops-enabled.test.ts b/apps/web/hooks/domains/azure-devops/use-azure-devops-enabled.test.ts new file mode 100644 index 0000000000..fdd839e4a0 --- /dev/null +++ b/apps/web/hooks/domains/azure-devops/use-azure-devops-enabled.test.ts @@ -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(); + }); +}); diff --git a/apps/web/hooks/domains/azure-devops/use-azure-devops-enabled.ts b/apps/web/hooks/domains/azure-devops/use-azure-devops-enabled.ts new file mode 100644 index 0000000000..92e36207aa --- /dev/null +++ b/apps/web/hooks/domains/azure-devops/use-azure-devops-enabled.ts @@ -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); +} diff --git a/apps/web/hooks/domains/github/use-github-enabled.test.ts b/apps/web/hooks/domains/github/use-github-enabled.test.ts new file mode 100644 index 0000000000..481fe8f81f --- /dev/null +++ b/apps/web/hooks/domains/github/use-github-enabled.test.ts @@ -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(); + }); +}); diff --git a/apps/web/hooks/domains/github/use-github-enabled.ts b/apps/web/hooks/domains/github/use-github-enabled.ts new file mode 100644 index 0000000000..6a0754abe1 --- /dev/null +++ b/apps/web/hooks/domains/github/use-github-enabled.ts @@ -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); +} diff --git a/apps/web/hooks/domains/gitlab/use-gitlab-enabled.test.ts b/apps/web/hooks/domains/gitlab/use-gitlab-enabled.test.ts new file mode 100644 index 0000000000..e425afc734 --- /dev/null +++ b/apps/web/hooks/domains/gitlab/use-gitlab-enabled.test.ts @@ -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(); + }); +}); diff --git a/apps/web/hooks/domains/gitlab/use-gitlab-enabled.ts b/apps/web/hooks/domains/gitlab/use-gitlab-enabled.ts new file mode 100644 index 0000000000..253a950db4 --- /dev/null +++ b/apps/web/hooks/domains/gitlab/use-gitlab-enabled.ts @@ -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); +} diff --git a/apps/web/hooks/domains/integrations/local-storage-mock.test-helpers.ts b/apps/web/hooks/domains/integrations/local-storage-mock.test-helpers.ts new file mode 100644 index 0000000000..4d18081cad --- /dev/null +++ b/apps/web/hooks/domains/integrations/local-storage-mock.test-helpers.ts @@ -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(); + 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, + }; +} diff --git a/apps/web/hooks/domains/integrations/use-hide-disabled-integrations-in-nav.test.ts b/apps/web/hooks/domains/integrations/use-hide-disabled-integrations-in-nav.test.ts new file mode 100644 index 0000000000..a350fc2415 --- /dev/null +++ b/apps/web/hooks/domains/integrations/use-hide-disabled-integrations-in-nav.test.ts @@ -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; + } + }); +}); diff --git a/apps/web/hooks/domains/integrations/use-hide-disabled-integrations-in-nav.ts b/apps/web/hooks/domains/integrations/use-hide-disabled-integrations-in-nav.ts new file mode 100644 index 0000000000..ede0578a6e --- /dev/null +++ b/apps/web/hooks/domains/integrations/use-hide-disabled-integrations-in-nav.ts @@ -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 }; +} diff --git a/apps/web/hooks/use-nav-availability.test.ts b/apps/web/hooks/use-nav-availability.test.ts index c8f7aadb79..2da345b10c 100644 --- a/apps/web/hooks/use-nav-availability.test.ts +++ b/apps/web/hooks/use-nav-availability.test.ts @@ -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 { 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); + }); + }); }); diff --git a/apps/web/hooks/use-nav-availability.ts b/apps/web/hooks/use-nav-availability.ts index a08208869f..7306733cdb 100644 --- a/apps/web/hooks/use-nav-availability.ts +++ b/apps/web/hooks/use-nav-availability.ts @@ -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), }; } diff --git a/apps/web/src/locales/en/integrations.json b/apps/web/src/locales/en/integrations.json index 6209bcb5b1..e139868f88 100644 --- a/apps/web/src/locales/en/integrations.json +++ b/apps/web/src/locales/en/integrations.json @@ -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}} file changed", diff --git a/apps/web/src/locales/en/settings.json b/apps/web/src/locales/en/settings.json index 041acaa578..59621bdb93 100644 --- a/apps/web/src/locales/en/settings.json +++ b/apps/web/src/locales/en/settings.json @@ -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", diff --git a/apps/web/src/locales/pseudo/integrations.json b/apps/web/src/locales/pseudo/integrations.json index 7d7df3fe9f..6f90debf18 100644 --- a/apps/web/src/locales/pseudo/integrations.json +++ b/apps/web/src/locales/pseudo/integrations.json @@ -40,9 +40,8 @@ "deleteSavedQuery": "Ďēĺēţē śàvēď qũēŕŷ", "describeYourChanges": "Ďēśćŕĩƀē ŷōũŕ ćĥàńĝēś...", "description": "Ďēśćŕĩƥţĩōń", - "disabled": "Ďĩśàƀĺēď", "draftCreated": "Ďŕàƒţ {{shortName}} ćŕēàţēď", - "enabled": "Ēńàƀĺēď", + "enableIntegrationNamed": "Ēńàƀĺē {{name}}", "enterCommitMessage": "Ēńţēŕ ćōḿḿĩţ ḿēśśàĝē...", "failedToCopyConfig": "Ƒàĩĺēď ţō ćōƥŷ ćōńƒĩĝ: {{err}}", "filesChanged_one": "<0>{{count}} ƒĩĺē ćĥàńĝēď", diff --git a/apps/web/src/locales/pseudo/settings.json b/apps/web/src/locales/pseudo/settings.json index e6173878e0..9601bd184c 100644 --- a/apps/web/src/locales/pseudo/settings.json +++ b/apps/web/src/locales/pseudo/settings.json @@ -307,6 +307,8 @@ "integrationDescriptionJira": "Àţĺàśśĩàń Ćĺōũď ćŕēďēńţĩàĺś àńď ĴQĹ ĩśśũē ŵàţćĥēŕś.", "integrationDescriptionLinear": "Ƥēŕśōńàĺ ÀƤĨ ķēŷ àńď ţēàḿ ďēƒàũĺţś.", "integrationDescriptionSentry": "Àũţĥ ţōķēń, ōŕĝ/ƥŕōĵēćţ ďēƒàũĺţś, àńď ĩśśũē ƀŕōŵśĩńĝ.", + "hideDisabledIntegrationsFromNav": "Ĥĩďē ďĩśàƀĺēď ĩńţēĝŕàţĩōńś ƒŕōḿ ĺēƒţ ƥàńēĺ ńàvĩĝàţĩōń", + "hideDisabledIntegrationsFromNavDescription": "Ŵĥēń ōń, à ďĩśàƀĺēď ĩńţēĝŕàţĩōń ĩś ŕēḿōvēď ƒŕōḿ ţĥē śĩďēƀàŕ àńď ḿōƀĩĺē ḿēńũ ēvēń ĩƒ ĩţ'ś śţĩĺĺ ćōńƒĩĝũŕēď.", "invalidJson": "Ĩńvàĺĩď ĴŚŌŃ", "keyboardShortcuts": "Ķēŷƀōàŕď Śĥōŕţćũţś", "language": "Ĺàńĝũàĝē", diff --git a/apps/web/src/locales/pt-pt/integrations.json b/apps/web/src/locales/pt-pt/integrations.json index 84a0a0cf64..b2a612aedc 100644 --- a/apps/web/src/locales/pt-pt/integrations.json +++ b/apps/web/src/locales/pt-pt/integrations.json @@ -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}} ficheiro alterado", diff --git a/apps/web/src/settings-routes.test.ts b/apps/web/src/settings-routes.test.ts index 43d3b76d6f..959c893185 100644 --- a/apps/web/src/settings-routes.test.ts +++ b/apps/web/src/settings-routes.test.ts @@ -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", diff --git a/apps/web/src/settings-routes.tsx b/apps/web/src/settings-routes.tsx index cd3b57eda2..0c3d3e07f1 100644 --- a/apps/web/src/settings-routes.tsx +++ b/apps/web/src/settings-routes.tsx @@ -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 ; + return workspaceId ? ( + + ) : ( + + ); case "azure-devops": return ; case "github": - return ; + return ; case "gitlab": return ; case "jira": diff --git a/docs/plans/integrations-enable-disable-toggle/plan.md b/docs/plans/integrations-enable-disable-toggle/plan.md new file mode 100644 index 0000000000..986bf7dedb --- /dev/null +++ b/docs/plans/integrations-enable-disable-toggle/plan.md @@ -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 `` 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 `` (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 + `` + 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 `` 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 `` (around line 489) gets + `action={}`. +- `apps/web/components/github/github-settings.tsx` — + `GitHubConnectionSection`'s `` (around line 263) gets + `action={}`. +- `apps/web/components/gitlab/gitlab-settings.tsx` — + `GitLabConnectionSection`/`GitLabConnectionCard`'s top `` + (around line 487–526; confirm exact section in-task, since the file was only + partially read during planning) gets + `action={}`. + +### Task 03 — Per-row slider on the integrations index page + +File: `apps/web/app/settings/integrations/page.tsx`. + +Current structure wraps the whole `` in a `` 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 `` inside that anchor is invalid DOM (interactive-in- +interactive) and fragile for click-through. Restructure so the `` is +the outer element (not itself inside an ``), 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 + + +
+ + + {label} + + +
+ + {t(descriptionKey, { trigger: "!kandev" })} + +
+
+``` + +`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 +``. +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 `` 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 ``, 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 `` 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 ( +
+
+ +

+ {t("settings:hideDisabledIntegrationsFromNavDescription")} +

+
+ +
+ ); +} +``` + +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 `` 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) diff --git a/docs/plans/integrations-enable-disable-toggle/task-01-enabled-hooks.md b/docs/plans/integrations-enable-disable-toggle/task-01-enabled-hooks.md new file mode 100644 index 0000000000..85abaadf96 --- /dev/null +++ b/docs/plans/integrations-enable-disable-toggle/task-01-enabled-hooks.md @@ -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. diff --git a/docs/plans/integrations-enable-disable-toggle/task-02-own-page-sliders.md b/docs/plans/integrations-enable-disable-toggle/task-02-own-page-sliders.md new file mode 100644 index 0000000000..b7a251757e --- /dev/null +++ b/docs/plans/integrations-enable-disable-toggle/task-02-own-page-sliders.md @@ -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 ``), + reflecting and persisting that integration's `useXEnabled()` state, + visually and behaviorally identical to Jira's existing + `` (draft + shared save bar, `data-settings-dirty` + wiring via `useDraftedIntegrationEnabled`). + 2. A component test per new control confirms it renders + `` 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={}`). +- **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 + `` wired to its Task 01 hook. +- Wired each into its integration's top ``: + `AzureDevOpsConnectionSection`, `GitHubConnectionSection`, + `GitLabConnectionCard` (GitLab's `action` already held a "Refresh" button; + combined both into one `
`). +- 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 ``. +- 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. diff --git a/docs/plans/integrations-enable-disable-toggle/task-03-index-page-sliders.md b/docs/plans/integrations-enable-disable-toggle/task-03-index-page-sliders.md new file mode 100644 index 0000000000..26e88add76 --- /dev/null +++ b/docs/plans/integrations-enable-disable-toggle/task-03-index-page-sliders.md @@ -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 `` — 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 ``); 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 `` inside an `` + (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` + mapping each slug to its Task 01/02 `` 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. diff --git a/docs/plans/integrations-enable-disable-toggle/task-04-hide-disabled-setting.md b/docs/plans/integrations-enable-disable-toggle/task-04-hide-disabled-setting.md new file mode 100644 index 0000000000..edb6860cb8 --- /dev/null +++ b/docs/plans/integrations-enable-disable-toggle/task-04-hide-disabled-setting.md @@ -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 + `` + `