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