982cdb63eb
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
259 lines
7.2 KiB
TypeScript
259 lines
7.2 KiB
TypeScript
"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<HTMLElement | null>;
|
|
};
|
|
|
|
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 (
|
|
<div className="space-y-2">
|
|
<Label htmlFor="task-github-issue-input">{t("task:issue")}</Label>
|
|
<Input
|
|
id="task-github-issue-input"
|
|
data-testid="task-github-issue-input"
|
|
value={input}
|
|
onChange={(event) => setInput(event.target.value)}
|
|
placeholder={placeholder}
|
|
disabled={submitting}
|
|
/>
|
|
{error && (
|
|
<p className="text-xs text-destructive" data-testid="task-github-issue-error">
|
|
{error}
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TaskGitHubIssueActions({
|
|
currentLabel,
|
|
submitting,
|
|
onCancel,
|
|
onSubmit,
|
|
onUnlink,
|
|
}: DialogActionsProps) {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<DialogFooter className="gap-2 sm:justify-between">
|
|
{currentLabel ? (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
className="cursor-pointer"
|
|
onClick={onUnlink}
|
|
disabled={submitting}
|
|
>
|
|
{t("task:unlink")}
|
|
</Button>
|
|
) : (
|
|
<span />
|
|
)}
|
|
<div className="flex gap-2">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
className="cursor-pointer"
|
|
onClick={onCancel}
|
|
disabled={submitting}
|
|
>
|
|
{t("common:cancel")}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
className="cursor-pointer"
|
|
onClick={onSubmit}
|
|
disabled={submitting}
|
|
data-testid="task-github-issue-submit"
|
|
>
|
|
{submitting ? t("task:saving") : t("common:save")}
|
|
</Button>
|
|
</div>
|
|
</DialogFooter>
|
|
);
|
|
}
|
|
|
|
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<string | null>(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 (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent
|
|
className="w-[calc(100vw-2rem)] sm:max-w-lg"
|
|
onCloseAutoFocus={createFocusReturnHandler(focusReturnRef)}
|
|
>
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
{currentLabel ? t("task:changeGithubIssue") : t("task:linkGithubIssue")}
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
{inferredRepo
|
|
? t("task:useAFullIssueUrlOr", { owner: inferredRepo.owner, repo: inferredRepo.repo })
|
|
: t("task:useAFullGithubIssueUrl")}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<TaskGitHubIssueFields
|
|
input={input}
|
|
setInput={setInput}
|
|
inferredRepo={inferredRepo}
|
|
submitting={submitting}
|
|
error={error}
|
|
/>
|
|
<TaskGitHubIssueActions
|
|
currentLabel={currentLabel}
|
|
submitting={submitting}
|
|
onCancel={() => onOpenChange(false)}
|
|
onSubmit={submit}
|
|
onUnlink={unlink}
|
|
/>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|