mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-11 22:53:00 +00:00
feat(web): inline-edit run titles in the run header
Click the title (or its hover-revealed pencil) to swap it for an input;
Enter or blur saves via PATCH /runs/{id}, Esc reverts. Trims whitespace,
no-ops blank or unchanged values, and surfaces server validation errors
through the existing toast system. The board and breadcrumb refresh from
the same SWR cache after a successful save.
This commit is contained in:
parent
48545f4a7d
commit
7b013cef7f
5 changed files with 350 additions and 4 deletions
192
apps/fabro-web/app/components/editable-run-title.test.tsx
Normal file
192
apps/fabro-web/app/components/editable-run-title.test.tsx
Normal file
|
|
@ -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(
|
||||
<SWRConfig value={{ provider: () => new Map(), dedupingInterval: 0 }}>
|
||||
<ToastProvider>{node}</ToastProvider>
|
||||
</SWRConfig>,
|
||||
);
|
||||
});
|
||||
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(<EditableRunTitle runId="run-1" title="Initial title" />);
|
||||
expect(instanceText(findEditButton(tree))).toContain("Initial title");
|
||||
});
|
||||
|
||||
test("clicking the title swaps to an input pre-filled with the current value", () => {
|
||||
const tree = render(<EditableRunTitle runId="run-1" title="Initial title" />);
|
||||
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(<EditableRunTitle runId="run-1" title="Initial title" />);
|
||||
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(<EditableRunTitle runId="run-1" title="Initial title" />);
|
||||
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(<EditableRunTitle runId="run-1" title="Initial title" />);
|
||||
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(<EditableRunTitle runId="run-1" title="Initial title" />);
|
||||
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;
|
||||
}
|
||||
});
|
||||
});
|
||||
133
apps/fabro-web/app/components/editable-run-title.tsx
Normal file
133
apps/fabro-web/app/components/editable-run-title.tsx
Normal file
|
|
@ -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<HTMLInputElement>(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 (
|
||||
<div className="min-w-0">
|
||||
<input
|
||||
ref={inputRef}
|
||||
name="run-title"
|
||||
aria-label="Run title"
|
||||
type="text"
|
||||
value={draft}
|
||||
maxLength={TITLE_MAX_LENGTH}
|
||||
disabled={isSaving}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<p className="mt-1.5 flex items-center gap-2 text-xs text-fg-muted">
|
||||
<span>
|
||||
{isSaving ? "Saving…" : "Press Enter to save · Esc to cancel"}
|
||||
</span>
|
||||
{showCount && !isSaving && (
|
||||
<span className={remaining < 0 ? "text-coral" : "tabular-nums"}>
|
||||
{remaining} left
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<h2 className="text-xl font-semibold text-fg">
|
||||
<button
|
||||
type="button"
|
||||
onClick={enterEdit}
|
||||
aria-label="Edit run title"
|
||||
className="group/title -mx-2 flex min-w-0 max-w-full items-center gap-1.5 rounded-md px-2 py-0.5 text-left text-fg transition-colors hover:bg-overlay focus-visible:bg-overlay focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-teal-500"
|
||||
>
|
||||
<span className="min-w-0 truncate">
|
||||
<InlineMarkdown content={title} />
|
||||
</span>
|
||||
<PencilIcon
|
||||
aria-hidden="true"
|
||||
className="size-3.5 shrink-0 text-fg-muted opacity-0 transition-opacity group-hover/title:opacity-100 group-focus-visible/title:opacity-100"
|
||||
/>
|
||||
</button>
|
||||
</h2>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<RunSummary> => {
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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 } }) {
|
|||
)}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="text-xl font-semibold text-fg">
|
||||
<InlineMarkdown content={run.title} />
|
||||
</h2>
|
||||
<EditableRunTitle runId={params.id} title={run.title} />
|
||||
<div className="mt-2 flex flex-wrap items-center gap-x-5 gap-y-2 text-sm">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className={`size-2 rounded-full ${run.statusDot}`} />
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue