Files
kandev/apps/web/components/integrations/integrations-menu.tsx
T
José Almeida 68c94dfa3a feat: refine workbench chrome (#761)
* docs: define product and design direction

* style: establish restrained workbench primitives

* refactor: declutter board and task topbars

* style: align dockview panel chrome

* fix: align task topbar vcs control sizing

* refactor: surface integrations navigation

* fix: repair integrations and task creation workflow

* refactor: include linear in integrations navigation

* style: restore familiar workbench tones

* fix: add dockview tab breathing room

* fix: show configured integration shortcuts

* fix: hover open integrations menu

* fix: stabilize integrations hover menu

* fix: align home and dockview topbars

* fix: normalize home topbar controls

* fix: compact home topbar height

* fix: simplify home chrome and split buttons

* fix: move integrations to left chrome

* fix: restore home stats shortcut

* fix: add dockview tab breathing room

* fix: border home stats button

* fix: streamline dockview topbar actions

* fix: align task split button seam

* fix: address review feedback

* fix: restore topbar e2e affordances

* fix: stabilize workflow e2e isolation

* fix: add task topbar action overflow

* fix: address PR review feedback
2026-05-01 21:02:41 +01:00

191 lines
5.5 KiB
TypeScript

"use client";
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import { Button } from "@kandev/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@kandev/ui/dropdown-menu";
import { IconBrandGithub, IconHexagon, IconPlugConnected, IconTicket } from "@tabler/icons-react";
import { useJiraAvailable } from "@/components/jira/my-jira/use-jira-availability";
import { useLinearAvailable } from "@/components/linear/use-linear-availability";
import { useGitHubStatus } from "@/hooks/domains/github/use-github-status";
import type { GitHubStatus } from "@/lib/types/github";
type IntegrationsProps = {
workspaceId?: string;
};
type MobileIntegrationsSectionProps = IntegrationsProps & {
onNavigate: () => void;
};
type IntegrationId = "github" | "jira" | "linear";
type IntegrationLink = {
id: IntegrationId;
label: string;
href: string;
};
type IntegrationAvailability = {
githubReady: boolean;
jiraAvailable: boolean;
linearAvailable: boolean;
};
const INTEGRATION_LINKS: IntegrationLink[] = [
{ id: "github", label: "GitHub", href: "/github" },
{ id: "jira", label: "Jira", href: "/jira" },
{ id: "linear", label: "Linear", href: "/linear" },
];
const INTEGRATION_ICONS = {
github: IconBrandGithub,
jira: IconTicket,
linear: IconHexagon,
} satisfies Record<IntegrationId, typeof IconBrandGithub>;
const HOVER_CLOSE_DELAY_MS = 180;
export function getAvailableIntegrationLinks({
githubReady,
jiraAvailable,
linearAvailable,
}: IntegrationAvailability): IntegrationLink[] {
return INTEGRATION_LINKS.filter((link) => {
if (link.id === "github") return githubReady;
if (link.id === "jira") return jiraAvailable;
return linearAvailable;
});
}
function getStatusLabel(loading: boolean | undefined): string {
return loading ? "Checking" : "Setup";
}
export function getGitHubIntegrationStatus(status: GitHubStatus | null, loading: boolean) {
if (status?.authenticated) return { ready: true, label: "Connected" };
if (status?.token_configured) return { ready: true, label: "Configured" };
return { ready: false, label: getStatusLabel(loading) };
}
function useConfiguredIntegrationLinks(workspaceId: string | undefined): IntegrationLink[] {
const { status, loading } = useGitHubStatus();
const jiraAvailable = useJiraAvailable(workspaceId);
const linearAvailable = useLinearAvailable(workspaceId);
const githubStatus = getGitHubIntegrationStatus(status, loading);
return getAvailableIntegrationLinks({
githubReady: githubStatus.ready,
jiraAvailable,
linearAvailable,
});
}
export function IntegrationsMenu({ workspaceId }: IntegrationsProps) {
const links = useConfiguredIntegrationLinks(workspaceId);
const [open, setOpen] = useState(false);
const closeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (closeTimeoutRef.current) clearTimeout(closeTimeoutRef.current);
};
}, []);
const clearCloseTimeout = () => {
if (!closeTimeoutRef.current) return;
clearTimeout(closeTimeoutRef.current);
closeTimeoutRef.current = null;
};
const openOnHover = () => {
clearCloseTimeout();
setOpen((current) => (current ? current : true));
};
const closeAfterHover = () => {
clearCloseTimeout();
closeTimeoutRef.current = setTimeout(() => setOpen(false), HOVER_CLOSE_DELAY_MS);
};
const handleOpenChange = (nextOpen: boolean) => {
clearCloseTimeout();
setOpen(nextOpen);
};
if (links.length === 0) return null;
return (
<DropdownMenu open={open} onOpenChange={handleOpenChange} modal={false}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon-lg"
className="cursor-pointer text-muted-foreground hover:text-foreground"
aria-label="Integrations"
onPointerEnter={openOnHover}
onPointerLeave={closeAfterHover}
>
<IconPlugConnected className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-48"
onPointerEnter={openOnHover}
onPointerLeave={closeAfterHover}
>
<DropdownMenuLabel>Integrations</DropdownMenuLabel>
{links.map((link) => {
const Icon = INTEGRATION_ICONS[link.id];
return (
<DropdownMenuItem key={link.id} asChild className="cursor-pointer">
<Link href={link.href}>
<Icon className="h-4 w-4 text-muted-foreground" />
{link.label}
</Link>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}
export function MobileIntegrationsSection({
workspaceId,
onNavigate,
}: MobileIntegrationsSectionProps) {
const links = useConfiguredIntegrationLinks(workspaceId);
if (links.length === 0) return null;
return (
<div className="space-y-3">
<div className="text-sm font-medium">Integrations</div>
{links.map((link) => {
const Icon = INTEGRATION_ICONS[link.id];
return (
<Button
key={link.id}
asChild
variant="outline"
className="w-full cursor-pointer justify-start gap-2"
>
<Link href={link.href} onClick={onNavigate}>
<Icon className="h-4 w-4" />
<span className="flex-1 text-left">{link.label}</span>
</Link>
</Button>
);
})}
</div>
);
}