diff --git a/apps/fabro-web/app/components/editable-run-title.test.tsx b/apps/fabro-web/app/components/editable-run-title.test.tsx new file mode 100644 index 000000000..b94121512 --- /dev/null +++ b/apps/fabro-web/app/components/editable-run-title.test.tsx @@ -0,0 +1,192 @@ +import { describe, expect, test } from "bun:test"; +import TestRenderer, { act } from "react-test-renderer"; +import { SWRConfig } from "swr"; + +import { EditableRunTitle } from "./editable-run-title"; +import { ToastProvider } from "./toast"; +import { generatedAxios } from "../lib/api-client"; + +function render(node: React.ReactNode): TestRenderer.ReactTestRenderer { + let tree: TestRenderer.ReactTestRenderer | undefined; + act(() => { + tree = TestRenderer.create( + new Map(), dedupingInterval: 0 }}> + {node} + , + ); + }); + return tree!; +} + +function instanceText(instance: TestRenderer.ReactTestInstance): string { + const parts: string[] = []; + for (const child of instance.children) { + if (typeof child === "string") parts.push(child); + else parts.push(instanceText(child)); + } + return parts.join(""); +} + +function findEditButton( + tree: TestRenderer.ReactTestRenderer, +): TestRenderer.ReactTestInstance { + return tree.root.findByProps({ "aria-label": "Edit run title" }); +} + +function findInput( + tree: TestRenderer.ReactTestRenderer, +): TestRenderer.ReactTestInstance { + return tree.root.findByProps({ "aria-label": "Run title" }); +} + +describe("EditableRunTitle", () => { + test("renders the run title with an edit affordance", () => { + const tree = render(); + expect(instanceText(findEditButton(tree))).toContain("Initial title"); + }); + + test("clicking the title swaps to an input pre-filled with the current value", () => { + const tree = render(); + act(() => { + findEditButton(tree).props.onClick(); + }); + expect(findInput(tree).props.value).toBe("Initial title"); + }); + + test("Enter submits a PATCH and the input collapses back to the heading", async () => { + const submitted: unknown[] = []; + const originalAdapter = generatedAxios.defaults.adapter; + generatedAxios.defaults.adapter = async (config) => { + submitted.push({ url: config.url, method: config.method, body: JSON.parse(String(config.data)) }); + return { + data: { id: "run-1", title: "Renamed title" }, + status: 200, + statusText: "OK", + headers: {}, + config, + }; + }; + + try { + const tree = render(); + act(() => { + findEditButton(tree).props.onClick(); + }); + const input = findInput(tree); + act(() => { + input.props.onChange({ target: { value: "Renamed title" } }); + }); + await act(async () => { + input.props.onKeyDown({ key: "Enter", preventDefault: () => {} }); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(submitted).toEqual([ + { url: "/api/v1/runs/run-1", method: "patch", body: { title: "Renamed title" } }, + ]); + } finally { + generatedAxios.defaults.adapter = originalAdapter; + } + }); + + test("Escape exits without sending a request", () => { + let calls = 0; + const originalAdapter = generatedAxios.defaults.adapter; + generatedAxios.defaults.adapter = async (config) => { + calls += 1; + return { + data: undefined, + status: 204, + statusText: "No Content", + headers: {}, + config, + }; + }; + + try { + const tree = render(); + act(() => { + findEditButton(tree).props.onClick(); + }); + const input = findInput(tree); + act(() => { + input.props.onChange({ target: { value: "Discarded" } }); + }); + act(() => { + input.props.onKeyDown({ key: "Escape", preventDefault: () => {} }); + }); + + expect(calls).toBe(0); + expect(instanceText(findEditButton(tree))).toContain("Initial title"); + } finally { + generatedAxios.defaults.adapter = originalAdapter; + } + }); + + test("submitting an empty title does not send a request", async () => { + let calls = 0; + const originalAdapter = generatedAxios.defaults.adapter; + generatedAxios.defaults.adapter = async (config) => { + calls += 1; + return { + data: undefined, + status: 200, + statusText: "OK", + headers: {}, + config, + }; + }; + + try { + const tree = render(); + act(() => { + findEditButton(tree).props.onClick(); + }); + const input = findInput(tree); + act(() => { + input.props.onChange({ target: { value: " " } }); + }); + await act(async () => { + input.props.onKeyDown({ key: "Enter", preventDefault: () => {} }); + await Promise.resolve(); + }); + + expect(calls).toBe(0); + } finally { + generatedAxios.defaults.adapter = originalAdapter; + } + }); + + test("unchanged title on Enter exits without a request", async () => { + let calls = 0; + const originalAdapter = generatedAxios.defaults.adapter; + generatedAxios.defaults.adapter = async (config) => { + calls += 1; + return { + data: undefined, + status: 200, + statusText: "OK", + headers: {}, + config, + }; + }; + + try { + const tree = render(); + act(() => { + findEditButton(tree).props.onClick(); + }); + const input = findInput(tree); + await act(async () => { + input.props.onKeyDown({ key: "Enter", preventDefault: () => {} }); + await Promise.resolve(); + }); + + expect(calls).toBe(0); + expect(instanceText(findEditButton(tree))).toContain("Initial title"); + } finally { + generatedAxios.defaults.adapter = originalAdapter; + } + }); +}); diff --git a/apps/fabro-web/app/components/editable-run-title.tsx b/apps/fabro-web/app/components/editable-run-title.tsx new file mode 100644 index 000000000..8d16fbdba --- /dev/null +++ b/apps/fabro-web/app/components/editable-run-title.tsx @@ -0,0 +1,133 @@ +import { useEffect, useRef, useState } from "react"; +import { PencilIcon } from "@heroicons/react/16/solid"; + +import { ApiError } from "../lib/api-client"; +import { useUpdateRunTitle } from "../lib/mutations"; +import { InlineMarkdown } from "./inline-markdown"; +import { useToast } from "./toast"; + +const TITLE_MAX_LENGTH = 100; + +function focusInputNextFrame(callback: () => void): void { + if (typeof requestAnimationFrame === "function") { + requestAnimationFrame(callback); + } else { + setTimeout(callback, 0); + } +} + +export function EditableRunTitle({ runId, title }: { runId: string; title: string }) { + const [isEditing, setIsEditing] = useState(false); + const [draft, setDraft] = useState(title); + const submittedRef = useRef(false); + const inputRef = useRef(null); + const updateMutation = useUpdateRunTitle(runId); + const { push } = useToast(); + const isSaving = updateMutation.isMutating; + + useEffect(() => { + if (!isEditing) setDraft(title); + }, [title, isEditing]); + + const enterEdit = () => { + setDraft(title); + submittedRef.current = false; + setIsEditing(true); + focusInputNextFrame(() => { + inputRef.current?.focus(); + inputRef.current?.select(); + }); + }; + + const exitEdit = () => { + setIsEditing(false); + setDraft(title); + }; + + const submit = async () => { + if (submittedRef.current) return; + const trimmed = draft.trim(); + if (trimmed === title.trim()) { + exitEdit(); + return; + } + if (trimmed.length === 0) { + push({ message: "Run title can't be blank.", tone: "error" }); + inputRef.current?.focus(); + return; + } + submittedRef.current = true; + try { + await updateMutation.trigger({ title: trimmed }); + setIsEditing(false); + push({ message: "Run title updated." }); + } catch (error) { + submittedRef.current = false; + const message = error instanceof ApiError && error.message + ? error.message + : "Could not update run title."; + push({ message, tone: "error" }); + focusInputNextFrame(() => inputRef.current?.focus()); + } + }; + + if (isEditing) { + const remaining = TITLE_MAX_LENGTH - draft.length; + const showCount = remaining <= 20; + return ( +
+ setDraft(e.target.value)} + onBlur={() => void submit()} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + void submit(); + } else if (e.key === "Escape") { + e.preventDefault(); + submittedRef.current = true; + exitEdit(); + } + }} + className="-mx-2 block w-full rounded-md bg-panel-alt px-2 py-0.5 text-xl font-semibold text-fg outline-1 -outline-offset-1 outline-line-strong focus:outline-2 focus:-outline-offset-1 focus:outline-teal-500 disabled:opacity-60" + /> +

+ + {isSaving ? "Saving…" : "Press Enter to save · Esc to cancel"} + + {showCount && !isSaving && ( + + {remaining} left + + )} +

+
+ ); + } + + return ( +

+ +

+ ); +} diff --git a/apps/fabro-web/app/lib/mutations.ts b/apps/fabro-web/app/lib/mutations.ts index 7b2d78615..6ac581d45 100644 --- a/apps/fabro-web/app/lib/mutations.ts +++ b/apps/fabro-web/app/lib/mutations.ts @@ -3,14 +3,17 @@ import { useSWRConfig } from "swr"; import type { PreviewUrlResponse, RunStatusResponse, + RunSummary, SteerRunRequest, SubmitAnswerRequest, + UpdateRunRequest, } from "@qltysh/fabro-api-client"; import { apiData, authApi, humanInTheLoopApi, + runsApi, } from "./api-client"; import { queryKeys } from "./query-keys"; import type { LifecycleAction, LifecycleActionError } from "./run-actions"; @@ -101,6 +104,25 @@ function useLifecycleMutation( ); } +export function useUpdateRunTitle(id: string | undefined) { + const { mutate } = useSWRConfig(); + return useSWRMutation( + id ? queryKeys.runs.updateTitle(id) : null, + async (_key, { arg }: { arg: UpdateRunRequest }): Promise => { + if (!id) throw new Error("id is required"); + return apiData(() => runsApi.updateRun(id, arg)); + }, + { + onSuccess: (run) => { + if (!id) return; + void mutate(queryKeys.runs.detail(id), run, { revalidate: false }); + void mutate(queryKeys.boards.runs()); + void mutate(queryKeys.boards.runs(true)); + }, + }, + ); +} + export type SubmitInterviewAnswerArg = { questionId: string; answer: SubmitAnswerRequest; diff --git a/apps/fabro-web/app/lib/query-keys.ts b/apps/fabro-web/app/lib/query-keys.ts index fee209d0e..7f1597692 100644 --- a/apps/fabro-web/app/lib/query-keys.ts +++ b/apps/fabro-web/app/lib/query-keys.ts @@ -44,6 +44,7 @@ export const queryKeys = { cancel: (id: string) => ["runs", "cancel", id] as const, archive: (id: string) => ["runs", "archive", id] as const, unarchive: (id: string) => ["runs", "unarchive", id] as const, + updateTitle: (id: string) => ["runs", "update-title", id] as const, attachUrl: (id: string) => `/api/v1/runs/${pathSegment(id)}/attach`, }, workflows: { diff --git a/apps/fabro-web/app/routes/run-detail.tsx b/apps/fabro-web/app/routes/run-detail.tsx index a7b8b66bd..4e4487a64 100644 --- a/apps/fabro-web/app/routes/run-detail.tsx +++ b/apps/fabro-web/app/routes/run-detail.tsx @@ -16,7 +16,7 @@ import { import { Link, Outlet, useLocation, useMatches, useNavigate } from "react-router"; import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react"; -import { InlineMarkdown } from "../components/inline-markdown"; +import { EditableRunTitle } from "../components/editable-run-title"; import { InterviewDock } from "../components/interview-dock"; import { PullRequestChip } from "../components/pull-request-chip"; import { SteerBar, type SteerBarHandle } from "../components/steer-bar"; @@ -297,9 +297,7 @@ export default function RunDetail({ params }: { params: { id: string } }) { )} >
-

- -

+