682 lines
23 KiB
TypeScript
682 lines
23 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { act, renderHook, waitFor } from "@testing-library/react";
|
|
import { computeDialogDefaultStepId } from "./task-create-dialog-defaults";
|
|
import type { WorkflowSnapshotData } from "@/lib/state/slices/kanban/types";
|
|
import { useDialogFormState } from "./task-create-dialog-state";
|
|
import { buildRepositoriesPayload } from "./task-create-dialog-helpers";
|
|
import { TASK_TITLE_MAX_LENGTH } from "@/lib/task-title";
|
|
|
|
const BITBUCKET_HEAD_BRANCH = "feature/fix";
|
|
|
|
// `useBranchesByURL` triggers a real network ensure() when given a URL — stub
|
|
// it so the dialog state hook can mount in JSDOM without hitting fetch. The
|
|
// stubbed shape mirrors the production hook (branches/loading/ensure).
|
|
vi.mock("@/hooks/domains/github/use-branches-by-url", () => ({
|
|
useBranchesByURL: () => ({
|
|
branches: () => [],
|
|
loading: () => false,
|
|
ensure: () => undefined,
|
|
}),
|
|
}));
|
|
|
|
// `usePRInfoByURL` also touches the network on ensure(); stub it to a
|
|
// per-test-controlled cache so the title-autofill effect can be exercised
|
|
// without an actual fetch. Each test that needs a specific GitHub URL info
|
|
// value writes into `prInfoMap` before calling `setUseRemote(true)`.
|
|
const prInfoMap = new Map<
|
|
string,
|
|
{
|
|
prHeadBranch?: string;
|
|
prBaseBranch?: string;
|
|
prNumber?: number;
|
|
issueNumber?: number;
|
|
suggestedTitle: string;
|
|
}
|
|
>();
|
|
vi.mock("@/hooks/domains/github/use-pr-info-by-url", async (importOriginal) => {
|
|
const original =
|
|
await importOriginal<typeof import("@/hooks/domains/github/use-pr-info-by-url")>();
|
|
return {
|
|
...original,
|
|
usePRInfoByURL: () => ({
|
|
info: (url: string) => prInfoMap.get(url),
|
|
loading: () => false,
|
|
ensure: () => undefined,
|
|
clear: () => undefined,
|
|
}),
|
|
};
|
|
});
|
|
|
|
function snapshot(workflowId: string): WorkflowSnapshotData {
|
|
return {
|
|
workflowId,
|
|
workflowName: workflowId,
|
|
steps: [
|
|
{
|
|
id: `${workflowId}-later`,
|
|
title: "Later",
|
|
color: "gray",
|
|
position: 2,
|
|
},
|
|
{
|
|
id: `${workflowId}-start`,
|
|
title: "Start",
|
|
color: "green",
|
|
position: 1,
|
|
is_start_step: true,
|
|
},
|
|
],
|
|
tasks: [],
|
|
};
|
|
}
|
|
|
|
describe("computeDialogDefaultStepId", () => {
|
|
it("uses the resolved workflow when falling back to snapshot steps", () => {
|
|
expect(
|
|
computeDialogDefaultStepId({
|
|
selectedWorkflowId: null,
|
|
workflowId: "provided",
|
|
fetchedSteps: null,
|
|
defaultStepId: null,
|
|
effectiveWorkflowId: "provided",
|
|
snapshots: {
|
|
provided: snapshot("provided"),
|
|
single: snapshot("single"),
|
|
},
|
|
}),
|
|
).toBe("provided-start");
|
|
});
|
|
|
|
it("falls back to the lowest-position snapshot step when no start step exists", () => {
|
|
expect(
|
|
computeDialogDefaultStepId({
|
|
selectedWorkflowId: null,
|
|
workflowId: "provided",
|
|
fetchedSteps: null,
|
|
defaultStepId: null,
|
|
effectiveWorkflowId: "provided",
|
|
snapshots: {
|
|
provided: {
|
|
workflowId: "provided",
|
|
workflowName: "provided",
|
|
steps: [
|
|
{ id: "provided-2", title: "Two", color: "gray", position: 2 },
|
|
{ id: "provided-1", title: "One", color: "green", position: 1 },
|
|
],
|
|
tasks: [],
|
|
},
|
|
},
|
|
}),
|
|
).toBe("provided-1");
|
|
});
|
|
|
|
it("ignores a stale default step while a newly selected workflow loads", () => {
|
|
expect(
|
|
computeDialogDefaultStepId({
|
|
selectedWorkflowId: "selected",
|
|
workflowId: "original",
|
|
fetchedSteps: null,
|
|
defaultStepId: "original-start",
|
|
effectiveWorkflowId: "selected",
|
|
snapshots: {
|
|
original: snapshot("original"),
|
|
selected: snapshot("selected"),
|
|
},
|
|
}),
|
|
).toBe("selected-start");
|
|
});
|
|
});
|
|
|
|
describe("useDialogFormState — remoteRepos mode", () => {
|
|
it("seeds one empty remoteRepos row when useRemote toggles on with an empty list", () => {
|
|
const { result } = renderHook(() => useDialogFormState(true, "ws-1", null));
|
|
expect(result.current.remoteRepos).toHaveLength(0);
|
|
|
|
act(() => {
|
|
result.current.setUseRemote(true);
|
|
});
|
|
|
|
expect(result.current.remoteRepos).toHaveLength(1);
|
|
expect(result.current.remoteRepos[0]).toMatchObject({ url: "", branch: "", source: "paste" });
|
|
});
|
|
|
|
it("preserves the remoteRepos array when switching Remote → Repo → Remote", () => {
|
|
const PASTED_URL = "github.com/owner/repo";
|
|
const { result } = renderHook(() => useDialogFormState(true, "ws-1", null));
|
|
|
|
// Enter Remote mode, fill in a URL.
|
|
act(() => {
|
|
result.current.setUseRemote(true);
|
|
});
|
|
const seededKey = result.current.remoteRepos[0]?.key;
|
|
act(() => {
|
|
result.current.updateRemoteRepo(seededKey!, { url: PASTED_URL });
|
|
});
|
|
expect(result.current.remoteRepos[0]?.url).toBe(PASTED_URL);
|
|
|
|
// Switch back to Repo mode (Remote off). The array must NOT be cleared.
|
|
act(() => {
|
|
result.current.setUseRemote(false);
|
|
});
|
|
expect(result.current.remoteRepos[0]?.url).toBe(PASTED_URL);
|
|
|
|
// Flip back to Remote mode — the prior rows are still there.
|
|
act(() => {
|
|
result.current.setUseRemote(true);
|
|
});
|
|
expect(result.current.remoteRepos).toHaveLength(1);
|
|
expect(result.current.remoteRepos[0]?.url).toBe(PASTED_URL);
|
|
});
|
|
|
|
it("seeds remoteRepos from initialValues.githubUrl and sets useRemote=true on dialog open", () => {
|
|
const initialValues = {
|
|
title: "",
|
|
githubUrl: "github.com/acme/site",
|
|
branch: "main",
|
|
};
|
|
const { result, rerender } = renderHook(
|
|
({ open }: { open: boolean }) => useDialogFormState(open, "ws-1", null, initialValues),
|
|
{ initialProps: { open: false } },
|
|
);
|
|
|
|
// Rising edge: dialog opens with a pre-filled URL.
|
|
rerender({ open: true });
|
|
|
|
expect(result.current.useRemote).toBe(true);
|
|
expect(result.current.remoteRepos).toHaveLength(1);
|
|
expect(result.current.remoteRepos[0]).toMatchObject({
|
|
url: "github.com/acme/site",
|
|
branch: "main",
|
|
source: "paste",
|
|
});
|
|
});
|
|
|
|
it("prefers provider-neutral remoteUrl while retaining githubUrl compatibility", () => {
|
|
const initialValues = {
|
|
title: "",
|
|
remoteUrl: "https://bitbucket.example.test/bitbucket/scm/PLATFORM/web.git",
|
|
githubUrl: "https://github.com/acme/legacy",
|
|
branch: "main",
|
|
};
|
|
const { result } = renderHook(() => useDialogFormState(true, "ws-1", null, initialValues));
|
|
|
|
expect(result.current.useRemote).toBe(true);
|
|
expect(result.current.remoteRepos).toEqual([
|
|
expect.objectContaining({
|
|
url: "https://bitbucket.example.test/bitbucket/scm/PLATFORM/web.git",
|
|
branch: "main",
|
|
source: "paste",
|
|
}),
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("useDialogFormState — workspace changes", () => {
|
|
it("clears task-specific workflow overrides while the dialog remains open", async () => {
|
|
const { result, rerender } = renderHook(
|
|
({ workspaceId }: { workspaceId: string }) => useDialogFormState(true, workspaceId, null),
|
|
{ initialProps: { workspaceId: "workspace-a" } },
|
|
);
|
|
|
|
act(() => {
|
|
result.current.setWorkflowAgentOverrides({ source: "replacement" });
|
|
});
|
|
expect(result.current.workflowAgentOverrides).toEqual({ source: "replacement" });
|
|
|
|
rerender({ workspaceId: "workspace-b" });
|
|
|
|
await waitFor(() => expect(result.current.workflowAgentOverrides).toEqual({}));
|
|
});
|
|
});
|
|
|
|
describe("useDialogFormState — canvas task preset", () => {
|
|
it("restores the repository-free source and launch-only local preference", async () => {
|
|
const { result } = renderHook(() =>
|
|
useDialogFormState(true, "ws-1", null, {
|
|
title: "Create a canvas",
|
|
description: "Build a canvas",
|
|
noRepository: true,
|
|
preferLocalExecutor: true,
|
|
}),
|
|
);
|
|
|
|
await waitFor(() => expect(result.current.noRepository).toBe(true));
|
|
expect(result.current.workspacePath).toBe("");
|
|
expect(result.current.preferLocalExecutor).toBe(true);
|
|
expect(result.current.repositories).toEqual([]);
|
|
expect(result.current.useRemote).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("useDialogFormState — repository policy snapshots", () => {
|
|
it("restores a policy-backed repository row without marking it as edited", () => {
|
|
const { result } = renderHook(() =>
|
|
useDialogFormState(true, "ws-1", null, {
|
|
title: "Existing task",
|
|
repositories: [
|
|
{
|
|
id: "task-repo-1",
|
|
task_id: "task-1",
|
|
repository_id: "repo-1",
|
|
base_branch: "main",
|
|
checkout_branch: "feature/existing",
|
|
branch_policy_id: "policy-1",
|
|
branch_policy_name: "Feature branches",
|
|
branch_policy_base_branch: "main",
|
|
branch_policy_branch_template: "feature/{title}-{suffix}",
|
|
branch_policy_pull_request_target: "main",
|
|
position: 0,
|
|
created_at: "2026-08-24T10:00:00Z",
|
|
updated_at: "2026-08-24T10:00:00Z",
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
|
|
expect(result.current.repositories).toEqual([
|
|
expect.objectContaining({
|
|
repositoryId: "repo-1",
|
|
branch: "main",
|
|
branchPolicyId: "policy-1",
|
|
}),
|
|
]);
|
|
expect(result.current.repositoriesDirty).toBe(false);
|
|
|
|
act(() => {
|
|
result.current.updateRepository(result.current.repositories[0]!.key, { branch: "develop" });
|
|
});
|
|
|
|
expect(result.current.repositoriesDirty).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("useDialogFormState — provider-neutral initial values", () => {
|
|
it("seeds an authorized provider descriptor before asynchronous URL inspection", () => {
|
|
const initialValues = {
|
|
title: "",
|
|
remoteUrl: "https://bitbucket.example.test/projects/PLATFORM/repos/web/pull-requests/42",
|
|
checkoutBranch: BITBUCKET_HEAD_BRANCH,
|
|
remoteRepository: {
|
|
providerId: "bitbucket",
|
|
providerHost: "https://bitbucket.example.test",
|
|
ownerOrProject: "PLATFORM",
|
|
repositoryId: "web-42",
|
|
repositoryName: "web",
|
|
cloneUrl: "https://bitbucket.example.test/scm/PLATFORM/web.git",
|
|
defaultBranch: "trunk",
|
|
baseBranch: "trunk",
|
|
headBranch: BITBUCKET_HEAD_BRANCH,
|
|
pullRequest: { number: 42, title: "Fix" },
|
|
},
|
|
};
|
|
const { result } = renderHook(() => useDialogFormState(true, "ws-1", null, initialValues));
|
|
|
|
expect(result.current.remoteRepos[0]).toMatchObject({
|
|
url: initialValues.remoteUrl,
|
|
remoteUrl: "https://bitbucket.example.test/scm/PLATFORM/web.git",
|
|
provider: "bitbucket",
|
|
providerHost: "https://bitbucket.example.test",
|
|
providerRepoId: "web-42",
|
|
providerOwner: "PLATFORM",
|
|
providerName: "web",
|
|
branch: BITBUCKET_HEAD_BRANCH,
|
|
prNumber: 42,
|
|
prBaseBranch: "trunk",
|
|
prHeadBranch: BITBUCKET_HEAD_BRANCH,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("useDialogFormState — remote PR metadata", () => {
|
|
it("clears seeded PR metadata when a remote repo URL changes", () => {
|
|
const initialValues = {
|
|
title: "",
|
|
githubUrl: PR_URL_42,
|
|
branch: "feature/x",
|
|
checkoutBranch: "feature/x",
|
|
prNumber: 42,
|
|
prBaseBranch: "main",
|
|
};
|
|
const { result, rerender } = renderHook(
|
|
({ open }: { open: boolean }) => useDialogFormState(open, "ws-1", null, initialValues),
|
|
{ initialProps: { open: false } },
|
|
);
|
|
|
|
rerender({ open: true });
|
|
const key = result.current.remoteRepos[0]?.key;
|
|
expect(result.current.remoteRepos[0]).toMatchObject({
|
|
url: PR_URL_42,
|
|
branch: "feature/x",
|
|
prNumber: 42,
|
|
prBaseBranch: "main",
|
|
prHeadBranch: "feature/x",
|
|
});
|
|
|
|
act(() => {
|
|
result.current.updateRemoteRepo(key!, { url: "https://github.com/acme/site/pull/99" });
|
|
});
|
|
|
|
expect(result.current.remoteRepos[0]).toMatchObject({
|
|
url: "https://github.com/acme/site/pull/99",
|
|
branch: "feature/x",
|
|
});
|
|
expect(result.current.remoteRepos[0]?.prNumber).toBeUndefined();
|
|
expect(result.current.remoteRepos[0]?.prBaseBranch).toBeUndefined();
|
|
expect(result.current.remoteRepos[0]?.prHeadBranch).toBeUndefined();
|
|
});
|
|
|
|
it("preserves PR metadata supplied with a remote repo URL change", () => {
|
|
const { result } = renderHook(() => useDialogFormState(true, "ws-1", null));
|
|
|
|
act(() => {
|
|
result.current.setUseRemote(true);
|
|
});
|
|
const key = result.current.remoteRepos[0]?.key;
|
|
|
|
act(() => {
|
|
result.current.updateRemoteRepo(key!, {
|
|
url: PR_URL_42,
|
|
branch: "feature/x",
|
|
prNumber: 42,
|
|
prBaseBranch: "main",
|
|
prHeadBranch: "feature/x",
|
|
});
|
|
});
|
|
|
|
expect(result.current.remoteRepos[0]).toMatchObject({
|
|
url: PR_URL_42,
|
|
branch: "feature/x",
|
|
prNumber: 42,
|
|
prBaseBranch: "main",
|
|
prHeadBranch: "feature/x",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("useDialogFormState — remoteRepos key allocation", () => {
|
|
// Regression: the per-hook counter starts at 0 and increments locally, so
|
|
// a hydrated state that already contains `remote-1` (e.g. from the seed
|
|
// effect or initialValues) would collide on the next addRemoteRepo() —
|
|
// the new row would also be named `remote-1`, breaking React keys.
|
|
it("addRemoteRepo skips keys already present in the rows array", () => {
|
|
const { result } = renderHook(() => useDialogFormState(true, "ws-1", null));
|
|
|
|
// Flip into Remote mode so the seed effect injects `remote-0`.
|
|
act(() => {
|
|
result.current.setUseRemote(true);
|
|
});
|
|
expect(result.current.remoteRepos).toHaveLength(1);
|
|
expect(result.current.remoteRepos[0]?.key).toBe("remote-0");
|
|
|
|
// Manually hydrate with a row whose key matches what the local counter
|
|
// is about to hand out (remote-1).
|
|
act(() => {
|
|
result.current.setRemoteRepos([
|
|
{ key: "remote-1", url: "github.com/a/b", branch: "main", source: "paste" },
|
|
]);
|
|
});
|
|
|
|
act(() => {
|
|
result.current.addRemoteRepo();
|
|
});
|
|
|
|
const keys = result.current.remoteRepos.map((r) => r.key);
|
|
// No duplicates: hydrated `remote-1` still present, but the new row
|
|
// skipped past it instead of colliding.
|
|
expect(new Set(keys).size).toBe(keys.length);
|
|
expect(result.current.remoteRepos).toHaveLength(2);
|
|
expect(result.current.remoteRepos[0]?.key).toBe("remote-1");
|
|
expect(result.current.remoteRepos[1]?.key).not.toBe("remote-1");
|
|
});
|
|
});
|
|
|
|
describe("buildRepositoriesPayload — remoteRepos rows", () => {
|
|
it("filters out rows with empty url before mapping to repos[]", () => {
|
|
const payload = buildRepositoriesPayload({
|
|
useRemote: true,
|
|
remoteRepos: [
|
|
{ key: "remote-0", url: "github.com/owner/repo-a", branch: "main", source: "paste" },
|
|
{ key: "remote-1", url: "", branch: "", source: "paste" },
|
|
{ key: "remote-2", url: " ", branch: "", source: "paste" },
|
|
{ key: "remote-3", url: "github.com/owner/repo-b", branch: "develop", source: "paste" },
|
|
],
|
|
repositories: [],
|
|
discoveredRepositories: [],
|
|
});
|
|
expect(payload).toHaveLength(2);
|
|
expect(payload[0]).toMatchObject({
|
|
github_url: "github.com/owner/repo-a",
|
|
});
|
|
expect(payload[1]).toMatchObject({
|
|
github_url: "github.com/owner/repo-b",
|
|
base_branch: "develop",
|
|
});
|
|
});
|
|
});
|
|
|
|
const PR_URL_42 = "https://github.com/acme/site/pull/42";
|
|
const PR_TITLE_42 = "PR #42: Test PR";
|
|
const USER_TYPED_TITLE = "my own title";
|
|
const ISSUE_URL_1456 = "https://github.com/acme/site/issues/1456";
|
|
const ISSUE_TITLE_1456 = "Issue #1456: Fix remote picker";
|
|
|
|
function seedPRInfo(url: string, prNumber: number, suggestedTitle: string) {
|
|
prInfoMap.set(url, {
|
|
prHeadBranch: "feature/x",
|
|
prBaseBranch: "main",
|
|
prNumber,
|
|
suggestedTitle,
|
|
});
|
|
}
|
|
|
|
function seedIssueInfo(url: string, issueNumber: number, suggestedTitle: string) {
|
|
prInfoMap.set(url, {
|
|
issueNumber,
|
|
suggestedTitle,
|
|
});
|
|
}
|
|
|
|
describe("useDialogFormState — title autofill from first row GitHub URL info", () => {
|
|
beforeEach(() => {
|
|
prInfoMap.clear();
|
|
});
|
|
|
|
it("seeds the task title from the first row's PR info when title is empty", () => {
|
|
seedPRInfo(PR_URL_42, 42, PR_TITLE_42);
|
|
const { result } = renderHook(() => useDialogFormState(true, "ws-1", null));
|
|
act(() => {
|
|
result.current.setUseRemote(true);
|
|
});
|
|
const key = result.current.remoteRepos[0]?.key;
|
|
act(() => {
|
|
result.current.updateRemoteRepo(key!, { url: PR_URL_42 });
|
|
});
|
|
expect(result.current.taskName).toBe(PR_TITLE_42);
|
|
expect(result.current.hasTitle).toBe(true);
|
|
});
|
|
|
|
it("truncates a long remote PR title to the shared limit", () => {
|
|
const longTitle = "PR #42: " + "x".repeat(100);
|
|
seedPRInfo(PR_URL_42, 42, longTitle);
|
|
const { result } = renderHook(() => useDialogFormState(true, "ws-1", null));
|
|
act(() => {
|
|
result.current.setUseRemote(true);
|
|
});
|
|
const key = result.current.remoteRepos[0]?.key;
|
|
act(() => {
|
|
result.current.updateRemoteRepo(key!, { url: PR_URL_42 });
|
|
});
|
|
|
|
expect(result.current.taskName).toHaveLength(TASK_TITLE_MAX_LENGTH);
|
|
expect(result.current.taskName.endsWith("…")).toBe(true);
|
|
});
|
|
|
|
it("does NOT overwrite a title the user typed themselves", () => {
|
|
seedPRInfo(PR_URL_42, 42, PR_TITLE_42);
|
|
const { result } = renderHook(() => useDialogFormState(true, "ws-1", null));
|
|
act(() => {
|
|
result.current.setTaskName(USER_TYPED_TITLE);
|
|
result.current.setUseRemote(true);
|
|
});
|
|
const key = result.current.remoteRepos[0]?.key;
|
|
act(() => {
|
|
result.current.updateRemoteRepo(key!, { url: PR_URL_42 });
|
|
});
|
|
expect(result.current.taskName).toBe(USER_TYPED_TITLE);
|
|
});
|
|
});
|
|
|
|
describe("useDialogFormState — title autofill after title ownership", () => {
|
|
beforeEach(() => {
|
|
prInfoMap.clear();
|
|
});
|
|
|
|
it("does NOT re-apply autofill after the user clears the title (user took ownership)", () => {
|
|
// Regression: clearing an auto-filled title used to reset the ref to ""
|
|
// and trigger a re-application on the next render, so the user could
|
|
// never actually clear the field — every keystroke or render brought
|
|
// the suggested title right back.
|
|
seedPRInfo(PR_URL_42, 42, PR_TITLE_42);
|
|
const { result } = renderHook(() => useDialogFormState(true, "ws-1", null));
|
|
act(() => {
|
|
result.current.setUseRemote(true);
|
|
});
|
|
const key = result.current.remoteRepos[0]?.key;
|
|
act(() => {
|
|
result.current.updateRemoteRepo(key!, { url: PR_URL_42 });
|
|
});
|
|
expect(result.current.taskName).toBe(PR_TITLE_42);
|
|
act(() => {
|
|
result.current.setTaskName("");
|
|
});
|
|
// Even after re-render, autofill MUST NOT reapply for this URL.
|
|
expect(result.current.taskName).toBe("");
|
|
});
|
|
|
|
it("re-applies autofill when the user switches to a different PR URL", () => {
|
|
// Once the user pastes a fresh PR URL, the previous "user-cleared" lock
|
|
// for the earlier URL must lift — the fresh URL is a new autofill
|
|
// opportunity.
|
|
seedPRInfo(PR_URL_42, 42, PR_TITLE_42);
|
|
const NEW_PR_URL = "https://github.com/acme/site/pull/99";
|
|
seedPRInfo(NEW_PR_URL, 99, "PR #99: Another PR");
|
|
const { result } = renderHook(() => useDialogFormState(true, "ws-1", null));
|
|
act(() => {
|
|
result.current.setUseRemote(true);
|
|
});
|
|
const key = result.current.remoteRepos[0]?.key;
|
|
act(() => {
|
|
result.current.updateRemoteRepo(key!, { url: PR_URL_42 });
|
|
});
|
|
expect(result.current.taskName).toBe(PR_TITLE_42);
|
|
act(() => {
|
|
result.current.setTaskName("");
|
|
});
|
|
expect(result.current.taskName).toBe("");
|
|
|
|
// Switch to a different PR URL → fresh autofill opportunity.
|
|
act(() => {
|
|
result.current.updateRemoteRepo(key!, { url: NEW_PR_URL });
|
|
});
|
|
expect(result.current.taskName).toBe("PR #99: Another PR");
|
|
});
|
|
|
|
it("autofills from the first non-empty row when an earlier placeholder is empty", () => {
|
|
const SECOND_PR_URL = "https://github.com/acme/api/pull/99";
|
|
prInfoMap.set(SECOND_PR_URL, {
|
|
prHeadBranch: "feature/y",
|
|
prBaseBranch: "main",
|
|
prNumber: 99,
|
|
suggestedTitle: "PR #99: Second PR",
|
|
});
|
|
const { result } = renderHook(() => useDialogFormState(true, "ws-1", null));
|
|
act(() => {
|
|
result.current.setUseRemote(true);
|
|
});
|
|
// Add a second row with a PR URL; row 0 stays empty.
|
|
act(() => {
|
|
result.current.addRemoteRepo();
|
|
});
|
|
const secondKey = result.current.remoteRepos[1]?.key;
|
|
act(() => {
|
|
result.current.updateRemoteRepo(secondKey!, { url: SECOND_PR_URL });
|
|
});
|
|
expect(result.current.taskName).toBe("PR #99: Second PR");
|
|
});
|
|
});
|
|
|
|
describe("useDialogFormState — title autofill from first row GitHub issue info", () => {
|
|
beforeEach(() => {
|
|
prInfoMap.clear();
|
|
});
|
|
|
|
it("seeds the task title from the first row's issue info when title is empty", () => {
|
|
seedIssueInfo(ISSUE_URL_1456, 1456, ISSUE_TITLE_1456);
|
|
const { result } = renderHook(() => useDialogFormState(true, "ws-1", null));
|
|
act(() => {
|
|
result.current.setUseRemote(true);
|
|
});
|
|
const key = result.current.remoteRepos[0]?.key;
|
|
act(() => {
|
|
result.current.updateRemoteRepo(key!, { url: ISSUE_URL_1456 });
|
|
});
|
|
expect(result.current.taskName).toBe(ISSUE_TITLE_1456);
|
|
expect(result.current.hasTitle).toBe(true);
|
|
});
|
|
|
|
it("does NOT overwrite a title the user typed themselves", () => {
|
|
seedIssueInfo(ISSUE_URL_1456, 1456, ISSUE_TITLE_1456);
|
|
const { result } = renderHook(() => useDialogFormState(true, "ws-1", null));
|
|
act(() => {
|
|
result.current.setTaskName(USER_TYPED_TITLE);
|
|
result.current.setUseRemote(true);
|
|
});
|
|
const key = result.current.remoteRepos[0]?.key;
|
|
act(() => {
|
|
result.current.updateRemoteRepo(key!, { url: ISSUE_URL_1456 });
|
|
});
|
|
expect(result.current.taskName).toBe(USER_TYPED_TITLE);
|
|
});
|
|
|
|
it("does NOT re-apply autofill after the user clears the title", () => {
|
|
seedIssueInfo(ISSUE_URL_1456, 1456, ISSUE_TITLE_1456);
|
|
const { result } = renderHook(() => useDialogFormState(true, "ws-1", null));
|
|
act(() => {
|
|
result.current.setUseRemote(true);
|
|
});
|
|
const key = result.current.remoteRepos[0]?.key;
|
|
act(() => {
|
|
result.current.updateRemoteRepo(key!, { url: ISSUE_URL_1456 });
|
|
});
|
|
expect(result.current.taskName).toBe(ISSUE_TITLE_1456);
|
|
act(() => {
|
|
result.current.setTaskName("");
|
|
});
|
|
expect(result.current.taskName).toBe("");
|
|
});
|
|
|
|
it("re-applies autofill when the user switches to a different issue URL", () => {
|
|
const newIssueURL = "https://github.com/acme/site/issues/1457";
|
|
seedIssueInfo(ISSUE_URL_1456, 1456, ISSUE_TITLE_1456);
|
|
seedIssueInfo(newIssueURL, 1457, "Issue #1457: Add issue URL paste");
|
|
const { result } = renderHook(() => useDialogFormState(true, "ws-1", null));
|
|
act(() => {
|
|
result.current.setUseRemote(true);
|
|
});
|
|
const key = result.current.remoteRepos[0]?.key;
|
|
act(() => {
|
|
result.current.updateRemoteRepo(key!, { url: ISSUE_URL_1456 });
|
|
});
|
|
expect(result.current.taskName).toBe(ISSUE_TITLE_1456);
|
|
act(() => {
|
|
result.current.setTaskName("");
|
|
});
|
|
expect(result.current.taskName).toBe("");
|
|
|
|
act(() => {
|
|
result.current.updateRemoteRepo(key!, { url: newIssueURL });
|
|
});
|
|
expect(result.current.taskName).toBe("Issue #1457: Add issue URL paste");
|
|
});
|
|
});
|