TitleEditor.tsx

  1import { Pencil } from "lucide-react";
  2import { useState, useRef, useEffect } from "react";
  3
  4import { useBugSetTitleMutation, BugDetailDocument } from "@/__generated__/graphql";
  5import { Button } from "@/components/ui/button";
  6import { Input } from "@/components/ui/input";
  7import { useAuth } from "@/lib/auth";
  8
  9interface TitleEditorProps {
 10  bugPrefix: string;
 11  title: string;
 12  humanId: string;
 13  /** Current repo slug, passed as `ref` in refetch query variables. */
 14  ref_?: string | null;
 15}
 16
 17// Inline title editor in BugDetailPage. Shows the title as plain text with a
 18// pencil icon on hover (auth-gated). Enter saves, Escape cancels.
 19export function TitleEditor({ bugPrefix, title, humanId, ref_ }: TitleEditorProps) {
 20  const { user } = useAuth();
 21  const [editing, setEditing] = useState(false);
 22  const [value, setValue] = useState(title);
 23  const inputRef = useRef<HTMLInputElement>(null);
 24
 25  const [setTitle, { loading }] = useBugSetTitleMutation({
 26    refetchQueries: [{ query: BugDetailDocument, variables: { ref: ref_, prefix: bugPrefix } }],
 27  });
 28
 29  useEffect(() => {
 30    if (editing) inputRef.current?.focus();
 31  }, [editing]);
 32
 33  // Keep local value in sync if title prop changes (e.g. after refetch)
 34  useEffect(() => {
 35    if (!editing) setValue(title);
 36  }, [title, editing]);
 37
 38  async function handleSave() {
 39    const trimmed = value.trim();
 40    if (trimmed && trimmed !== title) {
 41      await setTitle({ variables: { input: { prefix: bugPrefix, title: trimmed } } });
 42    }
 43    setEditing(false);
 44  }
 45
 46  function handleKeyDown(e: React.KeyboardEvent) {
 47    if (e.key === "Enter") void handleSave();
 48    if (e.key === "Escape") {
 49      setValue(title);
 50      setEditing(false);
 51    }
 52  }
 53
 54  if (editing) {
 55    return (
 56      <div className="flex items-start gap-2">
 57        <Input
 58          ref={inputRef}
 59          value={value}
 60          onChange={(e) => setValue(e.target.value)}
 61          onKeyDown={handleKeyDown}
 62          className="text-xl font-semibold"
 63          disabled={loading}
 64        />
 65        <Button
 66          size="sm"
 67          onClick={() => {
 68            void handleSave();
 69          }}
 70          disabled={loading || !value.trim()}
 71        >
 72          Save
 73        </Button>
 74        <Button
 75          size="sm"
 76          variant="ghost"
 77          onClick={() => {
 78            setValue(title);
 79            setEditing(false);
 80          }}
 81        >
 82          Cancel
 83        </Button>
 84      </div>
 85    );
 86  }
 87
 88  return (
 89    <div className="group flex items-start gap-2">
 90      <h1 className="flex-1 text-2xl font-semibold leading-tight text-foreground">
 91        {title}
 92        <span className="ml-2 text-xl font-normal text-muted-foreground">#{humanId}</span>
 93      </h1>
 94      {user && (
 95        <button
 96          onClick={() => setEditing(true)}
 97          className="mt-1 shrink-0 text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover:opacity-100"
 98          title="Edit title"
 99        >
100          <Pencil className="size-4" />
101        </button>
102      )}
103    </div>
104  );
105}