"use client";
import { useRef, useState, useTransition } from "react";
import { Dialog, Field, IconButton, Kbd, Switch, Textarea, Tooltip } from "@unlocalhosted/metalui";
import { createIssueAction } from "@/app/actions/issues";
import { ChevronRightIcon, PlusIcon, XIcon } from "@/components/icons";
import { Alert, Button } from "@/components/ui";
import type { NewIssueInput, Status } from "@/lib/issue-meta";
import { AssigneePicker, DueDateField, EstimatePicker, LabelsPicker, PriorityPicker, StatusPicker } from "./properties";
import { useTracker } from "./tracker-context";
export type IssueDefaults = Partial<Omit<NewIssueInput, "title" | "body">>;
/** Title, description and properties for a new issue. Submit with the button or ⌘/Ctrl + Enter. */
export function IssueComposer({
defaults,
onCreated,
onCancel,
offerCreateMore,
}: {
defaults?: IssueDefaults;
onCreated: (number: number, createMore: boolean) => void;
onCancel?: () => void;
offerCreateMore?: boolean;
}) {
const { owner, name, canTriage } = useTracker();
const [title, setTitle] = useState("");
const [body, setBody] = useState("");
const [status, setStatus] = useState<Status>(defaults?.status ?? "todo");
const [priority, setPriority] = useState(defaults?.priority ?? 0);
const [assigneeId, setAssigneeId] = useState<string | null>(defaults?.assigneeId ?? null);
const [labelIds, setLabelIds] = useState<string[]>(defaults?.labelIds ?? []);
const [estimate, setEstimate] = useState<number | null>(defaults?.estimate ?? null);
const [dueDate, setDueDate] = useState<string | null>(defaults?.dueDate ?? null);
const [createMore, setCreateMore] = useState(false);
const [error, setError] = useState<string>();
const [pending, startTransition] = useTransition();
const titleRef = useRef<HTMLInputElement>(null);
function submit() {
if (!title.trim() || pending) return;
startTransition(async () => {
const input: NewIssueInput = { title, body, ...(canTriage ? { status, priority, assigneeId, labelIds, estimate, dueDate } : {}) };
const result = await createIssueAction(owner, name, input);
if (result.error || !result.number) {
setError(result.error ?? "Couldn't create the issue.");
return;
}
setError(undefined);
setTitle("");
setBody("");
onCreated(result.number, createMore);
titleRef.current?.focus();
});
}
return (
<form
onSubmit={(e) => {
e.preventDefault();
submit();
}}
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
submit();
}
}}
>
<div className="space-y-2.5 px-5 pt-4">
<Field size="large">
<Field.Input
ref={titleRef}
autoFocus
value={title}
onChange={(e) => setTitle(e.target.value)}
maxLength={256}
placeholder="Issue title"
aria-label="Title"
className="font-semibold"
/>
</Field>
<Textarea
value={body}
onChange={(e) => setBody(e.target.value)}
minRows={5}
maxRows={14}
placeholder="Add description… Markdown is supported."
aria-label="Description"
/>
</div>
{canTriage && (
<div className="flex flex-wrap gap-1.5 px-5 pt-3">
<StatusPicker value={status} onChange={setStatus} />
<PriorityPicker value={priority} onChange={setPriority} />
<AssigneePicker value={assigneeId} onChange={setAssigneeId} />
<LabelsPicker value={labelIds} onChange={setLabelIds} />
<EstimatePicker value={estimate} onChange={setEstimate} />
<DueDateField value={dueDate} onChange={setDueDate} />
</div>
)}
{error && (
<div className="px-5 pt-3">
<Alert>{error}</Alert>
</div>
)}
<div className="rule-t mt-4 flex items-center gap-3 px-5 py-3">
{offerCreateMore && (
<label className="flex cursor-pointer items-center gap-2 type-ui text-ink2 select-none">
<Switch size="small" checked={createMore} onCheckedChange={setCreateMore} />
Create more
</label>
)}
<div className="ml-auto flex items-center gap-2">
{onCancel && (
<Button type="button" variant="invisible" onClick={onCancel}>
Cancel
</Button>
)}
<Button type="submit" variant="primary" icon={<PlusIcon />} disabled={pending || !title.trim()}>
{pending ? "Creating…" : "Create issue"}
<span className="hidden items-center opacity-70 sm:inline-flex" aria-hidden>
<Kbd size="small" surface="plain">
⌘↵
</Kbd>
</span>
</Button>
</div>
</div>
</form>
);
}
/** The composer on MetalUI's dialog plate, over the scrim. Esc or the close key dismisses it. */
export function CreateIssueDialog({ open, onClose, defaults }: { open: boolean; onClose: () => void; defaults?: IssueDefaults }) {
const { owner, name, notify } = useTracker();
return (
<Dialog open={open} onOpenChange={(next) => !next && onClose()}>
<Dialog.Popup
aria-label="New issue"
className="[--mu-r-dialog-self-gap:0px] [--mu-r-dialog-self-pad:0px] [--mu-r-dialog-self-width:680px]"
>
<div className="flex items-center gap-2 px-5 pt-4 pr-14 type-ui text-ink2">
<span className="well rounded-[7px] px-1.5 py-0.5 font-mono text-[11px] text-ink">
{owner}/{name}
</span>
<ChevronRightIcon width={12} height={12} className="text-ink3" />
<Dialog.Title>New issue</Dialog.Title>
</div>
{/* Remount on every open so the fields start fresh with the current defaults. */}
{open && (
<IssueComposer
defaults={defaults}
offerCreateMore
onCancel={onClose}
onCreated={(number, more) => {
notify(`Created issue #${number}`, { tone: "success" });
if (!more) onClose();
}}
/>
)}
{/* Last in the DOM so the dialog's initial focus lands on the title, not here. */}
<span className="absolute top-3 right-3">
<Tooltip label="Close" shortcut="⎋">
<IconButton variant="ghost" label="Close" icon={<XIcon />} onClick={onClose} />
</Tooltip>
</span>
</Dialog.Popup>
</Dialog>
);
}