19 KiB
spec, created, status
| spec | created | status |
|---|---|---|
| docs/specs/integrations/requirements/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.tsSTORAGE_KEY = "kandev:azure-devops:enabled:v1"LEGACY_KEY_PREFIX = "kandev:azure-devops:enabled:"(no real legacy data; prefix kept only for signature symmetry withuseIntegrationEnabled, 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, exportsuseGitHubEnabled()apps/web/hooks/domains/gitlab/use-gitlab-enabled.ts— same shape,kandev:gitlab:enabled:v1/kandev:gitlab:enabled:/kandev:gitlab:enabled-changed, exportsuseGitLabEnabled()
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} />usinguseAzureDevOpsEnabled().apps/web/components/github/github-enabled-control.tsx— same shape for GitHub,id="github", usinguseGitHubEnabled().apps/web/components/gitlab/gitlab-enabled-control.tsx— same shape for GitLab,id="gitlab", usinguseGitLabEnabled().
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) getsaction={<AzureDevOpsEnabledControl />}.apps/web/components/github/github-settings.tsx—GitHubConnectionSection's<SettingsSection>(around line 263) getsaction={<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) getsaction={<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 mocksuseJiraAvailable/useLinearAvailable; change mocks touseJiraAuthed/useLinearAuthed, add mocks for the three newuseXEnabledhooks,useJiraEnabled,useLinearEnabled, anduseHideDisabledIntegrationsInNav. Add new test cases per the Tests section below.apps/web/components/integrations/integrations-menu.test.ts— currently mocksuseGitLabAvailable/useJiraAvailable/useLinearAvailableat the hook level consumed transitively throughuseNavAvailability; update its mocks the same way (switch Jira/Linear mocks to theAuthedhooks; add the enabled/hide-disabled mocks it now transitively needs, defaultinghideDisabledtofalseso existing assertions keep passing unchanged).
Tests
- What:
useAzureDevOpsEnabled/useGitHubEnabled/useGitLabEnableddefault totrue, persist writes, and broadcast their sync event. File: one*.test.tsper hook beside it (e.g.apps/web/hooks/domains/azure-devops/use-azure-devops-enabled.test.ts). How: unit test withrenderHook, mirroringapps/web/hooks/domains/jira/use-jira-enabled.ts's existing test if one exists (check first; if the genericuseIntegrationEnabledalready has full coverage inuse-integration-enabled.test.ts, a thin smoke test per wrapper — default true,setEnabledround-trips — is sufficient; do not duplicate the generic hook's exhaustive cases). - What:
useHideDisabledIntegrationsInNavdefaults tofalse, persists, broadcasts a same-tab sync event, and degrades tofalsewhenlocalStoragethrows. File:apps/web/hooks/domains/integrations/use-hide-disabled-integrations-in-nav.test.ts. How: unit test withrenderHook, mirroringuse-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 eachuseXEnabledhook, render the page, assert seven switches present with correct initialchecked, click one and assert itspersist/setEnabledwas called andwindow.location/router navigation was not triggered. - What: the new "hide disabled" row renders default-off, and toggling it
calls
setHideDisabledonly after the settings save action fires (drafted, not immediate) — consistent withuseDraftedIntegrationEnabled's existing contract. File: samepage.test.tsxas above. How: component test, mirroringapps/web/components/integrations/use-drafted-integration-enabled.test.tsx's assertions. - What:
useNavAvailabilityreturnstruefor a configured-but-disabled integration whenhideDisabledisfalse(default), andfalsewhenhideDisabledistrue; an unconfigured integration staysfalseregardless ofhideDisabled/enabled. File:apps/web/hooks/use-nav-availability.test.ts(extend existing describe block). How: unit test withrenderHook, table-driven over the five nav-gated keys crossed with{configured, enabled, hideDisabled}per the spec's Scenarios section. - What:
AzureDevOpsEnabledControl/GitHubEnabledControl/GitLabEnabledControlrender<DraftedIntegrationEnabledControl>wired to the right hook. File: one*.test.tsxper control, or omit ifdrafted-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; sidebargetByRole("link", {name: "GitHub"})visible/hidden assertions before/after each toggle, matching the visibility-check style already used inapps/web/e2e/tests/integrations/mobile-integrations-nav.spec.ts. - Update
apps/web/e2e/tests/integrations/integrations-index-layout.spec.tsonly 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
actioncontrols 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-existingintegrations-index-layout.spec.ts—3 passed. - Scoped
eslint --max-warnings 0over every changed.ts/.tsxfile — clean (onesonarjs/no-duplicate-stringwarning in the new page test was fixed by extractingariaChecked()/ARIA_CHECKED_TRUE/ARIA_CHECKED_FALSEhelpers). make fmtwas run repo-wide; the only out-of-scope reformat it produced (pre-existing gofmt drift inapps/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)