Vectrum
Sign In
172 lines · 6.5 KB · TypeScript
"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<number | null>(defaults?.assigneeId ?? null);
  const [labelIds, setLabelIds] = useState<number[]>(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>
  );
}