feat(settings): improve workspace sidebar navigation (#1643)
This commit is contained in:
@@ -2,7 +2,13 @@ import { GitHubIntegrationPage } from "@/components/github/github-settings";
|
||||
import { StateHydrator } from "@/components/state-hydrator";
|
||||
import { fetchGitHubStatus } from "@/lib/api/domains/github-api";
|
||||
|
||||
export default async function IntegrationsGitHubPage() {
|
||||
type IntegrationsGitHubPageProps = {
|
||||
workspaceId?: string;
|
||||
};
|
||||
|
||||
export default async function IntegrationsGitHubPage({
|
||||
workspaceId,
|
||||
}: IntegrationsGitHubPageProps = {}) {
|
||||
const status = await fetchGitHubStatus({ cache: "no-store" }).catch(() => null);
|
||||
const initialState = status
|
||||
? {
|
||||
@@ -16,7 +22,7 @@ export default async function IntegrationsGitHubPage() {
|
||||
return (
|
||||
<>
|
||||
<StateHydrator initialState={initialState} />
|
||||
<GitHubIntegrationPage />
|
||||
<GitHubIntegrationPage workspaceId={workspaceId} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { GitLabIntegrationPage } from "@/components/gitlab/gitlab-settings";
|
||||
|
||||
export default function IntegrationsGitLabPage() {
|
||||
return <GitLabIntegrationPage />;
|
||||
type IntegrationsGitLabPageProps = {
|
||||
workspaceId?: string;
|
||||
};
|
||||
|
||||
export default function IntegrationsGitLabPage({ workspaceId }: IntegrationsGitLabPageProps = {}) {
|
||||
return <GitLabIntegrationPage workspaceId={workspaceId} />;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { JiraIntegrationPage } from "@/components/jira/jira-settings";
|
||||
|
||||
export default function IntegrationsJiraPage() {
|
||||
return <JiraIntegrationPage />;
|
||||
type IntegrationsJiraPageProps = {
|
||||
workspaceId?: string;
|
||||
};
|
||||
|
||||
export default function IntegrationsJiraPage({ workspaceId }: IntegrationsJiraPageProps = {}) {
|
||||
return <JiraIntegrationPage workspaceId={workspaceId} />;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { LinearIntegrationPage } from "@/components/linear/linear-settings";
|
||||
|
||||
export default function IntegrationsLinearPage() {
|
||||
return <LinearIntegrationPage />;
|
||||
type IntegrationsLinearPageProps = {
|
||||
workspaceId?: string;
|
||||
};
|
||||
|
||||
export default function IntegrationsLinearPage({ workspaceId }: IntegrationsLinearPageProps = {}) {
|
||||
return <LinearIntegrationPage workspaceId={workspaceId} />;
|
||||
}
|
||||
|
||||
@@ -11,44 +11,52 @@ import { Card, CardContent } from "@kandev/ui/card";
|
||||
|
||||
const INTEGRATIONS = [
|
||||
{
|
||||
href: "/settings/integrations/github",
|
||||
slug: "github",
|
||||
label: "GitHub",
|
||||
description: "PR review queues, issue watchers, and OAuth credentials.",
|
||||
Icon: IconBrandGithub,
|
||||
},
|
||||
{
|
||||
href: "/settings/integrations/gitlab",
|
||||
slug: "gitlab",
|
||||
label: "GitLab",
|
||||
description: "Merge request creation, discussion replies, and self-managed hosts.",
|
||||
Icon: IconBrandGitlab,
|
||||
},
|
||||
{
|
||||
href: "/settings/integrations/jira",
|
||||
slug: "jira",
|
||||
label: "Jira",
|
||||
description: "Atlassian Cloud credentials and JQL issue watchers.",
|
||||
Icon: IconTicket,
|
||||
},
|
||||
{
|
||||
href: "/settings/integrations/linear",
|
||||
slug: "linear",
|
||||
label: "Linear",
|
||||
description: "Personal API key and team defaults.",
|
||||
Icon: IconHexagon,
|
||||
},
|
||||
{
|
||||
href: "/settings/integrations/sentry",
|
||||
slug: "sentry",
|
||||
label: "Sentry",
|
||||
description: "Auth token, org/project defaults, and issue browsing.",
|
||||
Icon: IconBrandSentry,
|
||||
},
|
||||
{
|
||||
href: "/settings/integrations/slack",
|
||||
slug: "slack",
|
||||
label: "Slack",
|
||||
description: "Browser-session credentials and !kandev triage agent.",
|
||||
Icon: IconBrandSlack,
|
||||
},
|
||||
];
|
||||
|
||||
export default function IntegrationsIndexPage() {
|
||||
type IntegrationsIndexPageProps = {
|
||||
workspaceId?: string;
|
||||
};
|
||||
|
||||
export default function IntegrationsIndexPage({ workspaceId }: IntegrationsIndexPageProps = {}) {
|
||||
const rootHref = workspaceId
|
||||
? `/settings/workspace/${encodeURIComponent(workspaceId)}/integrations`
|
||||
: "/settings/integrations";
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
@@ -59,19 +67,22 @@ export default function IntegrationsIndexPage() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid auto-rows-fr gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{INTEGRATIONS.map(({ href, label, description, Icon }) => (
|
||||
<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">{description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
{INTEGRATIONS.map(({ slug, label, description, 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">{description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { SentryIntegrationPage } from "@/components/sentry/sentry-settings";
|
||||
|
||||
export default function IntegrationsSentryPage() {
|
||||
return <SentryIntegrationPage />;
|
||||
type IntegrationsSentryPageProps = {
|
||||
workspaceId?: string;
|
||||
};
|
||||
|
||||
export default function IntegrationsSentryPage({ workspaceId }: IntegrationsSentryPageProps = {}) {
|
||||
return <SentryIntegrationPage workspaceId={workspaceId} />;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { SlackIntegrationPage } from "@/components/slack/slack-settings";
|
||||
|
||||
export default function IntegrationsSlackPage() {
|
||||
return <SlackIntegrationPage />;
|
||||
type IntegrationsSlackPageProps = {
|
||||
workspaceId?: string;
|
||||
};
|
||||
|
||||
export default function IntegrationsSlackPage({ workspaceId }: IntegrationsSlackPageProps = {}) {
|
||||
return <SlackIntegrationPage workspaceId={workspaceId} />;
|
||||
}
|
||||
|
||||
@@ -45,7 +45,6 @@ async function SettingsLayoutServer({ children }: { children: React.ReactNode })
|
||||
const workspaceItems = workspaces.workspaces.map(mapWorkspaceItem);
|
||||
const activeWorkspaceId = resolveSettingsActiveWorkspaceId(
|
||||
workspaceItems,
|
||||
null,
|
||||
// `readCookies()` is client-only in this path; during SSR this is empty.
|
||||
// Settings active workspace selection is completed on first client render by
|
||||
// spa-routes via `readActiveWorkspaceCookie()`.
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
IconBrandGithub,
|
||||
IconBrandGitlab,
|
||||
IconBrandSentry,
|
||||
IconBrandSlack,
|
||||
IconHexagon,
|
||||
IconPlugConnected,
|
||||
IconTicket,
|
||||
} from "@tabler/icons-react";
|
||||
import type { Icon as TablerIcon } from "@tabler/icons-react";
|
||||
import { SettingsGroup, SettingsLeaf } from "./settings-nav-primitives";
|
||||
|
||||
const ROOT_HREF = "/settings/integrations";
|
||||
|
||||
const ITEMS: Array<{ href: string; label: string; icon: TablerIcon }> = [
|
||||
{ href: `${ROOT_HREF}/github`, label: "GitHub", icon: IconBrandGithub },
|
||||
{ href: `${ROOT_HREF}/gitlab`, label: "GitLab", icon: IconBrandGitlab },
|
||||
{ href: `${ROOT_HREF}/jira`, label: "Jira", icon: IconTicket },
|
||||
{ href: `${ROOT_HREF}/linear`, label: "Linear", icon: IconHexagon },
|
||||
{ href: `${ROOT_HREF}/sentry`, label: "Sentry", icon: IconBrandSentry },
|
||||
{ href: `${ROOT_HREF}/slack`, label: "Slack", icon: IconBrandSlack },
|
||||
];
|
||||
|
||||
type IntegrationsGroupProps = {
|
||||
pathname: string;
|
||||
expanded?: boolean;
|
||||
onToggle?: () => void;
|
||||
};
|
||||
|
||||
export function IntegrationsGroup({ pathname, expanded, onToggle }: IntegrationsGroupProps) {
|
||||
return (
|
||||
<SettingsGroup
|
||||
label="Integrations"
|
||||
icon={IconPlugConnected}
|
||||
href={ROOT_HREF}
|
||||
isActive={pathname === ROOT_HREF}
|
||||
expanded={expanded}
|
||||
onToggle={onToggle}
|
||||
>
|
||||
{ITEMS.map(({ href, label, icon }) => (
|
||||
<SettingsLeaf
|
||||
key={href}
|
||||
href={href}
|
||||
label={label}
|
||||
icon={icon}
|
||||
isActive={pathname === href}
|
||||
depth={1}
|
||||
/>
|
||||
))}
|
||||
</SettingsGroup>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
import Link from "@/components/routing/app-link";
|
||||
import { IconChevronRight } from "@tabler/icons-react";
|
||||
import type { Icon as TablerIcon } from "@tabler/icons-react";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { Collapsible, CollapsibleContent } from "@kandev/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SIDEBAR_ITEM_ACTIVE, SIDEBAR_ITEM_INACTIVE } from "../../app-sidebar-constants";
|
||||
@@ -22,8 +22,8 @@ type SettingsLeafProps = {
|
||||
depth?: number;
|
||||
};
|
||||
|
||||
const LEAF_DEPTH_PADDING = ["px-2.5", "pl-7 pr-2.5", "pl-10 pr-2.5"] as const;
|
||||
const GROUP_DEPTH_PADDING = ["pl-2.5 pr-1", "pl-7 pr-1", "pl-10 pr-1"] as const;
|
||||
const LEAF_DEPTH_PADDING = ["px-2.5", "pl-7 pr-2.5", "pl-10 pr-2.5", "pl-[52px] pr-2.5"] as const;
|
||||
const GROUP_DEPTH_PADDING = ["pl-2.5 pr-1", "pl-7 pr-1", "pl-10 pr-1", "pl-[52px] pr-1"] as const;
|
||||
const NAV_FOCUS_CLASS =
|
||||
"outline-none focus-visible:ring-1 focus-visible:ring-primary/50 focus-visible:ring-offset-1";
|
||||
|
||||
@@ -59,6 +59,7 @@ export function SettingsLeaf({
|
||||
|
||||
type SettingsGroupProps = {
|
||||
label: string;
|
||||
labelSuffix?: ReactNode;
|
||||
icon?: TablerIcon;
|
||||
/** When the group itself has a destination, the label area is also a link. */
|
||||
href?: string;
|
||||
@@ -78,6 +79,7 @@ type SettingsGroupProps = {
|
||||
|
||||
export function SettingsGroup({
|
||||
label,
|
||||
labelSuffix,
|
||||
icon: Icon,
|
||||
href,
|
||||
isActive,
|
||||
@@ -91,6 +93,11 @@ export function SettingsGroup({
|
||||
const isControlled = controlledExpanded !== undefined;
|
||||
const expanded = isControlled ? controlledExpanded : internalExpanded;
|
||||
const paddingClass = GROUP_DEPTH_PADDING[clampDepth(depth, GROUP_DEPTH_PADDING.length - 1)];
|
||||
|
||||
useEffect(() => {
|
||||
if (!isControlled) setInternalExpanded(defaultExpanded);
|
||||
}, [defaultExpanded, isControlled]);
|
||||
|
||||
const toggle = () => {
|
||||
if (isControlled) onToggle?.();
|
||||
else setInternalExpanded((v) => !v);
|
||||
@@ -100,6 +107,7 @@ export function SettingsGroup({
|
||||
<>
|
||||
{Icon && <Icon className="h-3.5 w-3.5 shrink-0" />}
|
||||
<span className="flex-1 truncate">{label}</span>
|
||||
{labelSuffix}
|
||||
</>
|
||||
);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
@@ -9,8 +9,10 @@ const ARCHIVE_WORKSPACE_NAME = "Archive Workspace";
|
||||
|
||||
const state = {
|
||||
workspaces: {
|
||||
activeId: MAIN_WORKSPACE_ID,
|
||||
items: [{ id: MAIN_WORKSPACE_ID, name: MAIN_WORKSPACE_NAME }],
|
||||
},
|
||||
setActiveWorkspace: vi.fn(),
|
||||
settingsAgents: {
|
||||
items: [],
|
||||
},
|
||||
@@ -45,7 +47,9 @@ import { WorkspacesGroup } from "./workspaces-group";
|
||||
|
||||
describe("SettingsTree rendering", () => {
|
||||
beforeEach(() => {
|
||||
state.workspaces.activeId = MAIN_WORKSPACE_ID;
|
||||
state.workspaces.items = [{ id: MAIN_WORKSPACE_ID, name: MAIN_WORKSPACE_NAME }];
|
||||
state.setActiveWorkspace.mockClear();
|
||||
state.settingsAgents.items = [];
|
||||
state.executors.items = [];
|
||||
});
|
||||
@@ -61,9 +65,54 @@ describe("SettingsTree rendering", () => {
|
||||
expect(screen.getByRole("link", { name: "Workflows" }).getAttribute("href")).toBe(
|
||||
"/settings/workspace/ws-1/workflows",
|
||||
);
|
||||
expect(screen.getByRole("link", { name: "Automations" }).getAttribute("href")).toBe(
|
||||
"/settings/workspace/ws-1/automations",
|
||||
);
|
||||
expect(screen.getByRole("link", { name: "Integrations" }).getAttribute("href")).toBe(
|
||||
"/settings/workspace/ws-1/integrations",
|
||||
);
|
||||
});
|
||||
|
||||
it("only opens the active workspace subsection on workspace detail routes", () => {
|
||||
it("opens the active workspace by default when the settings tree opens", () => {
|
||||
state.workspaces.items = [
|
||||
{ id: MAIN_WORKSPACE_ID, name: MAIN_WORKSPACE_NAME },
|
||||
{ id: ARCHIVE_WORKSPACE_ID, name: ARCHIVE_WORKSPACE_NAME },
|
||||
];
|
||||
|
||||
render(<SettingsTree pathname="/settings" />);
|
||||
|
||||
expect(screen.getByRole("link", { name: `${MAIN_WORKSPACE_NAME} Active` })).toBeTruthy();
|
||||
expect(screen.getByRole("link", { name: ARCHIVE_WORKSPACE_NAME })).toBeTruthy();
|
||||
expect(screen.queryByText("[active]")).toBeNull();
|
||||
expect(screen.getByRole("link", { name: "Repositories" }).getAttribute("href")).toBe(
|
||||
"/settings/workspace/ws-1/repositories",
|
||||
);
|
||||
expect(screen.getByRole("link", { name: "Automations" }).getAttribute("href")).toBe(
|
||||
"/settings/workspace/ws-1/automations",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses an accordion for workspace subsections", () => {
|
||||
state.workspaces.items = [
|
||||
{ id: MAIN_WORKSPACE_ID, name: MAIN_WORKSPACE_NAME },
|
||||
{ id: ARCHIVE_WORKSPACE_ID, name: ARCHIVE_WORKSPACE_NAME },
|
||||
];
|
||||
|
||||
render(<WorkspacesGroup pathname="/settings" expanded />);
|
||||
|
||||
expect(screen.getByRole("link", { name: "Repositories" }).getAttribute("href")).toBe(
|
||||
"/settings/workspace/ws-1/repositories",
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Expand Archive Workspace" }));
|
||||
|
||||
expect(screen.getByRole("button", { name: "Expand Main Workspace" })).toBeTruthy();
|
||||
expect(screen.getByRole("link", { name: "Repositories" }).getAttribute("href")).toBe(
|
||||
"/settings/workspace/ws-10/repositories",
|
||||
);
|
||||
});
|
||||
|
||||
it("only opens the routed workspace subsection on workspace detail routes", () => {
|
||||
state.workspaces.items = [
|
||||
{ id: MAIN_WORKSPACE_ID, name: MAIN_WORKSPACE_NAME },
|
||||
{ id: ARCHIVE_WORKSPACE_ID, name: ARCHIVE_WORKSPACE_NAME },
|
||||
@@ -71,13 +120,13 @@ describe("SettingsTree rendering", () => {
|
||||
|
||||
const { rerender } = render(<WorkspacesGroup pathname="/settings/workspace" expanded />);
|
||||
|
||||
expect(screen.getAllByRole("link", { name: "Repositories" })).toHaveLength(2);
|
||||
expect(screen.getAllByRole("link", { name: "Repositories" })).toHaveLength(1);
|
||||
|
||||
rerender(<WorkspacesGroup pathname="/settings/workspace/ws-10/repositories" expanded />);
|
||||
|
||||
expect(screen.getByRole("link", { name: MAIN_WORKSPACE_NAME }).getAttribute("href")).toBe(
|
||||
"/settings/workspace/ws-1",
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("link", { name: `${MAIN_WORKSPACE_NAME} Active` }).getAttribute("href"),
|
||||
).toBe("/settings/workspace/ws-1");
|
||||
const repositoryLinks = screen.getAllByRole("link", { name: "Repositories" });
|
||||
const workflowLinks = screen.getAllByRole("link", { name: "Workflows" });
|
||||
|
||||
@@ -90,6 +139,20 @@ describe("SettingsTree rendering", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("opens workspace integrations when a workspace integration route is active", () => {
|
||||
state.workspaces.items = [
|
||||
{ id: MAIN_WORKSPACE_ID, name: MAIN_WORKSPACE_NAME },
|
||||
{ id: ARCHIVE_WORKSPACE_ID, name: ARCHIVE_WORKSPACE_NAME },
|
||||
];
|
||||
|
||||
render(<WorkspacesGroup pathname="/settings/workspace/ws-10/integrations/github" expanded />);
|
||||
|
||||
expect(screen.getByRole("link", { name: "GitHub" }).getAttribute("href")).toBe(
|
||||
"/settings/workspace/ws-10/integrations/github",
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Expand Main Workspace" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("keeps Voice Mode in the settings tree as a standalone active leaf", () => {
|
||||
render(<SettingsTree pathname="/settings" />);
|
||||
|
||||
@@ -107,3 +170,70 @@ describe("SettingsTree rendering", () => {
|
||||
expect(screen.queryByRole("link", { name: "Appearance" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkspacesGroup active workspace presentation", () => {
|
||||
beforeEach(() => {
|
||||
state.workspaces.activeId = MAIN_WORKSPACE_ID;
|
||||
state.workspaces.items = [
|
||||
{ id: ARCHIVE_WORKSPACE_ID, name: ARCHIVE_WORKSPACE_NAME },
|
||||
{ id: MAIN_WORKSPACE_ID, name: MAIN_WORKSPACE_NAME },
|
||||
];
|
||||
state.setActiveWorkspace.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
it("keeps the active workspace first even when the API returns it later", () => {
|
||||
render(<WorkspacesGroup pathname="/settings" expanded />);
|
||||
|
||||
const workspaceLinks = getWorkspaceRootLinks();
|
||||
|
||||
expect(workspaceLinks.map((link) => link.textContent)).toEqual([
|
||||
`${MAIN_WORKSPACE_NAME}Active`,
|
||||
ARCHIVE_WORKSPACE_NAME,
|
||||
]);
|
||||
});
|
||||
|
||||
it("expands another workspace without changing the active workspace", () => {
|
||||
render(<WorkspacesGroup pathname="/settings" expanded />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Expand Archive Workspace" }));
|
||||
|
||||
expect(state.setActiveWorkspace).not.toHaveBeenCalled();
|
||||
expect(getWorkspaceRootLinks()[0].textContent).toBe(`${MAIN_WORKSPACE_NAME}Active`);
|
||||
expect(screen.getByRole("link", { name: `${MAIN_WORKSPACE_NAME} Active` })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkspacesGroup integration route sync", () => {
|
||||
beforeEach(() => {
|
||||
state.workspaces.activeId = MAIN_WORKSPACE_ID;
|
||||
state.workspaces.items = [
|
||||
{ id: MAIN_WORKSPACE_ID, name: MAIN_WORKSPACE_NAME },
|
||||
{ id: ARCHIVE_WORKSPACE_ID, name: ARCHIVE_WORKSPACE_NAME },
|
||||
];
|
||||
});
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
it("opens workspace integrations after navigating into an integration route", async () => {
|
||||
const { rerender } = render(
|
||||
<WorkspacesGroup pathname="/settings/workspace/ws-10/repositories" expanded />,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole("link", { name: "GitHub" })).toBeNull();
|
||||
|
||||
rerender(<WorkspacesGroup pathname="/settings/workspace/ws-10/integrations/github" expanded />);
|
||||
|
||||
expect((await screen.findByRole("link", { name: "GitHub" })).getAttribute("href")).toBe(
|
||||
"/settings/workspace/ws-10/integrations/github",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function getWorkspaceRootLinks(): HTMLAnchorElement[] {
|
||||
return screen.getAllByRole("link").filter((link): link is HTMLAnchorElement => {
|
||||
const href = link.getAttribute("href");
|
||||
return Boolean(href?.match(/^\/settings\/workspace\/[^/]+$/));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { settingsGroupIdForPath } from "./settings-tree";
|
||||
import { settingsGroupIdForPath, settingsOpenGroupIdForPath } from "./settings-tree";
|
||||
|
||||
describe("settingsGroupIdForPath", () => {
|
||||
it("maps a group root to its id", () => {
|
||||
expect(settingsGroupIdForPath("/settings/executors")).toBe("executors");
|
||||
expect(settingsGroupIdForPath("/settings/agents")).toBe("agents");
|
||||
expect(settingsGroupIdForPath("/settings/general")).toBe("general");
|
||||
expect(settingsGroupIdForPath("/settings/integrations")).toBe("integrations");
|
||||
expect(settingsGroupIdForPath("/settings/system")).toBe("system");
|
||||
expect(settingsGroupIdForPath("/settings/workspace")).toBe("workspaces");
|
||||
});
|
||||
@@ -14,6 +13,9 @@ describe("settingsGroupIdForPath", () => {
|
||||
it("maps a nested path to its owning group", () => {
|
||||
expect(settingsGroupIdForPath("/settings/executors/profile-123")).toBe("executors");
|
||||
expect(settingsGroupIdForPath("/settings/workspace/ws-1/repositories")).toBe("workspaces");
|
||||
expect(settingsGroupIdForPath("/settings/workspace/ws-1/integrations/github")).toBe(
|
||||
"workspaces",
|
||||
);
|
||||
expect(settingsGroupIdForPath("/settings/system/logs")).toBe("system");
|
||||
expect(settingsGroupIdForPath("/settings/system/feature-toggles")).toBe("system");
|
||||
// General subpages stay under /settings/general so they belong to General.
|
||||
@@ -25,6 +27,8 @@ describe("settingsGroupIdForPath", () => {
|
||||
|
||||
it("returns null for standalone leaves with no owning group", () => {
|
||||
expect(settingsGroupIdForPath("/settings/automations")).toBeNull();
|
||||
expect(settingsGroupIdForPath("/settings/integrations")).toBeNull();
|
||||
expect(settingsGroupIdForPath("/settings/integrations/github")).toBeNull();
|
||||
expect(settingsGroupIdForPath("/settings/prompts")).toBeNull();
|
||||
expect(settingsGroupIdForPath("/settings/voice-mode")).toBeNull();
|
||||
expect(settingsGroupIdForPath("/settings/utility-agents")).toBeNull();
|
||||
@@ -32,3 +36,16 @@ describe("settingsGroupIdForPath", () => {
|
||||
expect(settingsGroupIdForPath("/settings")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("settingsOpenGroupIdForPath", () => {
|
||||
it("defaults the settings tree to Workspaces when no other group owns the route", () => {
|
||||
expect(settingsOpenGroupIdForPath("/")).toBe("workspaces");
|
||||
expect(settingsOpenGroupIdForPath("/settings")).toBe("workspaces");
|
||||
expect(settingsOpenGroupIdForPath("/settings/integrations/github")).toBe("workspaces");
|
||||
});
|
||||
|
||||
it("keeps routed groups open when a route belongs to a different settings group", () => {
|
||||
expect(settingsOpenGroupIdForPath("/settings/general")).toBe("general");
|
||||
expect(settingsOpenGroupIdForPath("/settings/system/status")).toBe("system");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
IconBolt,
|
||||
IconKey,
|
||||
IconMessageCircle,
|
||||
IconMicrophone,
|
||||
@@ -12,17 +11,16 @@ import {
|
||||
import { AgentsGroup } from "./agents-group";
|
||||
import { ExecutorsGroup } from "./executors-group";
|
||||
import { GeneralGroup } from "./general-group";
|
||||
import { IntegrationsGroup } from "./integrations-group";
|
||||
import { SettingsLeaf } from "./settings-nav-primitives";
|
||||
import { SystemGroup } from "./system-group";
|
||||
import { WorkspacesGroup } from "./workspaces-group";
|
||||
|
||||
const AUTOMATIONS_HREF = "/settings/automations";
|
||||
const PROMPTS_HREF = "/settings/prompts";
|
||||
const VOICE_MODE_HREF = "/settings/voice-mode";
|
||||
const UTILITY_HREF = "/settings/utility-agents";
|
||||
const SECRETS_HREF = "/settings/general/secrets";
|
||||
const EXT_MCP_HREF = "/settings/external-mcp";
|
||||
const DEFAULT_OPEN_GROUP = "workspaces";
|
||||
|
||||
// Single-open accordion: each top-level group owns a route prefix. The group
|
||||
// whose prefix matches the current path is the open one. Prefixes are disjoint,
|
||||
@@ -30,7 +28,6 @@ const EXT_MCP_HREF = "/settings/external-mcp";
|
||||
const GROUP_ROUTES = [
|
||||
{ id: "general", prefix: "/settings/general" },
|
||||
{ id: "workspaces", prefix: "/settings/workspace" },
|
||||
{ id: "integrations", prefix: "/settings/integrations" },
|
||||
{ id: "agents", prefix: "/settings/agents" },
|
||||
{ id: "executors", prefix: "/settings/executors" },
|
||||
{ id: "system", prefix: "/settings/system" },
|
||||
@@ -41,21 +38,27 @@ export function settingsGroupIdForPath(pathname: string): string | null {
|
||||
return GROUP_ROUTES.find((g) => pathname.startsWith(g.prefix))?.id ?? null;
|
||||
}
|
||||
|
||||
export function settingsOpenGroupIdForPath(pathname: string): string {
|
||||
return settingsGroupIdForPath(pathname) ?? DEFAULT_OPEN_GROUP;
|
||||
}
|
||||
|
||||
/**
|
||||
* The settings nav tree. Top-level groups behave as a single-open accordion:
|
||||
* opening one closes the others and reveals its subsections. Navigating to a
|
||||
* standalone leaf (Prompts, Automations, …) closes every group.
|
||||
* opening one closes the others and reveals its subsections. Routes without a
|
||||
* top-level group open Workspaces so the active workspace settings stay visible.
|
||||
*
|
||||
* Rendered both inside the collapsible "Settings" sidebar section and, when the
|
||||
* footer gear is active, as the full-height sidebar takeover.
|
||||
*/
|
||||
export function SettingsTree({ pathname }: { pathname: string }) {
|
||||
const [openGroup, setOpenGroup] = useState<string | null>(() => settingsGroupIdForPath(pathname));
|
||||
const [openGroup, setOpenGroup] = useState<string | null>(() =>
|
||||
settingsOpenGroupIdForPath(pathname),
|
||||
);
|
||||
|
||||
// Re-sync when navigation lands on a different section so the open group
|
||||
// always reflects the current page (a leaf with no owning group → all closed).
|
||||
useEffect(() => {
|
||||
setOpenGroup(settingsGroupIdForPath(pathname));
|
||||
setOpenGroup(settingsOpenGroupIdForPath(pathname));
|
||||
}, [pathname]);
|
||||
|
||||
const groupProps = (id: string) => ({
|
||||
@@ -67,13 +70,6 @@ export function SettingsTree({ pathname }: { pathname: string }) {
|
||||
<>
|
||||
<GeneralGroup pathname={pathname} {...groupProps("general")} />
|
||||
<WorkspacesGroup pathname={pathname} {...groupProps("workspaces")} />
|
||||
<IntegrationsGroup pathname={pathname} {...groupProps("integrations")} />
|
||||
<SettingsLeaf
|
||||
href={AUTOMATIONS_HREF}
|
||||
label="Automations"
|
||||
icon={IconBolt}
|
||||
isActive={pathname.startsWith(AUTOMATIONS_HREF)}
|
||||
/>
|
||||
<AgentsGroup pathname={pathname} {...groupProps("agents")} />
|
||||
<SettingsLeaf
|
||||
href={PROMPTS_HREF}
|
||||
|
||||
@@ -1,11 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { IconArrowsShuffle, IconFolder, IconGitBranch } from "@tabler/icons-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
IconArrowsShuffle,
|
||||
IconBolt,
|
||||
IconBrandGithub,
|
||||
IconBrandGitlab,
|
||||
IconBrandSentry,
|
||||
IconBrandSlack,
|
||||
IconFolder,
|
||||
IconGitBranch,
|
||||
IconHexagon,
|
||||
IconPlugConnected,
|
||||
IconTicket,
|
||||
} from "@tabler/icons-react";
|
||||
import type { Icon as TablerIcon } from "@tabler/icons-react";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { SettingsGroup, SettingsLeaf } from "./settings-nav-primitives";
|
||||
|
||||
const ROOT_HREF = "/settings/workspace";
|
||||
|
||||
const INTEGRATIONS: Array<{ slug: string; label: string; icon: TablerIcon }> = [
|
||||
{ slug: "github", label: "GitHub", icon: IconBrandGithub },
|
||||
{ slug: "gitlab", label: "GitLab", icon: IconBrandGitlab },
|
||||
{ slug: "jira", label: "Jira", icon: IconTicket },
|
||||
{ slug: "linear", label: "Linear", icon: IconHexagon },
|
||||
{ slug: "sentry", label: "Sentry", icon: IconBrandSentry },
|
||||
{ slug: "slack", label: "Slack", icon: IconBrandSlack },
|
||||
];
|
||||
|
||||
const ACTIVE_WORKSPACE_LABEL = (
|
||||
<span className="shrink-0 rounded-full border border-primary/35 bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium leading-none text-primary">
|
||||
Active
|
||||
</span>
|
||||
);
|
||||
|
||||
type WorkspacesGroupProps = {
|
||||
pathname: string;
|
||||
expanded?: boolean;
|
||||
@@ -17,16 +46,42 @@ function isWorkspaceRoute(pathname: string, workspaceId: string): boolean {
|
||||
return pathname === workspacePath || pathname.startsWith(`${workspacePath}/`);
|
||||
}
|
||||
|
||||
function activeWorkspaceIdFor(
|
||||
workspaces: Array<{ id: string }>,
|
||||
storeActiveWorkspaceId: string | null,
|
||||
): string | null {
|
||||
return workspaces.some((workspace) => workspace.id === storeActiveWorkspaceId)
|
||||
? storeActiveWorkspaceId
|
||||
: null;
|
||||
}
|
||||
|
||||
function activeWorkspaceFirst<T extends { id: string }>(workspaces: T[], activeId: string | null) {
|
||||
if (!activeId) return workspaces;
|
||||
return [
|
||||
...workspaces.filter((workspace) => workspace.id === activeId),
|
||||
...workspaces.filter((workspace) => workspace.id !== activeId),
|
||||
];
|
||||
}
|
||||
|
||||
export function WorkspacesGroup({ pathname, expanded, onToggle }: WorkspacesGroupProps) {
|
||||
const workspaces = useAppStore((s) => s.workspaces.items);
|
||||
const activeWorkspaceId =
|
||||
const storeActiveWorkspaceId = useAppStore((s) => s.workspaces.activeId);
|
||||
const routeWorkspaceId =
|
||||
workspaces.find((workspace) => isWorkspaceRoute(pathname, workspace.id))?.id ?? null;
|
||||
const routeExpansionKey = activeWorkspaceId ?? "all";
|
||||
const hasActiveWorkspaceRoute = activeWorkspaceId !== null;
|
||||
const activeWorkspaceId = activeWorkspaceIdFor(workspaces, storeActiveWorkspaceId);
|
||||
const orderedWorkspaces = activeWorkspaceFirst(workspaces, activeWorkspaceId);
|
||||
const defaultExpandedWorkspaceId = routeWorkspaceId ?? activeWorkspaceId ?? workspaces[0]?.id;
|
||||
const [expandedWorkspaceId, setExpandedWorkspaceId] = useState<string | null>(
|
||||
defaultExpandedWorkspaceId ?? null,
|
||||
);
|
||||
|
||||
function shouldExpandWorkspace(workspaceId: string): boolean {
|
||||
return !hasActiveWorkspaceRoute || activeWorkspaceId === workspaceId;
|
||||
}
|
||||
useEffect(() => {
|
||||
setExpandedWorkspaceId(defaultExpandedWorkspaceId ?? null);
|
||||
}, [defaultExpandedWorkspaceId]);
|
||||
|
||||
const toggleWorkspace = (workspaceId: string) => {
|
||||
setExpandedWorkspaceId((current) => (current === workspaceId ? null : workspaceId));
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsGroup
|
||||
@@ -37,17 +92,24 @@ export function WorkspacesGroup({ pathname, expanded, onToggle }: WorkspacesGrou
|
||||
expanded={expanded}
|
||||
onToggle={onToggle}
|
||||
>
|
||||
{workspaces.map((workspace) => {
|
||||
{orderedWorkspaces.map((workspace) => {
|
||||
const workspacePath = `${ROOT_HREF}/${workspace.id}`;
|
||||
const repositoriesPath = `${workspacePath}/repositories`;
|
||||
const workflowsPath = `${workspacePath}/workflows`;
|
||||
const automationsPath = `${workspacePath}/automations`;
|
||||
const integrationsPath = `${workspacePath}/integrations`;
|
||||
const integrationsActive =
|
||||
pathname === integrationsPath || pathname.startsWith(`${integrationsPath}/`);
|
||||
const workspaceIsActive = activeWorkspaceId === workspace.id;
|
||||
return (
|
||||
<SettingsGroup
|
||||
key={`${workspace.id}:${routeExpansionKey}`}
|
||||
key={workspace.id}
|
||||
label={workspace.name}
|
||||
labelSuffix={workspaceIsActive ? ACTIVE_WORKSPACE_LABEL : undefined}
|
||||
href={workspacePath}
|
||||
isActive={pathname === workspacePath}
|
||||
defaultExpanded={shouldExpandWorkspace(workspace.id)}
|
||||
expanded={expandedWorkspaceId === workspace.id}
|
||||
onToggle={() => toggleWorkspace(workspace.id)}
|
||||
depth={1}
|
||||
>
|
||||
<SettingsLeaf
|
||||
@@ -64,6 +126,35 @@ export function WorkspacesGroup({ pathname, expanded, onToggle }: WorkspacesGrou
|
||||
isActive={pathname === workflowsPath}
|
||||
depth={2}
|
||||
/>
|
||||
<SettingsGroup
|
||||
label="Integrations"
|
||||
icon={IconPlugConnected}
|
||||
href={integrationsPath}
|
||||
isActive={pathname === integrationsPath}
|
||||
defaultExpanded={integrationsActive}
|
||||
depth={2}
|
||||
>
|
||||
{INTEGRATIONS.map(({ slug, label, icon }) => {
|
||||
const href = `${integrationsPath}/${slug}`;
|
||||
return (
|
||||
<SettingsLeaf
|
||||
key={href}
|
||||
href={href}
|
||||
label={label}
|
||||
icon={icon}
|
||||
isActive={pathname === href}
|
||||
depth={3}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</SettingsGroup>
|
||||
<SettingsLeaf
|
||||
href={automationsPath}
|
||||
label="Automations"
|
||||
icon={IconBolt}
|
||||
isActive={pathname === automationsPath}
|
||||
depth={2}
|
||||
/>
|
||||
</SettingsGroup>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -307,12 +307,16 @@ function PerWorkspaceSection({ workspaceId }: { workspaceId: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function GitHubIntegrationPage() {
|
||||
type GitHubIntegrationPageProps = {
|
||||
workspaceId?: string;
|
||||
};
|
||||
|
||||
export function GitHubIntegrationPage({ workspaceId }: GitHubIntegrationPageProps = {}) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="space-y-8">
|
||||
<GitHubConnectionSection />
|
||||
<WorkspaceScopedSection>
|
||||
<WorkspaceScopedSection workspaceId={workspaceId}>
|
||||
{(ws) => <PerWorkspaceSection key={ws} workspaceId={ws} />}
|
||||
</WorkspaceScopedSection>
|
||||
</div>
|
||||
|
||||
@@ -20,6 +20,7 @@ import { Card, CardContent } from "@kandev/ui/card";
|
||||
import { Input } from "@kandev/ui/input";
|
||||
import { Separator } from "@kandev/ui/separator";
|
||||
import { Spinner } from "@kandev/ui/spinner";
|
||||
import { WorkspaceScopedSection } from "@/components/integrations/workspace-scoped-section";
|
||||
import { useToast } from "@/components/toast-provider";
|
||||
import { SettingsSection } from "@/components/settings/settings-section";
|
||||
import {
|
||||
@@ -227,7 +228,19 @@ function ClearTokenButton({ onCleared }: { onCleared: () => void }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function GitLabIntegrationPage() {
|
||||
type GitLabIntegrationPageProps = {
|
||||
workspaceId?: string;
|
||||
};
|
||||
|
||||
export function GitLabIntegrationPage({ workspaceId }: GitLabIntegrationPageProps = {}) {
|
||||
return (
|
||||
<WorkspaceScopedSection workspaceId={workspaceId}>
|
||||
{(ws) => <GitLabConnectionSection key={ws} />}
|
||||
</WorkspaceScopedSection>
|
||||
);
|
||||
}
|
||||
|
||||
function GitLabConnectionSection() {
|
||||
const [status, setStatus] = useState<GitLabStatus | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
|
||||
@@ -1,32 +1,20 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { integrationFromPathname, integrationLabel } from "./integration-copy-config";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { integrationFromPathname } from "./integration-copy-config";
|
||||
|
||||
describe("integrationFromPathname", () => {
|
||||
it("resolves each supported integration slug", () => {
|
||||
expect(integrationFromPathname("/settings/integrations/slack")).toBe("slack");
|
||||
expect(integrationFromPathname("/settings/integrations/jira")).toBe("jira");
|
||||
expect(integrationFromPathname("/settings/integrations/linear")).toBe("linear");
|
||||
expect(integrationFromPathname("/settings/integrations/sentry")).toBe("sentry");
|
||||
it("recognizes global integration settings routes", () => {
|
||||
expect(integrationFromPathname("/settings/integrations/github")).toBe("github");
|
||||
expect(integrationFromPathname("/settings/integrations/linear")).toBe("linear");
|
||||
});
|
||||
|
||||
it("ignores trailing path segments and query-like suffixes", () => {
|
||||
expect(integrationFromPathname("/settings/integrations/slack/watchers")).toBe("slack");
|
||||
it("recognizes workspace-scoped integration settings routes", () => {
|
||||
expect(integrationFromPathname("/settings/workspace/ws-1/integrations/github")).toBe("github");
|
||||
expect(integrationFromPathname("/settings/workspace/ws-1/integrations/linear")).toBe("linear");
|
||||
});
|
||||
|
||||
it("returns null for non-integration or unknown pages", () => {
|
||||
expect(integrationFromPathname("/settings/integrations")).toBeNull();
|
||||
expect(integrationFromPathname("/settings/agents")).toBeNull();
|
||||
expect(integrationFromPathname("/settings/integrations/unknown")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("integrationLabel", () => {
|
||||
it("uses the app's canonical spelling", () => {
|
||||
expect(integrationLabel("github")).toBe("GitHub");
|
||||
expect(integrationLabel("jira")).toBe("Jira");
|
||||
expect(integrationLabel("slack")).toBe("Slack");
|
||||
expect(integrationLabel("linear")).toBe("Linear");
|
||||
expect(integrationLabel("sentry")).toBe("Sentry");
|
||||
it("ignores non-copyable integration routes", () => {
|
||||
expect(integrationFromPathname("/settings/workspace/ws-1/integrations/gitlab")).toBeNull();
|
||||
expect(integrationFromPathname("/settings/workspace/ws-1/integrations")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,10 +18,11 @@ const integrationLabels: Record<IntegrationSlug, string> = {
|
||||
};
|
||||
|
||||
// integrationFromPathname returns the integration slug for a settings pathname
|
||||
// like /settings/integrations/slack, or null when the current page is not a
|
||||
// copyable integration page.
|
||||
// like /settings/integrations/slack or
|
||||
// /settings/workspace/<id>/integrations/slack, or null when the current page is
|
||||
// not a copyable integration page.
|
||||
export function integrationFromPathname(pathname: string): IntegrationSlug | null {
|
||||
const match = pathname.match(/^\/settings\/integrations\/([^/]+)/);
|
||||
const match = pathname.match(/^\/settings(?:\/workspace\/[^/]+)?\/integrations\/([^/]+)/);
|
||||
const slug = match?.[1];
|
||||
if (slug && Object.prototype.hasOwnProperty.call(integrationLabels, slug)) {
|
||||
return slug as IntegrationSlug;
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = {
|
||||
workspaces: {
|
||||
activeId: "ws-active",
|
||||
items: [
|
||||
{ id: "ws-active", name: "Active" },
|
||||
{ id: "ws-route", name: "Route" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock("@/components/state-provider", () => ({
|
||||
useAppStore: (selector: (s: typeof state) => unknown) => selector(state),
|
||||
}));
|
||||
|
||||
import { WorkspaceScopedSection } from "./workspace-scoped-section";
|
||||
|
||||
describe("WorkspaceScopedSection", () => {
|
||||
afterEach(() => cleanup());
|
||||
|
||||
it("uses an explicit route workspace before the active workspace", () => {
|
||||
render(
|
||||
<WorkspaceScopedSection workspaceId="ws-route">
|
||||
{(workspaceId) => <div data-testid="workspace-id">{workspaceId}</div>}
|
||||
</WorkspaceScopedSection>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("workspace-id").textContent).toBe("ws-route");
|
||||
});
|
||||
});
|
||||
@@ -6,20 +6,22 @@ import { useAppStore } from "@/components/state-provider";
|
||||
|
||||
type WorkspaceScopedSectionProps = {
|
||||
emptyMessage?: string;
|
||||
workspaceId?: string;
|
||||
children: (workspaceId: string) => ReactNode;
|
||||
};
|
||||
|
||||
// WorkspaceScopedSection renders per-workspace integration settings for the
|
||||
// workspace currently selected in the top-right settings switcher. The switcher
|
||||
// (settings-layout-client) is the single source of truth for which workspace is
|
||||
// being edited, so this component just reads the active workspace from the
|
||||
// store — it no longer renders its own selector.
|
||||
export function WorkspaceScopedSection({ emptyMessage, children }: WorkspaceScopedSectionProps) {
|
||||
// routed workspace when present, otherwise the active workspace in the store.
|
||||
export function WorkspaceScopedSection({
|
||||
emptyMessage,
|
||||
workspaceId,
|
||||
children,
|
||||
}: WorkspaceScopedSectionProps) {
|
||||
const workspaces = useAppStore((s) => s.workspaces.items);
|
||||
const activeId = useAppStore((s) => s.workspaces.activeId);
|
||||
const selected = activeId ?? workspaces[0]?.id ?? null;
|
||||
const selected = workspaceId ?? activeId ?? workspaces[0]?.id ?? null;
|
||||
|
||||
if (workspaces.length === 0) {
|
||||
if (!workspaceId && workspaces.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-6 text-sm text-muted-foreground">
|
||||
|
||||
@@ -670,10 +670,10 @@ export function JiraConnectionSection({ workspaceId }: { workspaceId: string })
|
||||
);
|
||||
}
|
||||
|
||||
export function JiraIntegrationPage() {
|
||||
export function JiraIntegrationPage({ workspaceId }: { workspaceId?: string } = {}) {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<WorkspaceScopedSection>
|
||||
<WorkspaceScopedSection workspaceId={workspaceId}>
|
||||
{(workspaceId) => <JiraConnectionSection key={workspaceId} workspaceId={workspaceId} />}
|
||||
</WorkspaceScopedSection>
|
||||
<JiraIssueWatchersSection />
|
||||
|
||||
@@ -445,10 +445,14 @@ export function LinearConnectionSection({ workspaceId }: { workspaceId: string }
|
||||
);
|
||||
}
|
||||
|
||||
export function LinearIntegrationPage() {
|
||||
type LinearIntegrationPageProps = {
|
||||
workspaceId?: string;
|
||||
};
|
||||
|
||||
export function LinearIntegrationPage({ workspaceId }: LinearIntegrationPageProps = {}) {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<WorkspaceScopedSection>
|
||||
<WorkspaceScopedSection workspaceId={workspaceId}>
|
||||
{(workspaceId) => <LinearConnectionSection key={workspaceId} workspaceId={workspaceId} />}
|
||||
</WorkspaceScopedSection>
|
||||
<LinearIssueWatchersSection />
|
||||
|
||||
@@ -430,10 +430,14 @@ export function SentryConnectionSection({ workspaceId }: { workspaceId: string }
|
||||
);
|
||||
}
|
||||
|
||||
export function SentryIntegrationPage() {
|
||||
type SentryIntegrationPageProps = {
|
||||
workspaceId?: string;
|
||||
};
|
||||
|
||||
export function SentryIntegrationPage({ workspaceId }: SentryIntegrationPageProps = {}) {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<WorkspaceScopedSection>
|
||||
<WorkspaceScopedSection workspaceId={workspaceId}>
|
||||
{(workspaceId) => <SentryConnectionSection key={workspaceId} workspaceId={workspaceId} />}
|
||||
</WorkspaceScopedSection>
|
||||
<SentryIssueWatchersSection />
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
let pathname = "/settings/integrations/github";
|
||||
const COPY_CONFIG_TEST_ID = "mock-copy-config";
|
||||
|
||||
const state = {
|
||||
workspaces: {
|
||||
activeId: "ws-1",
|
||||
items: [
|
||||
{ id: "ws-1", name: "Default" },
|
||||
{ id: "ws-2", name: "Archive" },
|
||||
],
|
||||
},
|
||||
setActiveWorkspace: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock("@/lib/routing/client-router", () => ({
|
||||
usePathname: () => pathname,
|
||||
useRouter: () => ({ replace: vi.fn() }),
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/state-provider", () => ({
|
||||
useAppStore: (selector: (s: typeof state) => unknown) => selector(state),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/page-topbar", () => ({
|
||||
PageTopbar: ({ actions }: { actions?: ReactNode }) => (
|
||||
<div data-testid="page-topbar-actions">{actions}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@kandev/ui/tooltip", () => ({
|
||||
TooltipProvider: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/integrations/integration-copy-config-menu", () => ({
|
||||
IntegrationCopyConfigMenu: ({ sourceWorkspaceId }: { sourceWorkspaceId: string }) => (
|
||||
<div data-testid={COPY_CONFIG_TEST_ID} data-source-workspace-id={sourceWorkspaceId} />
|
||||
),
|
||||
}));
|
||||
|
||||
import { SettingsLayoutClient } from "./settings-layout-client";
|
||||
|
||||
describe("SettingsLayoutClient integrations actions", () => {
|
||||
beforeEach(() => {
|
||||
pathname = "/settings/integrations/github";
|
||||
state.workspaces.activeId = "ws-1";
|
||||
state.setActiveWorkspace.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
it("keeps copy config available without rendering the workspace switcher", () => {
|
||||
render(
|
||||
<SettingsLayoutClient>
|
||||
<div>Settings page</div>
|
||||
</SettingsLayoutClient>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("integration-workspace-switcher")).toBeNull();
|
||||
expect(screen.getByTestId(COPY_CONFIG_TEST_ID).dataset.sourceWorkspaceId).toBe("ws-1");
|
||||
});
|
||||
|
||||
it("shows copy config on workspace-scoped integration pages", () => {
|
||||
pathname = "/settings/workspace/ws-1/integrations/github";
|
||||
|
||||
render(
|
||||
<SettingsLayoutClient>
|
||||
<div>Settings page</div>
|
||||
</SettingsLayoutClient>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId(COPY_CONFIG_TEST_ID).dataset.sourceWorkspaceId).toBe("ws-1");
|
||||
});
|
||||
|
||||
it("uses the workspace from scoped integration routes before store hydration catches up", () => {
|
||||
pathname = "/settings/workspace/ws-2/integrations/github";
|
||||
state.workspaces.activeId = "ws-1";
|
||||
|
||||
render(
|
||||
<SettingsLayoutClient>
|
||||
<div>Settings page</div>
|
||||
</SettingsLayoutClient>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId(COPY_CONFIG_TEST_ID).dataset.sourceWorkspaceId).toBe("ws-2");
|
||||
});
|
||||
|
||||
it("falls back to the active workspace when a scoped route has invalid encoding", () => {
|
||||
pathname = "/settings/workspace/%E0%A4%A/integrations/github";
|
||||
state.workspaces.activeId = "ws-1";
|
||||
|
||||
render(
|
||||
<SettingsLayoutClient>
|
||||
<div>Settings page</div>
|
||||
</SettingsLayoutClient>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId(COPY_CONFIG_TEST_ID).dataset.sourceWorkspaceId).toBe("ws-1");
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { usePathname, useRouter, useSearchParams } from "@/lib/routing/client-router";
|
||||
import { usePathname } from "@/lib/routing/client-router";
|
||||
import { TooltipProvider } from "@kandev/ui/tooltip";
|
||||
import { PageTopbar } from "@/components/page-topbar";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { WorkspaceSwitcher } from "@/components/task/workspace-switcher";
|
||||
import { createQueuedUserSettingsSync } from "@/lib/user-settings-sync";
|
||||
import { IntegrationCopyConfigMenu } from "@/components/integrations/integration-copy-config-menu";
|
||||
import { integrationFromPathname } from "@/components/integrations/integration-copy-config";
|
||||
|
||||
const WORKSPACE_SYNC_FAILED_KEY = "kandev:settings:integration-workspace:sync-failed:v1";
|
||||
import { safeDecodePathSegment } from "@/lib/routing/path";
|
||||
|
||||
// Brand/initialism overrides so the derived label matches how the rest of the
|
||||
// app spells these (e.g. "github" → "GitHub", not "Github"). Anything not
|
||||
@@ -79,7 +75,7 @@ function deriveParents(pathname: string): Array<{ label: string; href: string }>
|
||||
export function SettingsLayoutClient({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const isAgentDetail = pathname.startsWith("/settings/agents/") && pathname !== "/settings/agents";
|
||||
const showWorkspaceSwitcher = pathname.startsWith("/settings/integrations");
|
||||
const showIntegrationCopyAction = integrationFromPathname(pathname) !== null;
|
||||
|
||||
if (isAgentDetail) {
|
||||
return (
|
||||
@@ -88,7 +84,7 @@ export function SettingsLayoutClient({ children }: { children: React.ReactNode }
|
||||
backHref="/settings/agents"
|
||||
backLabel="Agents"
|
||||
parents={[]}
|
||||
showWorkspaceSwitcher={showWorkspaceSwitcher}
|
||||
showIntegrationCopyAction={showIntegrationCopyAction}
|
||||
>
|
||||
{children}
|
||||
</SettingsShell>
|
||||
@@ -105,102 +101,55 @@ export function SettingsLayoutClient({ children }: { children: React.ReactNode }
|
||||
backHref="/"
|
||||
backLabel="Kandev"
|
||||
parents={parents}
|
||||
showWorkspaceSwitcher={showWorkspaceSwitcher}
|
||||
showIntegrationCopyAction={showIntegrationCopyAction}
|
||||
>
|
||||
{children}
|
||||
</SettingsShell>
|
||||
);
|
||||
}
|
||||
|
||||
// useWorkspaceQueryParamSync keeps the top-right switcher and the `?workspace`
|
||||
// query param in agreement. On load (or when a shared deep link points at a
|
||||
// valid workspace), it adopts the query param as the active workspace without
|
||||
// persisting it as the user's global default. setWorkspaceParam writes the
|
||||
// param back when the user picks a workspace.
|
||||
function useWorkspaceQueryParamSync(
|
||||
workspaces: Array<{ id: string }>,
|
||||
activeId: string | null,
|
||||
setActiveWorkspace: (id: string) => void,
|
||||
) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const paramWorkspace = searchParams.get("workspace");
|
||||
|
||||
useEffect(() => {
|
||||
if (!paramWorkspace || paramWorkspace === activeId) return;
|
||||
if (!workspaces.some((w) => w.id === paramWorkspace)) return;
|
||||
setActiveWorkspace(paramWorkspace);
|
||||
}, [paramWorkspace, activeId, workspaces, setActiveWorkspace]);
|
||||
|
||||
const setWorkspaceParam = useCallback(
|
||||
(workspaceId: string) => {
|
||||
const next = new URLSearchParams(window.location.search);
|
||||
next.set("workspace", workspaceId);
|
||||
router.replace(`${window.location.pathname}?${next.toString()}`, { scroll: false });
|
||||
},
|
||||
[router],
|
||||
);
|
||||
|
||||
return setWorkspaceParam;
|
||||
}
|
||||
|
||||
function IntegrationWorkspaceSwitcher() {
|
||||
function IntegrationCopyConfigAction() {
|
||||
const pathname = usePathname();
|
||||
const workspaces = useAppStore((s) => s.workspaces.items);
|
||||
const activeId = useAppStore((s) => s.workspaces.activeId);
|
||||
const setActiveWorkspace = useAppStore((s) => s.setActiveWorkspace);
|
||||
const selected = activeId ?? workspaces[0]?.id ?? null;
|
||||
const routeWorkspaceId = workspaceIdFromPathname(pathname);
|
||||
const selected =
|
||||
routeWorkspaceId && workspaces.some((workspace) => workspace.id === routeWorkspaceId)
|
||||
? routeWorkspaceId
|
||||
: (activeId ?? workspaces[0]?.id ?? null);
|
||||
const integration = integrationFromPathname(pathname);
|
||||
const persistWorkspace = useMemo(
|
||||
() =>
|
||||
createQueuedUserSettingsSync<string>(WORKSPACE_SYNC_FAILED_KEY, (workspaceId) => ({
|
||||
workspace_id: workspaceId,
|
||||
})),
|
||||
[],
|
||||
);
|
||||
const setWorkspaceParam = useWorkspaceQueryParamSync(workspaces, activeId, setActiveWorkspace);
|
||||
|
||||
const onSelect = useCallback(
|
||||
(workspaceId: string) => {
|
||||
setActiveWorkspace(workspaceId);
|
||||
setWorkspaceParam(workspaceId);
|
||||
void persistWorkspace(workspaceId);
|
||||
},
|
||||
[persistWorkspace, setActiveWorkspace, setWorkspaceParam],
|
||||
);
|
||||
|
||||
if (workspaces.length === 0) return null;
|
||||
if (!integration || !selected || workspaces.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2" data-testid="integration-workspace-switcher">
|
||||
<span className="hidden text-xs whitespace-nowrap text-muted-foreground sm:inline">
|
||||
Editing workspace
|
||||
</span>
|
||||
<WorkspaceSwitcher workspaces={workspaces} activeWorkspaceId={selected} onSelect={onSelect} />
|
||||
{integration && selected ? (
|
||||
<IntegrationCopyConfigMenu
|
||||
slug={integration}
|
||||
sourceWorkspaceId={selected}
|
||||
workspaces={workspaces}
|
||||
/>
|
||||
) : null}
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<IntegrationCopyConfigMenu
|
||||
slug={integration}
|
||||
sourceWorkspaceId={selected}
|
||||
workspaces={workspaces}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function workspaceIdFromPathname(pathname: string): string | null {
|
||||
const match = pathname.match(/^\/settings\/workspace\/([^/]+)(?:\/|$)/);
|
||||
return safeDecodePathSegment(match?.[1]);
|
||||
}
|
||||
|
||||
function SettingsShell({
|
||||
title,
|
||||
backHref,
|
||||
backLabel,
|
||||
parents,
|
||||
showWorkspaceSwitcher,
|
||||
showIntegrationCopyAction,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
backHref: string;
|
||||
backLabel: string;
|
||||
parents: Array<{ label: string; href: string }>;
|
||||
showWorkspaceSwitcher: boolean;
|
||||
showIntegrationCopyAction: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
@@ -212,7 +161,7 @@ function SettingsShell({
|
||||
backLabel={backLabel}
|
||||
parents={parents}
|
||||
className="h-10"
|
||||
actions={showWorkspaceSwitcher ? <IntegrationWorkspaceSwitcher /> : undefined}
|
||||
actions={showIntegrationCopyAction ? <IntegrationCopyConfigAction /> : undefined}
|
||||
/>
|
||||
{/* Scroll the content, not the topbar: min-h-0 lets this flex child
|
||||
shrink below its content height so overflow-y-auto can take effect. */}
|
||||
|
||||
@@ -603,10 +603,14 @@ export function SlackConnectionSection({ workspaceId }: { workspaceId: string })
|
||||
);
|
||||
}
|
||||
|
||||
export function SlackIntegrationPage() {
|
||||
type SlackIntegrationPageProps = {
|
||||
workspaceId?: string;
|
||||
};
|
||||
|
||||
export function SlackIntegrationPage({ workspaceId }: SlackIntegrationPageProps = {}) {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<WorkspaceScopedSection>
|
||||
<WorkspaceScopedSection workspaceId={workspaceId}>
|
||||
{(workspaceId) => <SlackConnectionSection key={workspaceId} workspaceId={workspaceId} />}
|
||||
</WorkspaceScopedSection>
|
||||
</div>
|
||||
|
||||
@@ -6,8 +6,6 @@ export class LinearSettingsPage {
|
||||
readonly saveButton: Locator;
|
||||
readonly deleteButton: Locator;
|
||||
readonly statusBanner: Locator;
|
||||
readonly switcher: Locator;
|
||||
readonly workspaceTrigger: Locator;
|
||||
readonly copyConfigTrigger: Locator;
|
||||
readonly copyConfigTarget: Locator;
|
||||
readonly copyConfigConfirm: Locator;
|
||||
@@ -18,8 +16,6 @@ export class LinearSettingsPage {
|
||||
this.saveButton = page.getByTestId("linear-save-button");
|
||||
this.deleteButton = page.getByTestId("linear-delete-button");
|
||||
this.statusBanner = page.getByTestId("integration-auth-status-banner");
|
||||
this.switcher = page.getByTestId("integration-workspace-switcher");
|
||||
this.workspaceTrigger = this.switcher.getByTitle("Switch Workspace");
|
||||
this.copyConfigTrigger = page.getByTestId("integration-copy-config-trigger");
|
||||
this.copyConfigTarget = page.getByTestId("integration-copy-config-target");
|
||||
this.copyConfigConfirm = page.getByTestId("integration-copy-config-confirm");
|
||||
@@ -29,4 +25,9 @@ export class LinearSettingsPage {
|
||||
await this.page.goto(`/settings/integrations/linear${query}`);
|
||||
await this.secretInput.waitFor({ state: "visible" });
|
||||
}
|
||||
|
||||
async gotoWorkspace(workspaceId: string) {
|
||||
await this.page.goto(`/settings/workspace/${workspaceId}/integrations/linear`);
|
||||
await this.secretInput.waitFor({ state: "visible" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,8 +35,8 @@ test.describe("GitHub workspace settings", () => {
|
||||
},
|
||||
]);
|
||||
|
||||
await testPage.goto("/settings/integrations/github");
|
||||
await expect(testPage.getByTestId("integration-workspace-switcher")).toBeVisible();
|
||||
await testPage.goto(`/settings/workspace/${seedData.workspaceId}/integrations/github`);
|
||||
await expect(testPage.getByTestId("integration-workspace-switcher")).toHaveCount(0);
|
||||
|
||||
await testPage.getByTestId("github-scope-mode").click();
|
||||
await testPage.getByRole("option", { name: "Selected repositories" }).click();
|
||||
|
||||
@@ -40,9 +40,7 @@ async function integrationCardIconTopInsets(cards: Locator[]) {
|
||||
}
|
||||
|
||||
async function integrationCards(page: Page): Promise<Locator[]> {
|
||||
const links = page
|
||||
.getByTestId("settings-scroll-container")
|
||||
.locator('a[href^="/settings/integrations/"]');
|
||||
const links = page.getByTestId("settings-scroll-container").locator('a[href*="/integrations/"]');
|
||||
await expect(links.first()).toBeVisible();
|
||||
const count = await links.count();
|
||||
expect(count).toBeGreaterThan(0);
|
||||
|
||||
@@ -45,7 +45,10 @@ test.describe("Linear settings", () => {
|
||||
await expect(testPage.getByText(/leave blank to keep the current value/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test("workspace selector scopes the saved credentials form", async ({ testPage, apiClient }) => {
|
||||
test("workspace-scoped route scopes the saved credentials form", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
}) => {
|
||||
const other = await apiClient.createWorkspace("Linear Secondary Workspace");
|
||||
await apiClient.mockLinearSetTeams([{ id: "team-1", key: "ENG", name: "Engineering" }]);
|
||||
|
||||
@@ -57,8 +60,7 @@ test.describe("Linear settings", () => {
|
||||
await expect(settings.deleteButton).toBeVisible();
|
||||
await expect(testPage.getByText(/leave blank to keep the current value/i)).toBeVisible();
|
||||
|
||||
await settings.workspaceTrigger.click();
|
||||
await testPage.getByRole("menuitem", { name: new RegExp(other.name) }).click();
|
||||
await settings.gotoWorkspace(other.id);
|
||||
|
||||
await expect(settings.secretInput).toHaveValue("");
|
||||
await expect(settings.saveButton).toBeDisabled();
|
||||
@@ -66,35 +68,36 @@ test.describe("Linear settings", () => {
|
||||
await expect(testPage.getByText(/leave blank to keep the current value/i)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("a ?workspace deep link adopts that workspace on load", async ({ testPage, apiClient }) => {
|
||||
test("a workspace-scoped deep link adopts that workspace on load", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
}) => {
|
||||
const other = await apiClient.createWorkspace("Linear Deep Link Workspace");
|
||||
// Seed the secondary workspace so the deep link lands on a configured row,
|
||||
// proving the query param — not the user's global default — drove selection.
|
||||
// proving the route path — not the user's global default — drove selection.
|
||||
await apiClient.setLinearConfig({ secret: "lin_api_deeplink", workspaceId: other.id });
|
||||
|
||||
const settings = new LinearSettingsPage(testPage);
|
||||
await settings.goto(`?workspace=${other.id}`);
|
||||
await settings.gotoWorkspace(other.id);
|
||||
|
||||
await expect(settings.switcher).toContainText("Editing workspace");
|
||||
await expect(settings.switcher).toContainText(other.name);
|
||||
// The seeded row loads, confirming the deep link scoped the form to `other`.
|
||||
await expect(testPage.getByText(/leave blank to keep the current value/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test("selecting a workspace writes it to the ?workspace query param", async ({
|
||||
test("workspace-scoped integration route keeps the workspace in the path", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
}) => {
|
||||
const other = await apiClient.createWorkspace("Linear Query Param Workspace");
|
||||
const other = await apiClient.createWorkspace("Linear Path Workspace");
|
||||
|
||||
const settings = new LinearSettingsPage(testPage);
|
||||
await settings.goto();
|
||||
await settings.gotoWorkspace(other.id);
|
||||
|
||||
await settings.workspaceTrigger.click();
|
||||
await testPage.getByRole("menuitem", { name: new RegExp(other.name) }).click();
|
||||
|
||||
await expect(testPage).toHaveURL(new RegExp(`[?&]workspace=${other.id}(&|$)`));
|
||||
await expect(settings.switcher).toContainText(other.name);
|
||||
await expect(testPage).toHaveURL(
|
||||
new RegExp(`/settings/workspace/${other.id}/integrations/linear$`),
|
||||
);
|
||||
await expect(testPage).not.toHaveURL(/[?&]workspace=/);
|
||||
await expect(settings.secretInput).toHaveValue("");
|
||||
});
|
||||
|
||||
test("copy config duplicates the credentials to another workspace", async ({
|
||||
@@ -125,9 +128,8 @@ test.describe("Linear settings", () => {
|
||||
const cfg = (await res.json()) as { hasSecret?: boolean };
|
||||
expect(cfg.hasSecret).toBe(true);
|
||||
|
||||
// Switching to the target in the UI shows the copied credentials loaded.
|
||||
await settings.workspaceTrigger.click();
|
||||
await testPage.getByRole("menuitem", { name: new RegExp(other.name) }).click();
|
||||
// Opening the target workspace route shows the copied credentials loaded.
|
||||
await settings.gotoWorkspace(other.id);
|
||||
await expect(settings.deleteButton).toBeVisible();
|
||||
await expect(testPage.getByText(/leave blank to keep the current value/i)).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -2,8 +2,11 @@ import { test } from "../../fixtures/test-base";
|
||||
import { expectStableIntegrationCardLayout } from "./integrations-index-layout-helpers";
|
||||
|
||||
test.describe("integrations settings index layout on mobile", () => {
|
||||
test("renders equal-height integration cards", async ({ testPage }) => {
|
||||
await testPage.goto("/settings/integrations");
|
||||
test("renders equal-height integration cards on a workspace-scoped route", async ({
|
||||
testPage,
|
||||
seedData,
|
||||
}) => {
|
||||
await testPage.goto(`/settings/workspace/${seedData.workspaceId}/integrations`);
|
||||
|
||||
await expectStableIntegrationCardLayout(testPage);
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { test, expect } from "../../fixtures/test-base";
|
||||
import { LinearSettingsPage } from "../../pages/linear-settings-page";
|
||||
|
||||
test.describe("Linear settings on mobile", () => {
|
||||
test("workspace selector scopes the credentials form", async ({ testPage, apiClient }) => {
|
||||
test("workspace-scoped route scopes the credentials form", async ({ testPage, apiClient }) => {
|
||||
const other = await apiClient.createWorkspace("Mobile Linear Workspace");
|
||||
|
||||
const settings = new LinearSettingsPage(testPage);
|
||||
@@ -12,8 +12,7 @@ test.describe("Linear settings on mobile", () => {
|
||||
await settings.saveButton.click();
|
||||
await expect(testPage.getByText(/leave blank to keep the current value/i)).toBeVisible();
|
||||
|
||||
await settings.workspaceTrigger.click();
|
||||
await testPage.getByRole("menuitem", { name: new RegExp(other.name) }).click();
|
||||
await settings.gotoWorkspace(other.id);
|
||||
|
||||
await expect(settings.secretInput).toHaveValue("");
|
||||
await expect(settings.saveButton).toBeDisabled();
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
|
||||
test.describe("Mobile settings navigation", () => {
|
||||
test("keeps the desktop settings sidebar hidden without leaking the old active text", async ({
|
||||
testPage,
|
||||
}) => {
|
||||
await testPage.goto("/settings");
|
||||
|
||||
await expect(testPage.getByRole("link", { name: /Appearance/ })).toBeVisible();
|
||||
await expect(testPage.getByRole("link", { name: /Terminal/ })).toBeVisible();
|
||||
await expect(testPage.getByText("[active]")).toHaveCount(0);
|
||||
await expect(testPage.getByTestId("app-sidebar")).toBeHidden();
|
||||
|
||||
const takeover = testPage.getByTestId("app-sidebar-settings-mode");
|
||||
|
||||
await expect(takeover).toBeHidden();
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,13 @@ test.describe("Settings sidebar takeover", () => {
|
||||
// Gear opens the takeover again.
|
||||
await gear.click();
|
||||
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);
|
||||
|
||||
// Enter a section: navigates to a settings sub-page; takeover stays open.
|
||||
await takeover.locator('a[href="/settings/agents"]').first().click();
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export function safeDecodePathSegment(segment: string | undefined): string | null {
|
||||
if (!segment) return null;
|
||||
try {
|
||||
return decodeURIComponent(segment);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -79,25 +79,10 @@ describe("resolveSettingsActiveWorkspaceId", () => {
|
||||
],
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
),
|
||||
).toBe(kanbanOne);
|
||||
});
|
||||
|
||||
it("prefers explicit route workspace ID", () => {
|
||||
expect(
|
||||
resolveSettingsActiveWorkspaceId(
|
||||
[
|
||||
{ id: "office-1", office_workflow_id: officeWorkflow },
|
||||
{ id: kanbanOne, office_workflow_id: null },
|
||||
],
|
||||
"office-1",
|
||||
null,
|
||||
null,
|
||||
),
|
||||
).toBe("office-1");
|
||||
});
|
||||
|
||||
it("uses active cookie when it matches a kanban workspace", () => {
|
||||
expect(
|
||||
resolveSettingsActiveWorkspaceId(
|
||||
@@ -106,7 +91,6 @@ describe("resolveSettingsActiveWorkspaceId", () => {
|
||||
{ id: kanbanOne, office_workflow_id: null },
|
||||
{ id: kanbanTwo, office_workflow_id: null },
|
||||
],
|
||||
null,
|
||||
kanbanTwo,
|
||||
null,
|
||||
),
|
||||
@@ -121,7 +105,6 @@ describe("resolveSettingsActiveWorkspaceId", () => {
|
||||
{ id: kanbanOne, office_workflow_id: null },
|
||||
{ id: kanbanTwo, office_workflow_id: null },
|
||||
],
|
||||
null,
|
||||
"ws-missing",
|
||||
kanbanOne,
|
||||
),
|
||||
@@ -129,7 +112,7 @@ describe("resolveSettingsActiveWorkspaceId", () => {
|
||||
});
|
||||
|
||||
it("returns null when no workspaces exist", () => {
|
||||
expect(resolveSettingsActiveWorkspaceId([], null, "k-1", "k-2")).toBeNull();
|
||||
expect(resolveSettingsActiveWorkspaceId([], "k-1", "k-2")).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to office workspace only when no kanban workspaces exist", () => {
|
||||
@@ -139,7 +122,6 @@ describe("resolveSettingsActiveWorkspaceId", () => {
|
||||
{ id: "office-1", office_workflow_id: officeWorkflow1 },
|
||||
{ id: "office-2", office_workflow_id: officeWorkflow2 },
|
||||
],
|
||||
null,
|
||||
"missing",
|
||||
null,
|
||||
),
|
||||
@@ -155,7 +137,6 @@ describe("resolveSettingsActiveWorkspaceId", () => {
|
||||
],
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
),
|
||||
).toBe(kanbanOne);
|
||||
});
|
||||
|
||||
@@ -44,14 +44,12 @@ type SettingsWorkspaceItem = {
|
||||
|
||||
export function resolveSettingsActiveWorkspaceId(
|
||||
workspaceItems: SettingsWorkspaceItem[],
|
||||
requestedWorkspaceId: string | null,
|
||||
activeCookieWorkspaceId: string | null,
|
||||
settingsWorkspaceId: string | null,
|
||||
): string | null {
|
||||
const kanbanWorkspaceItems = workspaceItems.filter((workspace) => !workspace.office_workflow_id);
|
||||
|
||||
return (
|
||||
workspaceItems.find((workspace) => workspace.id === requestedWorkspaceId)?.id ??
|
||||
kanbanWorkspaceItems.find((workspace) => workspace.id === activeCookieWorkspaceId)?.id ??
|
||||
kanbanWorkspaceItems.find((workspace) => workspace.id === settingsWorkspaceId)?.id ??
|
||||
kanbanWorkspaceItems[0]?.id ??
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { isValidElement, type ReactElement } from "react";
|
||||
|
||||
import IntegrationsGitLabPage from "@/app/settings/integrations/gitlab/page";
|
||||
import { workspaceId, workflowId } from "@/lib/types/ids";
|
||||
import type { ListWorkspacesResponse, UserSettingsResponse } from "@/lib/types/http";
|
||||
import { buildSettingsInitialStateForRoute } from "./settings-routes";
|
||||
import { buildSettingsInitialStateForRoute, renderSettingsRoute } from "./settings-routes";
|
||||
|
||||
const ACTIVE_WORKSPACE_COOKIE = "kandev-active-workspace";
|
||||
const OWNER_ID = "owner-1";
|
||||
const TIMESTAMP = "2026-01-01T00:00:00Z";
|
||||
const SETTINGS_ROUTE = "/settings/integrations";
|
||||
|
||||
describe("buildSettingsInitialStateForRoute", () => {
|
||||
beforeEach(() => {
|
||||
@@ -15,22 +16,20 @@ describe("buildSettingsInitialStateForRoute", () => {
|
||||
});
|
||||
|
||||
describe("workspace selection", () => {
|
||||
it("prefers the workspace matching the URL path param", () => {
|
||||
it("keeps the saved active workspace for settings hydration", () => {
|
||||
const state = buildState({
|
||||
pathname: "/settings/workspace/ws-2/repositories",
|
||||
workspaces: workspaceRows(["ws-1", "ws-2"]),
|
||||
userSettingsResponse: userSettings({ workspace_id: workspaceId("ws-1") }),
|
||||
});
|
||||
|
||||
expect(state.workspaces?.activeId).toBe("ws-2");
|
||||
expect(state.userSettings?.workspaceId).toBe("ws-2");
|
||||
expect(state.workspaces?.activeId).toBe("ws-1");
|
||||
expect(state.userSettings?.workspaceId).toBe("ws-1");
|
||||
});
|
||||
|
||||
it("keeps the active workspace cookie on global settings pages", () => {
|
||||
document.cookie = `${ACTIVE_WORKSPACE_COOKIE}=ws-2; path=/`;
|
||||
|
||||
const state = buildState({
|
||||
pathname: SETTINGS_ROUTE,
|
||||
workspaces: workspaceRows(["ws-1", "ws-2"]),
|
||||
userSettingsResponse: userSettings({ workspace_id: workspaceId("ws-1") }),
|
||||
});
|
||||
@@ -43,7 +42,6 @@ describe("buildSettingsInitialStateForRoute", () => {
|
||||
document.cookie = `${ACTIVE_WORKSPACE_COOKIE}=ws-office; path=/`;
|
||||
|
||||
const state = buildState({
|
||||
pathname: SETTINGS_ROUTE,
|
||||
workspaces: [
|
||||
buildWorkspace({ id: "ws-office", office_workflow_id: workflowId("office") }),
|
||||
buildWorkspace({ id: "ws-kanban", office_workflow_id: null }),
|
||||
@@ -57,9 +55,8 @@ describe("buildSettingsInitialStateForRoute", () => {
|
||||
});
|
||||
|
||||
describe("fallbacks", () => {
|
||||
it("falls back to the settings workspace_id when no URL param matches", () => {
|
||||
it("falls back to the settings workspace_id when no cookie matches", () => {
|
||||
const state = buildState({
|
||||
pathname: "/settings/workspace/missing/repositories",
|
||||
workspaces: workspaceRows(["ws-1", "ws-2"]),
|
||||
userSettingsResponse: userSettings({ workspace_id: workspaceId("ws-2") }),
|
||||
});
|
||||
@@ -68,9 +65,8 @@ describe("buildSettingsInitialStateForRoute", () => {
|
||||
expect(state.userSettings?.workspaceId).toBe("ws-2");
|
||||
});
|
||||
|
||||
it("falls back to the first workspace when neither URL param nor settings match", () => {
|
||||
it("falls back to the first workspace when neither cookie nor settings match", () => {
|
||||
const state = buildState({
|
||||
pathname: "/settings/utility-agents",
|
||||
workspaces: workspaceRows(["ws-1", "ws-2"]),
|
||||
userSettingsResponse: userSettings({ workspace_id: workspaceId("missing") }),
|
||||
});
|
||||
@@ -113,11 +109,17 @@ describe("buildSettingsInitialStateForRoute", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderSettingsRoute", () => {
|
||||
it("passes the route workspace id to the GitLab integration page", () => {
|
||||
expect(gitLabRouteWorkspaceId("/settings/workspace/ws-2/integrations/gitlab")).toBe("ws-2");
|
||||
expect(gitLabRouteWorkspaceId("/settings/workspace/ws%202/integrations/gitlab")).toBe("ws 2");
|
||||
});
|
||||
});
|
||||
|
||||
function buildState(
|
||||
overrides: Partial<Parameters<typeof buildSettingsInitialStateForRoute>[0]> = {},
|
||||
) {
|
||||
return buildSettingsInitialStateForRoute({
|
||||
pathname: "/settings",
|
||||
workspaces: [],
|
||||
executors: [],
|
||||
agents: [],
|
||||
@@ -173,3 +175,12 @@ function userSettings(
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function gitLabRouteWorkspaceId(pathname: string): string | undefined {
|
||||
const route = renderSettingsRoute(pathname);
|
||||
if (!isValidElement(route)) {
|
||||
throw new Error("expected GitLab integration route element");
|
||||
}
|
||||
expect(route.type).toBe(IntegrationsGitLabPage);
|
||||
return (route as ReactElement<{ workspaceId?: string }>).props.workspaceId;
|
||||
}
|
||||
|
||||
@@ -66,6 +66,7 @@ import {
|
||||
import { listWorkflowTemplates } from "@/lib/api/domains/workflow-api";
|
||||
import { listRepositories, listWorkspaces } from "@/lib/api/domains/workspace-api";
|
||||
import { useRouter } from "@/lib/routing/client-router";
|
||||
import { safeDecodePathSegment } from "@/lib/routing/path";
|
||||
import {
|
||||
mapWorkspaceItem,
|
||||
readActiveWorkspaceCookie,
|
||||
@@ -97,7 +98,6 @@ type WorkspaceWorkflowsRouteState = {
|
||||
workflowTemplates: WorkflowTemplate[];
|
||||
};
|
||||
type SettingsInitialStateData = {
|
||||
pathname: string;
|
||||
workspaces: ListWorkspacesResponse["workspaces"];
|
||||
executors: Awaited<ReturnType<typeof listExecutors>>["executors"];
|
||||
agents: Awaited<ReturnType<typeof listAgents>>["agents"];
|
||||
@@ -136,13 +136,13 @@ const SETTINGS_ROUTES: Record<string, RouteRenderer> = {
|
||||
"/settings/external-mcp": () => <ExternalMcpPage />,
|
||||
"/settings/prompts": () => <PromptsSettings />,
|
||||
"/settings/voice-mode": () => <VoiceModeSettings />,
|
||||
"/settings/integrations": () => <IntegrationsIndexPage />,
|
||||
"/settings/integrations/github": () => <GitHubIntegrationPage />,
|
||||
"/settings/integrations/gitlab": () => <IntegrationsGitLabPage />,
|
||||
"/settings/integrations/jira": () => <IntegrationsJiraPage />,
|
||||
"/settings/integrations/linear": () => <IntegrationsLinearPage />,
|
||||
"/settings/integrations/sentry": () => <IntegrationsSentryPage />,
|
||||
"/settings/integrations/slack": () => <IntegrationsSlackPage />,
|
||||
"/settings/integrations": () => renderIntegrationSettingsRoute(null),
|
||||
"/settings/integrations/github": () => renderIntegrationSettingsRoute("github"),
|
||||
"/settings/integrations/gitlab": () => renderIntegrationSettingsRoute("gitlab"),
|
||||
"/settings/integrations/jira": () => renderIntegrationSettingsRoute("jira"),
|
||||
"/settings/integrations/linear": () => renderIntegrationSettingsRoute("linear"),
|
||||
"/settings/integrations/sentry": () => renderIntegrationSettingsRoute("sentry"),
|
||||
"/settings/integrations/slack": () => renderIntegrationSettingsRoute("slack"),
|
||||
"/settings/system": () => <SettingsRedirect to="/settings/system/status" />,
|
||||
"/settings/system/about": () => (
|
||||
<SystemPageShell title="About" description="Version, build metadata, and links.">
|
||||
@@ -218,44 +218,15 @@ export function settingsRouteKey(pathname: string): string {
|
||||
return normalizeSettingsPath(pathname);
|
||||
}
|
||||
|
||||
function renderSettingsRoute(pathname: string) {
|
||||
export function renderSettingsRoute(pathname: string) {
|
||||
const dynamicRoute = renderDynamicSettingsRoute(pathname);
|
||||
if (dynamicRoute) return dynamicRoute;
|
||||
return SETTINGS_ROUTES[pathname]?.() ?? <SettingsRouteFallback pathname={pathname} />;
|
||||
}
|
||||
|
||||
function renderDynamicSettingsRoute(pathname: string) {
|
||||
const workspaceAutomation = matchDouble(
|
||||
pathname,
|
||||
/^\/settings\/workspace\/([^/]+)\/automations\/([^/]+)$/,
|
||||
);
|
||||
if (workspaceAutomation) {
|
||||
const [id, automationId] = workspaceAutomation;
|
||||
if (automationId === "new") {
|
||||
return <NewAutomationPage params={Promise.resolve({ id })} />;
|
||||
}
|
||||
return <AutomationEditorPage params={Promise.resolve({ id, automationId })} />;
|
||||
}
|
||||
|
||||
const workspaceSubpage = matchDouble(
|
||||
pathname,
|
||||
/^\/settings\/workspace\/([^/]+)\/(repositories|workflows|automations)$/,
|
||||
);
|
||||
if (workspaceSubpage) {
|
||||
const [id, section] = workspaceSubpage;
|
||||
if (section === "repositories") {
|
||||
return <WorkspaceRepositoriesRoute workspaceId={id} />;
|
||||
}
|
||||
if (section === "workflows") {
|
||||
return <WorkspaceWorkflowsRoute workspaceId={id} />;
|
||||
}
|
||||
return <AutomationsPage params={Promise.resolve({ id })} />;
|
||||
}
|
||||
|
||||
const workspaceId = matchSingle(pathname, /^\/settings\/workspace\/([^/]+)$/);
|
||||
if (workspaceId) {
|
||||
return <WorkspaceEditPage params={Promise.resolve({ id: workspaceId })} />;
|
||||
}
|
||||
const workspaceRoute = renderWorkspaceSettingsRoute(pathname);
|
||||
if (workspaceRoute) return workspaceRoute;
|
||||
|
||||
const agentProfile = matchDouble(pathname, /^\/settings\/agents\/([^/]+)\/profiles\/([^/]+)$/);
|
||||
if (agentProfile) {
|
||||
@@ -299,6 +270,73 @@ function renderDynamicSettingsRoute(pathname: string) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderWorkspaceSettingsRoute(pathname: string) {
|
||||
const workspaceIntegration = pathname.match(
|
||||
/^\/settings\/workspace\/([^/]+)\/integrations(?:\/([^/]+))?$/,
|
||||
);
|
||||
if (workspaceIntegration?.[1]) {
|
||||
const workspaceId = safeDecodePathSegment(workspaceIntegration[1]);
|
||||
const section = workspaceIntegration[2] ? safeDecodePathSegment(workspaceIntegration[2]) : null;
|
||||
if (!workspaceId || (workspaceIntegration[2] && !section)) return null;
|
||||
return renderIntegrationSettingsRoute(section, workspaceId);
|
||||
}
|
||||
|
||||
const workspaceAutomation = matchDouble(
|
||||
pathname,
|
||||
/^\/settings\/workspace\/([^/]+)\/automations\/([^/]+)$/,
|
||||
);
|
||||
if (workspaceAutomation) {
|
||||
const [id, automationId] = workspaceAutomation;
|
||||
if (automationId === "new") {
|
||||
return <NewAutomationPage params={Promise.resolve({ id })} />;
|
||||
}
|
||||
return <AutomationEditorPage params={Promise.resolve({ id, automationId })} />;
|
||||
}
|
||||
|
||||
const workspaceSubpage = matchDouble(
|
||||
pathname,
|
||||
/^\/settings\/workspace\/([^/]+)\/(repositories|workflows|automations)$/,
|
||||
);
|
||||
if (workspaceSubpage) {
|
||||
const [id, section] = workspaceSubpage;
|
||||
if (section === "repositories") {
|
||||
return <WorkspaceRepositoriesRoute workspaceId={id} />;
|
||||
}
|
||||
if (section === "workflows") {
|
||||
return <WorkspaceWorkflowsRoute workspaceId={id} />;
|
||||
}
|
||||
return <AutomationsPage params={Promise.resolve({ id })} />;
|
||||
}
|
||||
|
||||
const workspaceId = matchSingle(pathname, /^\/settings\/workspace\/([^/]+)$/);
|
||||
if (workspaceId) {
|
||||
return <WorkspaceEditPage params={Promise.resolve({ id: workspaceId })} />;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderIntegrationSettingsRoute(section: string | null, workspaceId?: string) {
|
||||
switch (section) {
|
||||
case null:
|
||||
return <IntegrationsIndexPage workspaceId={workspaceId} />;
|
||||
case "github":
|
||||
return <GitHubIntegrationPage workspaceId={workspaceId} />;
|
||||
case "gitlab":
|
||||
return <IntegrationsGitLabPage workspaceId={workspaceId} />;
|
||||
case "jira":
|
||||
return <IntegrationsJiraPage workspaceId={workspaceId} />;
|
||||
case "linear":
|
||||
return <IntegrationsLinearPage workspaceId={workspaceId} />;
|
||||
case "sentry":
|
||||
return <IntegrationsSentryPage workspaceId={workspaceId} />;
|
||||
case "slack":
|
||||
return <IntegrationsSlackPage workspaceId={workspaceId} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function renderUpdatesRoute() {
|
||||
return (
|
||||
<SystemPageShell
|
||||
@@ -330,7 +368,7 @@ function SettingsRouteBootstrap({ pathname }: { pathname: string }) {
|
||||
let cancelled = false;
|
||||
|
||||
async function bootstrap() {
|
||||
const initialState = await loadSettingsInitialState(pathname);
|
||||
const initialState = await loadSettingsInitialState();
|
||||
if (!cancelled && Object.keys(initialState).length > 0) {
|
||||
store.getState().hydrate(initialState);
|
||||
}
|
||||
@@ -346,7 +384,7 @@ function SettingsRouteBootstrap({ pathname }: { pathname: string }) {
|
||||
return null;
|
||||
}
|
||||
|
||||
async function loadSettingsInitialState(pathname: string): Promise<Partial<AppState>> {
|
||||
async function loadSettingsInitialState(): Promise<Partial<AppState>> {
|
||||
const [workspaces, executors, agents, discovery, available, userSettingsResponse] =
|
||||
await Promise.all([
|
||||
listWorkspaces({ cache: "no-store" }).catch(() => ({ workspaces: [] })),
|
||||
@@ -358,7 +396,6 @@ async function loadSettingsInitialState(pathname: string): Promise<Partial<AppSt
|
||||
]);
|
||||
|
||||
return buildSettingsInitialStateForRoute({
|
||||
pathname,
|
||||
workspaces: workspaces.workspaces,
|
||||
executors: executors.executors,
|
||||
agents: agents.agents,
|
||||
@@ -370,7 +407,6 @@ async function loadSettingsInitialState(pathname: string): Promise<Partial<AppSt
|
||||
}
|
||||
|
||||
export function buildSettingsInitialStateForRoute({
|
||||
pathname,
|
||||
workspaces,
|
||||
executors,
|
||||
agents,
|
||||
@@ -382,7 +418,6 @@ export function buildSettingsInitialStateForRoute({
|
||||
const workspaceItems = workspaces.map(mapWorkspaceItem);
|
||||
const activeWorkspaceId = resolveSettingsActiveWorkspaceId(
|
||||
workspaceItems,
|
||||
matchSingle(pathname, /^\/settings\/workspace\/([^/]+)/),
|
||||
readActiveWorkspaceCookie(),
|
||||
userSettingsResponse?.settings?.workspace_id ?? null,
|
||||
);
|
||||
@@ -513,13 +548,15 @@ function SettingsRouteFallback({ pathname }: { pathname: string }) {
|
||||
|
||||
function matchSingle(pathname: string, pattern: RegExp): string | null {
|
||||
const match = pathname.match(pattern);
|
||||
return match?.[1] ? decodeURIComponent(match[1]) : null;
|
||||
return safeDecodePathSegment(match?.[1]);
|
||||
}
|
||||
|
||||
function matchDouble(pathname: string, pattern: RegExp): [string, string] | null {
|
||||
const match = pathname.match(pattern);
|
||||
if (!match?.[1] || !match[2]) return null;
|
||||
return [decodeURIComponent(match[1]), decodeURIComponent(match[2])];
|
||||
const first = safeDecodePathSegment(match[1]);
|
||||
const second = safeDecodePathSegment(match[2]);
|
||||
return first && second ? [first, second] : null;
|
||||
}
|
||||
|
||||
function normalizeSettingsPath(pathname: string): string {
|
||||
|
||||
Reference in New Issue
Block a user