Files
kandev/docs/plans/integrations-enable-disable-toggle/plan.md
T
2026-08-07 19:01:07 +01:00

19 KiB
Raw Blame History

spec, created, status
spec created status
docs/specs/integrations/enable-disable-toggle.md 2026-08-06 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:

<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

"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 SettingsCards):

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:

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

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)