240 lines
7.5 KiB
TypeScript
240 lines
7.5 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef, useState } from "react";
|
|
import Link from "@/components/routing/app-link";
|
|
import { Button } from "@kandev/ui/button";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuLabel,
|
|
DropdownMenuTrigger,
|
|
} from "@kandev/ui/dropdown-menu";
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
|
import {
|
|
IconBrandGithub,
|
|
IconBrandGitlab,
|
|
IconHexagon,
|
|
IconPlugConnected,
|
|
IconTicket,
|
|
} from "@tabler/icons-react";
|
|
import { useJiraAvailable } from "@/hooks/domains/jira/use-jira-availability";
|
|
import { useLinearAvailable } from "@/hooks/domains/linear/use-linear-availability";
|
|
import { useGitHubStatus } from "@/hooks/domains/github/use-github-status";
|
|
import { useGitLabAvailable } from "@/hooks/domains/gitlab/use-task-mr";
|
|
import { useAppStore } from "@/components/state-provider";
|
|
import type { GitHubStatus } from "@/lib/types/github";
|
|
|
|
type MobileIntegrationsSectionProps = {
|
|
onNavigate: () => void;
|
|
};
|
|
|
|
type IntegrationId = "github" | "gitlab" | "jira" | "linear";
|
|
|
|
type IntegrationLink = {
|
|
id: IntegrationId;
|
|
label: string;
|
|
href: string;
|
|
};
|
|
|
|
type IntegrationAvailability = {
|
|
githubReady: boolean;
|
|
gitlabReady: boolean;
|
|
jiraAvailable: boolean;
|
|
linearAvailable: boolean;
|
|
};
|
|
|
|
const INTEGRATION_LINKS: IntegrationLink[] = [
|
|
{ id: "github", label: "GitHub", href: "/github" },
|
|
{ id: "gitlab", label: "GitLab", href: "/gitlab" },
|
|
{ id: "jira", label: "Jira", href: "/jira" },
|
|
{ id: "linear", label: "Linear", href: "/linear" },
|
|
];
|
|
|
|
const INTEGRATION_ICONS = {
|
|
github: IconBrandGithub,
|
|
gitlab: IconBrandGitlab,
|
|
jira: IconTicket,
|
|
linear: IconHexagon,
|
|
} satisfies Record<IntegrationId, typeof IconBrandGithub>;
|
|
|
|
const HOVER_CLOSE_DELAY_MS = 180;
|
|
|
|
export function getAvailableIntegrationLinks({
|
|
githubReady,
|
|
gitlabReady,
|
|
jiraAvailable,
|
|
linearAvailable,
|
|
}: IntegrationAvailability): IntegrationLink[] {
|
|
return INTEGRATION_LINKS.filter((link) => {
|
|
if (link.id === "github") return githubReady;
|
|
if (link.id === "gitlab") return gitlabReady;
|
|
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) };
|
|
}
|
|
|
|
export function useConfiguredIntegrationLinks(): IntegrationLink[] {
|
|
// Jira and Linear are per-workspace integrations, so their availability must
|
|
// be checked against the active workspace. Omitting the id makes the backend
|
|
// fall back to a legacy default-workspace resolver that can point at the
|
|
// wrong workspace, hiding a configured integration from the sidebar. GitHub
|
|
// and GitLab are install-wide and don't need the workspace id.
|
|
const activeWorkspaceId = useAppStore((s) => s.workspaces.activeId);
|
|
const activeWorkspaceExists = useAppStore((s) =>
|
|
s.workspaces.items.some((item) => item.id === s.workspaces.activeId),
|
|
);
|
|
// Guard against a stale active id: if the active workspace was removed but
|
|
// activeId was not reconciled (e.g. setWorkspaces keeps a non-null id),
|
|
// scoping to the deleted id would return no config and hide the links even
|
|
// when another workspace is configured. Fall back to null so the backend's
|
|
// default-workspace resolution applies instead.
|
|
const scopedWorkspaceId = activeWorkspaceExists ? activeWorkspaceId : null;
|
|
const { status, loading } = useGitHubStatus();
|
|
const gitlabAvailable = useGitLabAvailable();
|
|
const jiraAvailable = useJiraAvailable(scopedWorkspaceId);
|
|
const linearAvailable = useLinearAvailable(scopedWorkspaceId);
|
|
const githubStatus = getGitHubIntegrationStatus(status, loading);
|
|
|
|
return getAvailableIntegrationLinks({
|
|
githubReady: githubStatus.ready,
|
|
gitlabReady: gitlabAvailable,
|
|
jiraAvailable,
|
|
linearAvailable,
|
|
});
|
|
}
|
|
|
|
export function IntegrationsMenu() {
|
|
const links = useConfiguredIntegrationLinks();
|
|
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 IntegrationsTopbarLinks() {
|
|
const links = useConfiguredIntegrationLinks();
|
|
if (links.length === 0) return null;
|
|
|
|
return (
|
|
<>
|
|
{links.map((link) => {
|
|
const Icon = INTEGRATION_ICONS[link.id];
|
|
return (
|
|
<Tooltip key={link.id}>
|
|
<TooltipTrigger asChild>
|
|
<Button asChild variant="outline" size="icon-lg" className="cursor-pointer">
|
|
<Link href={link.href} aria-label={link.label}>
|
|
<Icon className="h-4 w-4" />
|
|
</Link>
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>{link.label}</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
})}
|
|
</>
|
|
);
|
|
}
|
|
|
|
export function MobileIntegrationsSection({ onNavigate }: MobileIntegrationsSectionProps) {
|
|
const links = useConfiguredIntegrationLinks();
|
|
|
|
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>
|
|
);
|
|
}
|