fix(web): stabilize remote repository picker (#1747)
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { TaskRemoteRepoRow } from "./task-create-dialog-types";
|
||||
import { computeTriggerLabel } from "./task-create-dialog-remote-repo-chip";
|
||||
|
||||
function row(overrides: Partial<TaskRemoteRepoRow> = {}): TaskRemoteRepoRow {
|
||||
return { key: "remote-0", url: "", branch: "", source: "paste", ...overrides };
|
||||
}
|
||||
|
||||
describe("computeTriggerLabel", () => {
|
||||
it("returns the empty-state placeholder when url is empty", () => {
|
||||
expect(computeTriggerLabel(row())).toBe("Pick or paste a repo");
|
||||
});
|
||||
|
||||
it("returns picker fullName when source is 'picker' and metadata is present", () => {
|
||||
expect(
|
||||
computeTriggerLabel(
|
||||
row({
|
||||
url: "https://github.com/octocat/hello-world",
|
||||
source: "picker",
|
||||
provider: "github",
|
||||
fullName: "octocat/hello-world",
|
||||
}),
|
||||
),
|
||||
).toBe("octocat/hello-world");
|
||||
});
|
||||
|
||||
it("returns short paste URLs verbatim", () => {
|
||||
const label = computeTriggerLabel(row({ url: "github.com/x/y", source: "paste" }));
|
||||
expect(label).toBe("github.com/x/y");
|
||||
expect(label).not.toContain("\u2026");
|
||||
});
|
||||
|
||||
it("middle-truncates long paste URLs while preserving first and last chars", () => {
|
||||
const url = "github.com/some-very-long-org/some-very-long-repo-name";
|
||||
const label = computeTriggerLabel(row({ url, source: "paste" }));
|
||||
expect(label).toContain("\u2026");
|
||||
expect(label.length).toBeLessThan(url.length);
|
||||
expect(label.startsWith(url[0]!)).toBe(true);
|
||||
expect(label.endsWith(url[url.length - 1]!)).toBe(true);
|
||||
});
|
||||
|
||||
it("strips https:// and www. prefixes from paste labels", () => {
|
||||
expect(
|
||||
computeTriggerLabel(row({ url: "https://www.github.com/foo/bar", source: "paste" })),
|
||||
).toBe("github.com/foo/bar");
|
||||
});
|
||||
});
|
||||
@@ -31,11 +31,13 @@ function makeAccessible(
|
||||
};
|
||||
}
|
||||
|
||||
import { RemoteRepoChip, computeTriggerLabel } from "./task-create-dialog-remote-repo-chip";
|
||||
import { RemoteRepoChip } from "./task-create-dialog-remote-repo-chip";
|
||||
|
||||
const TRIGGER_TID = "remote-repo-chip-trigger";
|
||||
const INPUT_TID = "remote-repo-input";
|
||||
const FULL_NAME = "acme/site";
|
||||
const URL_ACME_SITE = "https://github.com/acme/site";
|
||||
const URL_FOO_BAR_PR = "https://github.com/foo/bar/pull/42";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
@@ -87,46 +89,6 @@ describe("RemoteRepoChip — write paths", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("paste input writes URL with source=paste (no metadata) on Enter", () => {
|
||||
const onURLChange = vi.fn();
|
||||
renderInProvider(
|
||||
<RemoteRepoChip
|
||||
row={row()}
|
||||
branches={[]}
|
||||
branchesLoading={false}
|
||||
accessibleRepos={makeAccessible()}
|
||||
onURLChange={onURLChange}
|
||||
onBranchChange={noopBranch}
|
||||
onRemove={noopRemove}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByTestId(TRIGGER_TID));
|
||||
const input = screen.getByTestId("remote-paste-url-input") as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: "https://github.com/acme/api" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(onURLChange).toHaveBeenCalledWith("https://github.com/acme/api", "paste");
|
||||
});
|
||||
|
||||
it("paste input also commits on blur", () => {
|
||||
const onURLChange = vi.fn();
|
||||
renderInProvider(
|
||||
<RemoteRepoChip
|
||||
row={row()}
|
||||
branches={[]}
|
||||
branchesLoading={false}
|
||||
accessibleRepos={makeAccessible()}
|
||||
onURLChange={onURLChange}
|
||||
onBranchChange={noopBranch}
|
||||
onRemove={noopRemove}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByTestId(TRIGGER_TID));
|
||||
const input = screen.getByTestId("remote-paste-url-input") as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: "https://github.com/foo/bar" } });
|
||||
fireEvent.blur(input);
|
||||
expect(onURLChange).toHaveBeenCalledWith("https://github.com/foo/bar", "paste");
|
||||
});
|
||||
|
||||
it("calls onRemove when the X button is clicked", () => {
|
||||
const onRemove = vi.fn();
|
||||
renderInProvider(
|
||||
@@ -145,12 +107,138 @@ describe("RemoteRepoChip — write paths", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("RemoteRepoChip — paste/picker race", () => {
|
||||
it("picker click after typing in paste input does not trigger paste commit", () => {
|
||||
// Race: user types into paste input, then clicks a picker option. The
|
||||
// input's onBlur fires first (focus moves to the option button). Without
|
||||
// the guard, blur would commit the typed value AND close the popover,
|
||||
// and the subsequent picker click would be dropped.
|
||||
describe("RemoteRepoChip — URL entry", () => {
|
||||
it("writes a GitHub URL with source=paste on Enter", () => {
|
||||
const onURLChange = vi.fn();
|
||||
renderInProvider(
|
||||
<RemoteRepoChip
|
||||
row={row()}
|
||||
branches={[]}
|
||||
branchesLoading={false}
|
||||
accessibleRepos={makeAccessible()}
|
||||
onURLChange={onURLChange}
|
||||
onBranchChange={noopBranch}
|
||||
onRemove={noopRemove}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByTestId(TRIGGER_TID));
|
||||
const input = screen.getByTestId(INPUT_TID) as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: "https://github.com/acme/api" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(onURLChange).toHaveBeenCalledWith("https://github.com/acme/api", "paste");
|
||||
});
|
||||
|
||||
it("commits a pasted GitHub URL immediately", () => {
|
||||
const onURLChange = vi.fn();
|
||||
renderInProvider(
|
||||
<RemoteRepoChip
|
||||
row={row()}
|
||||
branches={[]}
|
||||
branchesLoading={false}
|
||||
accessibleRepos={makeAccessible()}
|
||||
onURLChange={onURLChange}
|
||||
onBranchChange={noopBranch}
|
||||
onRemove={noopRemove}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByTestId(TRIGGER_TID));
|
||||
const input = screen.getByTestId(INPUT_TID) as HTMLInputElement;
|
||||
fireEvent.paste(input, {
|
||||
clipboardData: { getData: () => URL_FOO_BAR_PR },
|
||||
});
|
||||
expect(onURLChange).toHaveBeenCalledWith(URL_FOO_BAR_PR, "paste");
|
||||
});
|
||||
|
||||
it("commits a typed GitHub URL on blur", () => {
|
||||
const onURLChange = vi.fn();
|
||||
renderInProvider(
|
||||
<RemoteRepoChip
|
||||
row={row()}
|
||||
branches={[]}
|
||||
branchesLoading={false}
|
||||
accessibleRepos={makeAccessible()}
|
||||
onURLChange={onURLChange}
|
||||
onBranchChange={noopBranch}
|
||||
onRemove={noopRemove}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByTestId(TRIGGER_TID));
|
||||
const input = screen.getByTestId(INPUT_TID) as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: "https://github.com/foo/bar/issues/42" } });
|
||||
fireEvent.blur(input);
|
||||
expect(onURLChange).toHaveBeenCalledWith("https://github.com/foo/bar/issues/42", "paste");
|
||||
});
|
||||
|
||||
it("commits a typed GitHub PR URL when Tab cannot move focus out of the popover", () => {
|
||||
const onURLChange = vi.fn();
|
||||
renderInProvider(
|
||||
<RemoteRepoChip
|
||||
row={row()}
|
||||
branches={[]}
|
||||
branchesLoading={false}
|
||||
accessibleRepos={makeAccessible()}
|
||||
onURLChange={onURLChange}
|
||||
onBranchChange={noopBranch}
|
||||
onRemove={noopRemove}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByTestId(TRIGGER_TID));
|
||||
const input = screen.getByTestId(INPUT_TID) as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: URL_FOO_BAR_PR } });
|
||||
fireEvent.keyDown(input, { key: "Tab" });
|
||||
expect(onURLChange).toHaveBeenCalledWith(URL_FOO_BAR_PR, "paste");
|
||||
});
|
||||
|
||||
it("surfaces an inline error when a URL-shaped non-GitHub value is submitted", () => {
|
||||
const onURLChange = vi.fn();
|
||||
renderInProvider(
|
||||
<RemoteRepoChip
|
||||
row={row()}
|
||||
branches={[]}
|
||||
branchesLoading={false}
|
||||
accessibleRepos={makeAccessible()}
|
||||
onURLChange={onURLChange}
|
||||
onBranchChange={noopBranch}
|
||||
onRemove={noopRemove}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByTestId(TRIGGER_TID));
|
||||
const input = screen.getByTestId(INPUT_TID) as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: "https://gitlab.com/acme/api" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
|
||||
expect(input.getAttribute("aria-invalid")).toBe("true");
|
||||
expect(screen.getByRole("alert").textContent).toContain("Enter a GitHub repository URL");
|
||||
expect(onURLChange).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("RemoteRepoChip — unified search", () => {
|
||||
it("uses ordinary text as repository search without committing it on blur", () => {
|
||||
const onURLChange = vi.fn();
|
||||
const search = vi.fn();
|
||||
renderInProvider(
|
||||
<RemoteRepoChip
|
||||
row={row()}
|
||||
branches={[]}
|
||||
branchesLoading={false}
|
||||
accessibleRepos={makeAccessible({ search })}
|
||||
onURLChange={onURLChange}
|
||||
onBranchChange={noopBranch}
|
||||
onRemove={noopRemove}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByTestId(TRIGGER_TID));
|
||||
const input = screen.getByTestId(INPUT_TID) as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: "acme" } });
|
||||
fireEvent.blur(input);
|
||||
expect(search).toHaveBeenLastCalledWith("acme");
|
||||
expect(input.getAttribute("aria-invalid")).toBeNull();
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
expect(onURLChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("picker click after searching commits only the selected repository", () => {
|
||||
const accessibleRepos = makeAccessible({
|
||||
repos: [
|
||||
{
|
||||
@@ -176,10 +264,8 @@ describe("RemoteRepoChip — paste/picker race", () => {
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByTestId(TRIGGER_TID));
|
||||
const input = screen.getByTestId("remote-paste-url-input") as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: "https://github.com/typed/value" } });
|
||||
// Simulate focus moving to the picker option before the click lands —
|
||||
// this is the exact ordering the browser produces (blur → click).
|
||||
const input = screen.getByTestId(INPUT_TID) as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: "acme" } });
|
||||
const option = screen.getByText(FULL_NAME).closest("button") as HTMLButtonElement;
|
||||
fireEvent.blur(input, { relatedTarget: option });
|
||||
fireEvent.click(option);
|
||||
@@ -192,6 +278,48 @@ describe("RemoteRepoChip — paste/picker race", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("RemoteRepoChip — picker focus", () => {
|
||||
it("does not commit a typed URL on blur when focus moves to a repository option", () => {
|
||||
const accessibleRepos = makeAccessible({
|
||||
repos: [
|
||||
{
|
||||
provider: "github",
|
||||
owner: "acme",
|
||||
name: "site",
|
||||
full_name: FULL_NAME,
|
||||
default_branch: "main",
|
||||
private: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
const onURLChange = vi.fn();
|
||||
renderInProvider(
|
||||
<RemoteRepoChip
|
||||
row={row()}
|
||||
branches={[]}
|
||||
branchesLoading={false}
|
||||
accessibleRepos={accessibleRepos}
|
||||
onURLChange={onURLChange}
|
||||
onBranchChange={noopBranch}
|
||||
onRemove={noopRemove}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByTestId(TRIGGER_TID));
|
||||
const input = screen.getByTestId(INPUT_TID) as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: URL_ACME_SITE } });
|
||||
const option = screen.getByText(FULL_NAME).closest("button") as HTMLButtonElement;
|
||||
fireEvent.blur(input, { relatedTarget: option });
|
||||
fireEvent.click(option);
|
||||
|
||||
expect(onURLChange).toHaveBeenCalledTimes(1);
|
||||
expect(onURLChange).toHaveBeenCalledWith(URL_ACME_SITE, "picker", {
|
||||
provider: "github",
|
||||
fullName: FULL_NAME,
|
||||
defaultBranch: "main",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("RemoteRepoChip — branch pill", () => {
|
||||
it("is disabled when the URL is empty", () => {
|
||||
renderInProvider(
|
||||
@@ -305,6 +433,7 @@ describe("RemoteRepoChip — picker loading state", () => {
|
||||
fireEvent.click(screen.getByTestId(TRIGGER_TID));
|
||||
const loadingNode = screen.getByTestId("remote-repo-picker-loading");
|
||||
expect(loadingNode.textContent).toContain("Loading repositories");
|
||||
expect(loadingNode.parentElement?.className).toContain("h-56");
|
||||
});
|
||||
|
||||
it("does NOT render the spinner once repos have loaded (even if loading flips true again later)", () => {
|
||||
@@ -335,9 +464,67 @@ describe("RemoteRepoChip — picker loading state", () => {
|
||||
expect(screen.queryByTestId("remote-repo-picker-loading")).toBeNull();
|
||||
expect(screen.getByText(FULL_NAME)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not render a loading spinner with the GitHub connection banner", () => {
|
||||
renderInProvider(
|
||||
<RemoteRepoChip
|
||||
row={row()}
|
||||
branches={[]}
|
||||
branchesLoading={false}
|
||||
accessibleRepos={makeAccessible({ unavailable: true, loading: true })}
|
||||
onURLChange={vi.fn()}
|
||||
onBranchChange={noopBranch}
|
||||
onRemove={noopRemove}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByTestId(TRIGGER_TID));
|
||||
expect(screen.getByText(/Connect a GitHub account/i)).toBeTruthy();
|
||||
expect(screen.queryByTestId("remote-repo-picker-loading")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows only the connection banner for invalid URL input when GitHub is unavailable", () => {
|
||||
renderInProvider(
|
||||
<RemoteRepoChip
|
||||
row={row()}
|
||||
branches={[]}
|
||||
branchesLoading={false}
|
||||
accessibleRepos={makeAccessible({ unavailable: true })}
|
||||
onURLChange={vi.fn()}
|
||||
onBranchChange={noopBranch}
|
||||
onRemove={noopRemove}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByTestId(TRIGGER_TID));
|
||||
const input = screen.getByTestId(INPUT_TID) as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: "https://gitlab.com/acme/api" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
|
||||
expect(screen.getByText(/Connect a GitHub account/i)).toBeTruthy();
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
expect(input.getAttribute("aria-invalid")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("RemoteRepoChip — popover content", () => {
|
||||
it("renders one top-level input for both repository search and GitHub URLs", () => {
|
||||
renderInProvider(
|
||||
<RemoteRepoChip
|
||||
row={row()}
|
||||
branches={[]}
|
||||
branchesLoading={false}
|
||||
accessibleRepos={makeAccessible()}
|
||||
onURLChange={vi.fn()}
|
||||
onBranchChange={noopBranch}
|
||||
onRemove={noopRemove}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByTestId(TRIGGER_TID));
|
||||
const input = screen.getByTestId(INPUT_TID) as HTMLInputElement;
|
||||
expect(input.placeholder).toBe("Search repositories or paste a GitHub URL");
|
||||
expect(screen.queryByTestId("remote-repo-search")).toBeNull();
|
||||
expect(screen.queryByTestId("remote-paste-url-input")).toBeNull();
|
||||
});
|
||||
|
||||
it("portals and constrains the repo picker so dialog overflow cannot clip it", () => {
|
||||
renderInProvider(
|
||||
<div data-testid="clipping-host" className="overflow-hidden">
|
||||
@@ -357,7 +544,7 @@ describe("RemoteRepoChip — popover content", () => {
|
||||
expect(screen.getByTestId("clipping-host").contains(content)).toBe(false);
|
||||
expect(content.className).toContain("max-w-[calc(100vw-2rem)]");
|
||||
expect(content.className).toContain("max-h-[min(420px,calc(100vh-12rem))]");
|
||||
expect(content.className).toContain("overflow-y-auto");
|
||||
expect(content.className).toContain("overflow-hidden");
|
||||
});
|
||||
|
||||
it("renders the 'Connect GitHub' banner when accessibleRepos.unavailable=true", () => {
|
||||
@@ -444,44 +631,3 @@ describe("RemoteRepoChip — trigger label", () => {
|
||||
expect(screen.getByTestId(TRIGGER_TID).textContent).toContain("github.com/foo/bar");
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeTriggerLabel", () => {
|
||||
it("returns the empty-state placeholder when url is empty", () => {
|
||||
expect(computeTriggerLabel(row())).toBe("Pick or paste a repo");
|
||||
});
|
||||
|
||||
it("returns picker fullName when source is 'picker' and metadata is present", () => {
|
||||
expect(
|
||||
computeTriggerLabel(
|
||||
row({
|
||||
url: "https://github.com/octocat/hello-world",
|
||||
source: "picker",
|
||||
provider: "github",
|
||||
fullName: "octocat/hello-world",
|
||||
}),
|
||||
),
|
||||
).toBe("octocat/hello-world");
|
||||
});
|
||||
|
||||
it("returns short paste URLs verbatim (no ellipsis under threshold)", () => {
|
||||
const label = computeTriggerLabel(row({ url: "github.com/x/y", source: "paste" }));
|
||||
expect(label).toBe("github.com/x/y");
|
||||
expect(label).not.toContain("…");
|
||||
});
|
||||
|
||||
it("middle-truncates long paste URLs while preserving first and last chars", () => {
|
||||
const long = "github.com/some-very-long-org/some-very-long-repo-name";
|
||||
const stripped = "github.com/some-very-long-org/some-very-long-repo-name"; // no scheme to strip
|
||||
const label = computeTriggerLabel(row({ url: long, source: "paste" }));
|
||||
expect(label).toContain("…");
|
||||
expect(label.length).toBeLessThan(stripped.length);
|
||||
expect(label.startsWith(stripped[0]!)).toBe(true);
|
||||
expect(label.endsWith(stripped[stripped.length - 1]!)).toBe(true);
|
||||
});
|
||||
|
||||
it("strips https:// and www. prefixes from paste labels", () => {
|
||||
expect(
|
||||
computeTriggerLabel(row({ url: "https://www.github.com/foo/bar", source: "paste" })),
|
||||
).toBe("github.com/foo/bar");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
import { scoreBranch } from "@/lib/utils/branch-filter";
|
||||
import type { UseAccessibleReposResult } from "@/hooks/domains/github/use-accessible-repos";
|
||||
import type { AccessibleRepo } from "@/lib/api/domains/github-api";
|
||||
import type { PRInfo } from "@/hooks/domains/github/use-pr-info-by-url";
|
||||
import { parseGitHubAnyUrl, type PRInfo } from "@/hooks/domains/github/use-pr-info-by-url";
|
||||
import type { TaskRemoteRepoRow } from "@/components/task-create-dialog-types";
|
||||
import { useTaskCreateDialogPopoverContainer } from "@/hooks/use-task-create-dialog-popover-container";
|
||||
|
||||
@@ -79,8 +79,8 @@ export type RemoteRepoChipProps = {
|
||||
*
|
||||
* [ repo pill ] [ branch pill ] [X]
|
||||
*
|
||||
* The repo pill opens a custom popover with two sections (an autocomplete
|
||||
* search over the user's accessible GitHub repos, and a paste-a-URL input).
|
||||
* The repo pill opens a custom popover with one input that searches the
|
||||
* user's accessible GitHub repos or accepts a pasted GitHub URL.
|
||||
* The branch pill is the shared `Pill` primitive over the per-URL branches
|
||||
* the parent loads via `branchesByUrl`.
|
||||
*/
|
||||
@@ -220,7 +220,7 @@ function RemoteRepoPill({
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
data-testid="remote-repo-popover-content"
|
||||
className="w-[380px] max-w-[calc(100vw-2rem)] max-h-[min(420px,calc(100vh-12rem))] overflow-y-auto p-0"
|
||||
className="w-[380px] max-w-[calc(100vw-2rem)] max-h-[min(420px,calc(100vh-12rem))] overflow-hidden p-0"
|
||||
align="start"
|
||||
portalContainer={portalContainer}
|
||||
>
|
||||
@@ -278,70 +278,110 @@ function RemoteRepoPopoverContent({
|
||||
onPick: (repo: AccessibleRepo) => void;
|
||||
onPaste: (value: string) => void;
|
||||
}) {
|
||||
const [search, setSearch] = useState("");
|
||||
// Destructure the stable `search` callback so the effect's dep array is
|
||||
// not invalidated on every render of the parent (the hook's result object
|
||||
// identity changes each render, but `search` is a useCallback). The hook
|
||||
// itself owns the 250ms debounce so we just forward the latest value.
|
||||
const [value, setValue] = useState("");
|
||||
const [urlError, setUrlError] = useState<string | null>(null);
|
||||
const { search: triggerSearch } = accessible;
|
||||
useEffect(() => {
|
||||
triggerSearch(search);
|
||||
}, [search, triggerSearch]);
|
||||
triggerSearch(value);
|
||||
}, [value, triggerSearch]);
|
||||
|
||||
const commitURL = (candidate: string) => {
|
||||
const trimmed = candidate.trim();
|
||||
if (!parseGitHubAnyUrl(trimmed)) {
|
||||
if (looksLikeURL(trimmed)) {
|
||||
setUrlError("Enter a GitHub repository URL, such as github.com/owner/repo.");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
setUrlError(null);
|
||||
onPaste(trimmed);
|
||||
return true;
|
||||
};
|
||||
const visibleUrlError = accessible.unavailable ? null : urlError;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<PickerSection
|
||||
accessible={accessible}
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
onPick={onPick}
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(event) => {
|
||||
setValue(event.target.value);
|
||||
setUrlError(null);
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
const popoverContent = event.currentTarget.closest('[data-slot="popover-content"]');
|
||||
if (
|
||||
popoverContent &&
|
||||
event.relatedTarget instanceof Node &&
|
||||
popoverContent.contains(event.relatedTarget)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
commitURL(value);
|
||||
}}
|
||||
onPaste={(event) => {
|
||||
const pasted = event.clipboardData.getData("text");
|
||||
const isURL = looksLikeURL(pasted.trim());
|
||||
if (!commitURL(pasted) && !isURL) return;
|
||||
event.preventDefault();
|
||||
setValue(pasted.trim());
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Tab") {
|
||||
commitURL(value);
|
||||
return;
|
||||
}
|
||||
if (event.key !== "Enter") return;
|
||||
const isURL = looksLikeURL(value.trim());
|
||||
if (commitURL(value) || isURL) event.preventDefault();
|
||||
}}
|
||||
placeholder="Search repositories or paste a GitHub URL"
|
||||
aria-label="Search repositories or paste a GitHub URL"
|
||||
aria-invalid={visibleUrlError ? true : undefined}
|
||||
data-testid="remote-repo-input"
|
||||
data-legacy-testid="remote-paste-url-input"
|
||||
className={cn(
|
||||
"h-11 sm:h-9 mx-2 mt-2 rounded-md px-2 text-xs bg-muted/30 border border-border/60",
|
||||
"outline-none focus:bg-muted focus:border-border placeholder:text-muted-foreground",
|
||||
visibleUrlError && "border-destructive focus:border-destructive",
|
||||
)}
|
||||
/>
|
||||
<div className="border-t" />
|
||||
<PasteSection onPaste={onPaste} />
|
||||
<PickerList accessible={accessible} onPick={onPick} urlError={visibleUrlError} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PickerSection({
|
||||
accessible,
|
||||
search,
|
||||
onSearchChange,
|
||||
onPick,
|
||||
}: {
|
||||
accessible: UseAccessibleReposResult;
|
||||
search: string;
|
||||
onSearchChange: (v: string) => void;
|
||||
onPick: (repo: AccessibleRepo) => void;
|
||||
}) {
|
||||
if (accessible.unavailable) return <ConnectGitHubBanner />;
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
placeholder="Search your GitHub repos…"
|
||||
data-testid="remote-repo-search"
|
||||
className={cn(
|
||||
"h-9 mx-2 mt-2 rounded-md px-2 text-xs bg-muted/30 border border-border/60",
|
||||
"outline-none focus:bg-muted focus:border-border placeholder:text-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
<PickerList accessible={accessible} onPick={onPick} />
|
||||
</div>
|
||||
);
|
||||
function looksLikeURL(value: string): boolean {
|
||||
if (!value) return false;
|
||||
const withScheme = /^[a-z][a-z\d+.-]*:\/\//i.test(value) ? value : `https://${value}`;
|
||||
try {
|
||||
const parsed = new URL(withScheme);
|
||||
return parsed.hostname.includes(".") && value.includes("/");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function PickerList({
|
||||
accessible,
|
||||
onPick,
|
||||
urlError,
|
||||
}: {
|
||||
accessible: UseAccessibleReposResult;
|
||||
onPick: (repo: AccessibleRepo) => void;
|
||||
urlError: string | null;
|
||||
}) {
|
||||
const { repos, loading, error } = accessible;
|
||||
return (
|
||||
<div className="max-h-56 overflow-y-auto p-1">
|
||||
{loading && repos.length === 0 ? (
|
||||
<div className="h-56 max-h-[calc(100vh-16rem)] overflow-y-auto p-1">
|
||||
{urlError ? (
|
||||
<div role="alert" className="px-2 py-3 text-xs text-destructive">
|
||||
{urlError}
|
||||
</div>
|
||||
) : null}
|
||||
{accessible.unavailable ? <ConnectGitHubBanner /> : null}
|
||||
{!accessible.unavailable && loading && repos.length === 0 ? (
|
||||
<div
|
||||
className="flex items-center gap-2 px-2 py-3 text-xs text-muted-foreground"
|
||||
data-testid="remote-repo-picker-loading"
|
||||
@@ -350,7 +390,7 @@ function PickerList({
|
||||
<span>Loading repositories…</span>
|
||||
</div>
|
||||
) : null}
|
||||
{!loading && repos.length === 0 && !error ? (
|
||||
{!accessible.unavailable && !loading && repos.length === 0 && !error ? (
|
||||
<div className="px-2 py-3 text-xs text-muted-foreground">No repositories found.</div>
|
||||
) : null}
|
||||
{error ? (
|
||||
@@ -378,7 +418,7 @@ function RepoOption({
|
||||
onClick={() => onPick(repo)}
|
||||
data-testid="remote-repo-option"
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-2 rounded-sm px-2 py-1.5 text-xs",
|
||||
"flex min-h-11 sm:min-h-8 w-full items-center justify-between gap-2 rounded-sm px-2 py-1.5 text-xs",
|
||||
"hover:bg-muted cursor-pointer text-left",
|
||||
)}
|
||||
>
|
||||
@@ -407,55 +447,6 @@ function ConnectGitHubBanner() {
|
||||
);
|
||||
}
|
||||
|
||||
function PasteSection({ onPaste }: { onPaste: (value: string) => void }) {
|
||||
const [value, setValue] = useState("");
|
||||
const commit = () => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return;
|
||||
onPaste(trimmed);
|
||||
};
|
||||
return (
|
||||
<div className="flex flex-col gap-1 p-2">
|
||||
<label className="text-[11px] text-muted-foreground" htmlFor="remote-paste-url-input">
|
||||
…or paste a URL/PR/issue
|
||||
</label>
|
||||
<input
|
||||
id="remote-paste-url-input"
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onBlur={(e) => {
|
||||
// If focus moved to another element inside this popover (e.g., a
|
||||
// picker option click), let that handler run instead of committing
|
||||
// the paste — otherwise the popover would close before the click
|
||||
// lands and the user's pick would be lost.
|
||||
const popoverContent = e.currentTarget.closest('[data-slot="popover-content"]');
|
||||
if (
|
||||
popoverContent &&
|
||||
e.relatedTarget instanceof Node &&
|
||||
popoverContent.contains(e.relatedTarget)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
commit();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
commit();
|
||||
}
|
||||
}}
|
||||
placeholder="github.com/owner/repo, .../pull/123, or .../issues/123"
|
||||
data-testid="remote-paste-url-input"
|
||||
className={cn(
|
||||
"h-8 rounded-md px-2 text-xs bg-muted/30 border border-border/60",
|
||||
"outline-none focus:bg-muted focus:border-border placeholder:text-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Branch pill -------------------------------------------------------------
|
||||
|
||||
function RemoteBranchPill({
|
||||
|
||||
@@ -161,7 +161,7 @@ test.describe("Branch selector behavior with executor types", () => {
|
||||
// Switch to Remote tab and paste via the chip popover.
|
||||
await testPage.getByTestId("source-mode-remote").click();
|
||||
await testPage.getByTestId("remote-repo-chip-trigger").first().click();
|
||||
const pasteInput = testPage.getByTestId("remote-paste-url-input");
|
||||
const pasteInput = testPage.getByTestId("remote-repo-input");
|
||||
await pasteInput.fill("https://github.com/branch-test-owner/branch-test-repo");
|
||||
await pasteInput.press("Enter");
|
||||
|
||||
@@ -433,7 +433,7 @@ test.describe("Fresh-branch flow", () => {
|
||||
// Switch to Remote tab and paste via the chip popover.
|
||||
await testPage.getByTestId("source-mode-remote").click();
|
||||
await testPage.getByTestId("remote-repo-chip-trigger").first().click();
|
||||
const pasteInput = testPage.getByTestId("remote-paste-url-input");
|
||||
const pasteInput = testPage.getByTestId("remote-repo-input");
|
||||
await pasteInput.fill("https://github.com/branch-test-owner/branch-test-repo");
|
||||
await pasteInput.press("Enter");
|
||||
await testPage.getByTestId("executor-profile-selector").click();
|
||||
|
||||
@@ -8,7 +8,7 @@ import { SessionPage } from "../../pages/session-page";
|
||||
useRegularMode();
|
||||
|
||||
/**
|
||||
* Helper: switch the create-task dialog to the Remote tab and paste a URL
|
||||
* Helper: switch the create-task dialog to the Remote tab and type a URL
|
||||
* into the (newly nested) chip popover. Replaces the old "click
|
||||
* toggle-github-url + fill github-url-input" pair after Task 5/8 swapped the
|
||||
* top-level URL input for a per-row chip + popover.
|
||||
@@ -17,10 +17,10 @@ async function openRemoteAndPasteURL(testPage: Page, url: string): Promise<void>
|
||||
await testPage.getByTestId("source-mode-remote").click();
|
||||
// The chip popover holds the paste input. Open the first chip.
|
||||
await testPage.getByTestId("remote-repo-chip-trigger").first().click();
|
||||
const pasteInput = testPage.getByTestId("remote-paste-url-input");
|
||||
await expect(pasteInput).toBeVisible();
|
||||
await pasteInput.fill(url);
|
||||
await pasteInput.press("Enter");
|
||||
const urlInput = testPage.getByTestId("remote-repo-input");
|
||||
await expect(urlInput).toBeVisible();
|
||||
await urlInput.fill(url);
|
||||
await urlInput.press("Tab");
|
||||
}
|
||||
|
||||
test.describe("Task creation from GitHub URL", () => {
|
||||
|
||||
@@ -8,8 +8,8 @@ import { KanbanPage } from "../../pages/kanban-page";
|
||||
// rows, add/remove rows, the "GitHub not configured" banner, and mode-switch
|
||||
// preservation. Modeled on create-task-github-url.spec.ts but exercises the
|
||||
// new chip-popover UI (testids: source-mode-remote, remote-repo-chip,
|
||||
// remote-repo-chip-trigger, remote-paste-url-input, remote-add-row,
|
||||
// remote-chip-remove, remote-repo-search, remote-repo-option).
|
||||
// remote-repo-chip-trigger, remote-repo-input, remote-add-row,
|
||||
// remote-chip-remove, remote-repo-option).
|
||||
|
||||
type TaskRepoAPI = {
|
||||
repositories?: Array<{
|
||||
@@ -90,7 +90,7 @@ async function pasteUrlInChip(testPage: Page, url: string, chipIndex = 0): Promi
|
||||
// multiple chips have popovers open (or Radix is mid-transition between
|
||||
// opens), the same testid can briefly resolve to more than one element,
|
||||
// so target the last (currently focused) one.
|
||||
const pasteInput = testPage.getByTestId("remote-paste-url-input").last();
|
||||
const pasteInput = testPage.getByTestId("remote-repo-input").last();
|
||||
await expect(pasteInput).toBeVisible();
|
||||
await pasteInput.fill(url);
|
||||
await pasteInput.press("Enter");
|
||||
@@ -117,6 +117,55 @@ test.describe("Task creation from Remote tab (chip picker)", () => {
|
||||
await apiClient.mockGitHubReset();
|
||||
});
|
||||
|
||||
test("keeps the unified input fixed while repositories load", async ({ testPage, apiClient }) => {
|
||||
await seedAccessibleRepos(apiClient);
|
||||
let releaseRepos = () => undefined;
|
||||
const reposGate = new Promise<void>((resolve) => {
|
||||
releaseRepos = resolve;
|
||||
});
|
||||
await testPage.route("**/api/v1/github/repos?*", async (route) => {
|
||||
await reposGate;
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await openCreateDialog(testPage, kanban);
|
||||
await clickRemoteMode(testPage);
|
||||
await testPage.getByTestId("remote-repo-chip-trigger").first().click();
|
||||
|
||||
const popover = testPage.getByTestId("remote-repo-popover-content");
|
||||
const input = testPage.getByTestId("remote-repo-input");
|
||||
await expect(testPage.getByTestId("remote-repo-picker-loading")).toBeVisible();
|
||||
// Measure the loading state after the finite popover entrance animation.
|
||||
// Otherwise its scale transform makes the first box a few pixels smaller
|
||||
// than the loaded-state box even though the layout itself never shifts.
|
||||
await popover.evaluate(async (element) => {
|
||||
await Promise.all(
|
||||
element.getAnimations().map((animation) => animation.finished.catch(() => undefined)),
|
||||
);
|
||||
});
|
||||
const [loadingPopoverBox, loadingInputBox] = await Promise.all([
|
||||
popover.boundingBox(),
|
||||
input.boundingBox(),
|
||||
]);
|
||||
|
||||
releaseRepos();
|
||||
await expect(
|
||||
testPage.getByTestId("remote-repo-option").filter({ hasText: "mock-user/alpha" }),
|
||||
).toBeVisible();
|
||||
const [loadedPopoverBox, loadedInputBox] = await Promise.all([
|
||||
popover.boundingBox(),
|
||||
input.boundingBox(),
|
||||
]);
|
||||
|
||||
expect(loadingPopoverBox).not.toBeNull();
|
||||
expect(loadingInputBox).not.toBeNull();
|
||||
expect(loadedPopoverBox).not.toBeNull();
|
||||
expect(loadedInputBox).not.toBeNull();
|
||||
expect(Math.abs(loadedPopoverBox!.height - loadingPopoverBox!.height)).toBeLessThanOrEqual(2);
|
||||
expect(Math.abs(loadedInputBox!.y - loadingInputBox!.y)).toBeLessThanOrEqual(2);
|
||||
});
|
||||
|
||||
test("scenario 1: picker selection submits one repo", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
@@ -238,7 +287,7 @@ test.describe("Task creation from Remote tab (chip picker)", () => {
|
||||
|
||||
await testPage.getByTestId("remote-repo-chip-trigger").first().click();
|
||||
await expectPopoverFitsDialog(testPage);
|
||||
const pasteInput = testPage.getByTestId("remote-paste-url-input").last();
|
||||
const pasteInput = testPage.getByTestId("remote-repo-input").last();
|
||||
await pasteInput.fill("https://github.com/issue-owner/issue-repo/issues/1456");
|
||||
await pasteInput.press("Enter");
|
||||
|
||||
@@ -407,7 +456,7 @@ test.describe("Task creation from Remote tab (chip picker)", () => {
|
||||
await expect(settingsLink).toBeVisible();
|
||||
|
||||
// The paste input is still rendered and usable — paste a URL.
|
||||
const pasteInput = testPage.getByTestId("remote-paste-url-input");
|
||||
const pasteInput = testPage.getByTestId("remote-repo-input");
|
||||
await expect(pasteInput).toBeVisible();
|
||||
await pasteInput.fill("https://github.com/banner-owner/banner-repo");
|
||||
await pasteInput.press("Enter");
|
||||
|
||||
@@ -59,7 +59,7 @@ test.describe("Create-task URL flow - branches after reopen", () => {
|
||||
// inside the chip in Task 5/8).
|
||||
await testPage.getByTestId("source-mode-remote").click();
|
||||
await testPage.getByTestId("remote-repo-chip-trigger").first().click();
|
||||
const pasteInput = testPage.getByTestId("remote-paste-url-input");
|
||||
const pasteInput = testPage.getByTestId("remote-repo-input");
|
||||
await pasteInput.fill(`https://github.com/${repoFullName}`);
|
||||
await pasteInput.press("Enter");
|
||||
|
||||
|
||||
@@ -13,12 +13,19 @@ async function openRemotePicker(testPage: Page): Promise<void> {
|
||||
|
||||
async function expectPopoverFitsViewport(testPage: Page): Promise<void> {
|
||||
const viewport = testPage.viewportSize();
|
||||
const box = await testPage.getByTestId("remote-repo-popover-content").boundingBox();
|
||||
const input = testPage.getByTestId("remote-repo-input");
|
||||
const [box, inputBox] = await Promise.all([
|
||||
testPage.getByTestId("remote-repo-popover-content").boundingBox(),
|
||||
input.boundingBox(),
|
||||
]);
|
||||
expect(viewport).not.toBeNull();
|
||||
expect(box).not.toBeNull();
|
||||
expect(inputBox).not.toBeNull();
|
||||
expect(box!.x).toBeGreaterThanOrEqual(0);
|
||||
expect(box!.x + box!.width).toBeLessThanOrEqual(viewport!.width);
|
||||
expect(box!.y + box!.height).toBeLessThanOrEqual(viewport!.height);
|
||||
expect(inputBox!.y - box!.y).toBeLessThan(16);
|
||||
await expect(input).toHaveCSS("height", "44px");
|
||||
}
|
||||
|
||||
test.describe("Create task Remote repo picker on mobile", () => {
|
||||
@@ -43,7 +50,7 @@ test.describe("Create task Remote repo picker on mobile", () => {
|
||||
|
||||
await openRemotePicker(testPage);
|
||||
await expectPopoverFitsViewport(testPage);
|
||||
const pasteInput = testPage.getByTestId("remote-paste-url-input").last();
|
||||
const pasteInput = testPage.getByTestId("remote-repo-input").last();
|
||||
await pasteInput.fill("https://github.com/issue-owner/issue-repo/issues/1456");
|
||||
await pasteInput.press("Enter");
|
||||
|
||||
|
||||
@@ -656,7 +656,7 @@ test.describe("Subtask dialog feature parity", () => {
|
||||
const chipTrigger = testPage.getByTestId("remote-repo-chip-trigger").first();
|
||||
await expect(chipTrigger).toBeVisible({ timeout: 5_000 });
|
||||
await chipTrigger.click();
|
||||
const urlInput = testPage.getByTestId("remote-paste-url-input").last();
|
||||
const urlInput = testPage.getByTestId("remote-repo-input").last();
|
||||
await expect(urlInput).toBeVisible({ timeout: 5_000 });
|
||||
await urlInput.fill("https://github.com/subtask-owner/subtask-repo");
|
||||
await urlInput.press("Enter");
|
||||
|
||||
Reference in New Issue
Block a user