"use client"; import { useEffect, useMemo, useState, type RefObject } from "react"; import { Button } from "@kandev/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@kandev/ui/dialog"; import { Input } from "@kandev/ui/input"; import { Label } from "@kandev/ui/label"; import { useToast } from "@/components/toast-provider"; import { linkTaskIssue, unlinkTaskIssue } from "@/lib/api/domains/github-api"; import { createFocusReturnHandler } from "@/lib/dialog-focus-return"; import type { Repository } from "@/lib/types/http"; import { useTranslation } from "react-i18next"; /** URL shape the user types verbatim — protocol, not copy. Passed into the * placeholder message as an interpolation value so it survives translation. */ const GITHUB_ISSUE_URL_EXAMPLE = "github.com/owner/repo/issues/1470"; type TaskIssue = { id: string; title: string; repositoryId?: string; issueUrl?: string; issueNumber?: number; repositories?: Array<{ repository_id: string }>; }; type GitHubRepoRef = { owner: string; repo: string; }; type TaskGitHubIssueDialogProps = { open: boolean; onOpenChange: (open: boolean) => void; task: TaskIssue; repositories: Repository[]; /** Element to return keyboard focus to on close (AC-TASKS-TASK-ACTIONS-MENU-001.12). * Omitted callers keep Radix's default restore-to-previously-focused-element behavior. */ focusReturnRef?: RefObject; }; type DialogFieldsProps = { input: string; setInput: (input: string) => void; inferredRepo: GitHubRepoRef | null; submitting: boolean; error: string | null; }; type DialogActionsProps = { currentLabel: string | null; submitting: boolean; onCancel: () => void; onSubmit: () => void; onUnlink: () => void; }; function githubReposForTask(task: TaskIssue, repositories: Repository[]): GitHubRepoRef[] { const ids = new Set((task.repositories ?? []).map((repo) => repo.repository_id)); if (task.repositoryId) ids.add(task.repositoryId); return repositories .filter((repo) => ids.has(repo.id) && repo.provider === "github") .map((repo) => ({ owner: repo.provider_owner, repo: repo.provider_name })) .filter((repo) => repo.owner && repo.repo); } function issuePayload(input: string, inferredRepo: GitHubRepoRef | null) { const trimmed = input.trim(); if (/^#?\d+$/.test(trimmed) && inferredRepo) { return { issue: trimmed, owner: inferredRepo.owner, repo: inferredRepo.repo, number: Number(trimmed.replace(/^#/, "")), }; } return { issue: trimmed }; } function TaskGitHubIssueFields({ input, setInput, inferredRepo, submitting, error, }: DialogFieldsProps) { const { t } = useTranslation(); const placeholder = inferredRepo ? t("task:githubIssueRefPlaceholder", { example: GITHUB_ISSUE_URL_EXAMPLE }) : GITHUB_ISSUE_URL_EXAMPLE; return (
setInput(event.target.value)} placeholder={placeholder} disabled={submitting} /> {error && (

{error}

)}
); } function TaskGitHubIssueActions({ currentLabel, submitting, onCancel, onSubmit, onUnlink, }: DialogActionsProps) { const { t } = useTranslation(); return ( {currentLabel ? ( ) : ( )}
); } export function TaskGitHubIssueDialog({ open, onOpenChange, task, repositories, focusReturnRef, }: TaskGitHubIssueDialogProps) { const { t } = useTranslation(); const { toast } = useToast(); const [input, setInput] = useState(""); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); let currentLabel: string | null = null; if (task.issueNumber) { currentLabel = `#${task.issueNumber}`; } else if (task.issueUrl) { currentLabel = t("task:linkedIssue"); } const githubRepos = useMemo(() => githubReposForTask(task, repositories), [task, repositories]); const inferredRepo = githubRepos.length === 1 ? githubRepos[0] : null; useEffect(() => { if (open) { setInput(task.issueUrl ?? ""); setError(null); } }, [open, task.issueUrl]); const submit = async () => { if (!input.trim()) { setError(t("task:enterGithubIssueUrlOrNumber")); return; } setSubmitting(true); setError(null); try { await linkTaskIssue(task.id, issuePayload(input, inferredRepo)); toast({ description: t("task:githubIssueLinked"), variant: "success" }); onOpenChange(false); } catch (err) { setError(err instanceof Error ? err.message : t("task:failedToLinkGithubIssue")); } finally { setSubmitting(false); } }; const unlink = async () => { setSubmitting(true); setError(null); try { await unlinkTaskIssue(task.id); toast({ description: t("task:githubIssueUnlinked"), variant: "success" }); onOpenChange(false); } catch (err) { setError(err instanceof Error ? err.message : t("task:failedToUnlinkGithubIssue")); } finally { setSubmitting(false); } }; return ( {currentLabel ? t("task:changeGithubIssue") : t("task:linkGithubIssue")} {inferredRepo ? t("task:useAFullIssueUrlOr", { owner: inferredRepo.owner, repo: inferredRepo.repo }) : t("task:useAFullGithubIssueUrl")} onOpenChange(false)} onSubmit={submit} onUnlink={unlink} /> ); }