feat(KB-018): simplify GitHub import modal remote selection
- Simplify remote selection UI in GitHub import modal - Refactor GitHubImportModal component for better UX - Update and expand test coverage for modal interactions - Add changeset documenting the UI simplification
This commit is contained in:
@@ -59,16 +59,21 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks }: GitHubIm
|
||||
setRemotes(fetchedRemotes);
|
||||
setLoadingRemotes(false);
|
||||
|
||||
// Auto-populate if exactly one remote and fields are empty
|
||||
if (fetchedRemotes.length === 1) {
|
||||
// Single remote: auto-select it
|
||||
const remote = fetchedRemotes[0];
|
||||
setOwner(remote.owner);
|
||||
setRepo(remote.repo);
|
||||
setSelectedRemoteName(remote.name);
|
||||
} else if (fetchedRemotes.length > 1) {
|
||||
// Multiple remotes: don't auto-select, user must choose
|
||||
setOwner("");
|
||||
setRepo("");
|
||||
setSelectedRemoteName("");
|
||||
}
|
||||
// If no remotes, owner/repo remain empty
|
||||
})
|
||||
.catch(() => {
|
||||
// Silently fail - manual input remains available
|
||||
if (mountedRef.current) {
|
||||
setLoadingRemotes(false);
|
||||
}
|
||||
@@ -84,7 +89,6 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks }: GitHubIm
|
||||
const handleRemoteChange = useCallback((remoteName: string) => {
|
||||
setSelectedRemoteName(remoteName);
|
||||
if (remoteName === "") {
|
||||
// Manual mode - clear fields
|
||||
setOwner("");
|
||||
setRepo("");
|
||||
} else {
|
||||
@@ -108,7 +112,7 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks }: GitHubIm
|
||||
|
||||
const handleLoad = useCallback(async () => {
|
||||
if (!owner.trim() || !repo.trim()) {
|
||||
setError("Owner and repo are required");
|
||||
setError("Repository must be selected");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -159,9 +163,10 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks }: GitHubIm
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
// Determine if we should show the remote dropdown
|
||||
const showRemoteDropdown = remotes.length > 1 || (remotes.length === 1 && !loadingRemotes);
|
||||
// Determine the repository selection UI state
|
||||
const hasRemotes = remotes.length > 0;
|
||||
const singleRemote = remotes.length === 1;
|
||||
const multipleRemotes = remotes.length > 1;
|
||||
|
||||
return (
|
||||
<div className="modal-overlay open" onClick={(e) => e.target === e.currentTarget && onClose()}>
|
||||
@@ -174,59 +179,54 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks }: GitHubIm
|
||||
</div>
|
||||
|
||||
<div className="modal-body">
|
||||
{/* Remote Selection Dropdown */}
|
||||
{(showRemoteDropdown || loadingRemotes) && (
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label htmlFor="gh-remote">
|
||||
Repository
|
||||
{loadingRemotes && <Loader2 size={12} className="spin" style={{ marginLeft: 8, display: "inline" }} />}
|
||||
</label>
|
||||
{/* Repository Selection Section */}
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label htmlFor="gh-remote">
|
||||
Repository
|
||||
{loadingRemotes && <Loader2 size={12} className="spin" style={{ marginLeft: 8, display: "inline" }} />}
|
||||
</label>
|
||||
|
||||
{/* Loading state */}
|
||||
{loadingRemotes && (
|
||||
<span className="text-muted">Loading remotes...</span>
|
||||
)}
|
||||
|
||||
{/* No remotes */}
|
||||
{!loadingRemotes && !hasRemotes && (
|
||||
<div className="form-error">
|
||||
No GitHub remotes detected. Add a remote with:
|
||||
<code style={{ display: "block", marginTop: 4, padding: 4, background: "#f5f5f5", borderRadius: 4 }}>
|
||||
git remote add origin https://github.com/owner/repo.git
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Single remote - read only display */}
|
||||
{!loadingRemotes && singleRemote && (
|
||||
<span className="text-muted">
|
||||
{remotes[0].name} ({remotes[0].owner}/{remotes[0].repo})
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Multiple remotes - dropdown */}
|
||||
{!loadingRemotes && multipleRemotes && (
|
||||
<select
|
||||
id="gh-remote"
|
||||
value={selectedRemoteName}
|
||||
onChange={(e) => handleRemoteChange(e.target.value)}
|
||||
disabled={loadingRemotes || loading || importing}
|
||||
disabled={loading || importing}
|
||||
>
|
||||
{hasRemotes && <option value="">Select a remote...</option>}
|
||||
{loadingRemotes && <option value="">Loading remotes...</option>}
|
||||
{!hasRemotes && !loadingRemotes && <option value="">No GitHub remotes detected</option>}
|
||||
<option value="">Select a remote...</option>
|
||||
{remotes.map((remote) => (
|
||||
<option key={remote.name} value={remote.name}>
|
||||
{remote.name} ({remote.owner}/{remote.repo})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form Row */}
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label htmlFor="gh-owner">Owner</label>
|
||||
<input
|
||||
id="gh-owner"
|
||||
type="text"
|
||||
placeholder="e.g. dustinbyrne"
|
||||
value={owner}
|
||||
onChange={(e) => setOwner(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleLoad()}
|
||||
disabled={loading || importing}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="gh-repo">Repo</label>
|
||||
<input
|
||||
id="gh-repo"
|
||||
type="text"
|
||||
placeholder="e.g. kb"
|
||||
value={repo}
|
||||
onChange={(e) => setRepo(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleLoad()}
|
||||
disabled={loading || importing}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="gh-labels">Labels (optional)</label>
|
||||
<input
|
||||
@@ -239,9 +239,14 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks }: GitHubIm
|
||||
disabled={loading || importing}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group form-group--action">
|
||||
<label> </label>
|
||||
<button className="btn btn-primary" onClick={handleLoad} disabled={loading || importing || !owner.trim() || !repo.trim()}>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleLoad}
|
||||
disabled={loading || importing || !owner.trim() || !repo.trim()}
|
||||
>
|
||||
{loading ? <Loader2 size={14} className="spin" /> : "Load"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { GitHubImportModal } from "../GitHubImportModal";
|
||||
import { apiFetchGitHubIssues, apiImportGitHubIssue } from "../../api";
|
||||
import { apiFetchGitHubIssues, apiImportGitHubIssue, fetchGitRemotes } from "../../api";
|
||||
import type { Task } from "@kb/core";
|
||||
import type { GitRemote } from "../../api";
|
||||
|
||||
// Mock the API module
|
||||
vi.mock("../../api", async (importOriginal) => {
|
||||
@@ -11,7 +12,7 @@ vi.mock("../../api", async (importOriginal) => {
|
||||
...actual,
|
||||
apiFetchGitHubIssues: vi.fn(),
|
||||
apiImportGitHubIssue: vi.fn(),
|
||||
fetchGitRemotes: vi.fn().mockResolvedValue([]),
|
||||
fetchGitRemotes: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -28,6 +29,15 @@ const mockTask: Task = {
|
||||
updatedAt: "2024-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
const singleRemote: GitRemote[] = [
|
||||
{ name: "origin", owner: "dustinbyrne", repo: "kb", url: "https://github.com/dustinbyrne/kb.git" },
|
||||
];
|
||||
|
||||
const multipleRemotes: GitRemote[] = [
|
||||
{ name: "origin", owner: "dustinbyrne", repo: "kb", url: "https://github.com/dustinbyrne/kb.git" },
|
||||
{ name: "upstream", owner: "upstream", repo: "kb", url: "https://github.com/upstream/kb.git" },
|
||||
];
|
||||
|
||||
describe("GitHubImportModal", () => {
|
||||
const onClose = vi.fn();
|
||||
const onImport = vi.fn();
|
||||
@@ -36,9 +46,12 @@ describe("GitHubImportModal", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders when isOpen is true", () => {
|
||||
it("renders when isOpen is true", async () => {
|
||||
vi.mocked(fetchGitRemotes).mockResolvedValueOnce([]);
|
||||
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
|
||||
expect(screen.getByText("Import from GitHub")).toBeTruthy();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Import from GitHub")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("does not render when isOpen is false", () => {
|
||||
@@ -46,220 +59,355 @@ describe("GitHubImportModal", () => {
|
||||
expect(screen.queryByText("Import from GitHub")).toBeNull();
|
||||
});
|
||||
|
||||
it("has owner and repo inputs", () => {
|
||||
it("has optional labels input", async () => {
|
||||
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
|
||||
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
|
||||
expect(screen.getByLabelText("Owner")).toBeTruthy();
|
||||
expect(screen.getByLabelText("Repo")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("has optional labels input", () => {
|
||||
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
|
||||
expect(screen.getByLabelText(/Labels/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("disables Load button when owner or repo is empty", () => {
|
||||
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
|
||||
const loadButton = screen.getByRole("button", { name: /Load/i }) as HTMLButtonElement;
|
||||
expect(loadButton.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("enables Load button when owner and repo are filled", () => {
|
||||
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
|
||||
const ownerInput = screen.getByLabelText("Owner");
|
||||
const repoInput = screen.getByLabelText("Repo");
|
||||
|
||||
fireEvent.change(ownerInput, { target: { value: "dustinbyrne" } });
|
||||
fireEvent.change(repoInput, { target: { value: "kb" } });
|
||||
|
||||
const loadButton = screen.getByRole("button", { name: /Load/i }) as HTMLButtonElement;
|
||||
expect(loadButton.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("calls apiFetchGitHubIssues when Load is clicked", async () => {
|
||||
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce([]);
|
||||
|
||||
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
|
||||
const ownerInput = screen.getByLabelText("Owner");
|
||||
const repoInput = screen.getByLabelText("Repo");
|
||||
|
||||
fireEvent.change(ownerInput, { target: { value: "dustinbyrne" } });
|
||||
fireEvent.change(repoInput, { target: { value: "kb" } });
|
||||
|
||||
const loadButton = screen.getByRole("button", { name: /Load/i });
|
||||
fireEvent.click(loadButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(apiFetchGitHubIssues).toHaveBeenCalledWith("dustinbyrne", "kb", 30, undefined);
|
||||
expect(screen.getByLabelText(/Labels/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("displays fetched issues after loading", async () => {
|
||||
const issues = [
|
||||
{ number: 1, title: "First Issue", body: "Body 1", html_url: "https://github.com/owner/repo/issues/1", labels: [] },
|
||||
{ number: 2, title: "Second Issue", body: "Body 2", html_url: "https://github.com/owner/repo/issues/2", labels: [] },
|
||||
];
|
||||
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce(issues);
|
||||
describe("with no remotes", () => {
|
||||
it("shows 'No GitHub remotes detected' message", async () => {
|
||||
vi.mocked(fetchGitRemotes).mockResolvedValueOnce([]);
|
||||
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/No GitHub remotes detected/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
|
||||
const ownerInput = screen.getByLabelText("Owner");
|
||||
const repoInput = screen.getByLabelText("Repo");
|
||||
|
||||
fireEvent.change(ownerInput, { target: { value: "owner" } });
|
||||
fireEvent.change(repoInput, { target: { value: "repo" } });
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Load/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("First Issue")).toBeTruthy();
|
||||
expect(screen.getByText("Second Issue")).toBeTruthy();
|
||||
it("disables Load button when no remotes available", async () => {
|
||||
vi.mocked(fetchGitRemotes).mockResolvedValueOnce([]);
|
||||
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
|
||||
await waitFor(() => {
|
||||
const loadButton = screen.getByRole("button", { name: /Load/i }) as HTMLButtonElement;
|
||||
expect(loadButton.disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("selects an issue when clicked", async () => {
|
||||
const issues = [
|
||||
{ number: 1, title: "First Issue", body: "Body 1", html_url: "https://github.com/owner/repo/issues/1", labels: [] },
|
||||
];
|
||||
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce(issues);
|
||||
|
||||
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
|
||||
fireEvent.change(screen.getByLabelText("Owner"), { target: { value: "owner" } });
|
||||
fireEvent.change(screen.getByLabelText("Repo"), { target: { value: "repo" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /Load/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("First Issue")).toBeTruthy();
|
||||
describe("with single remote", () => {
|
||||
it("auto-selects the remote and shows as read-only text", async () => {
|
||||
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
|
||||
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/origin \(dustinbyrne\/kb\)/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
const radio = screen.getByRole("radio") as HTMLInputElement;
|
||||
fireEvent.click(radio);
|
||||
it("does not show a dropdown", async () => {
|
||||
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
|
||||
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("combobox")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
expect(radio.checked).toBe(true);
|
||||
it("enables Load button when remote is auto-selected", async () => {
|
||||
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
|
||||
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
|
||||
await waitFor(() => {
|
||||
const loadButton = screen.getByRole("button", { name: /Load/i }) as HTMLButtonElement;
|
||||
expect(loadButton.disabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("calls apiFetchGitHubIssues when Load is clicked", async () => {
|
||||
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
|
||||
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce([]);
|
||||
|
||||
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/origin \(dustinbyrne\/kb\)/)).toBeTruthy();
|
||||
});
|
||||
|
||||
const loadButton = screen.getByRole("button", { name: /Load/i });
|
||||
fireEvent.click(loadButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(apiFetchGitHubIssues).toHaveBeenCalledWith("dustinbyrne", "kb", 30, undefined);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("disables Import button when no issue is selected", async () => {
|
||||
const issues = [
|
||||
{ number: 1, title: "First Issue", body: "Body 1", html_url: "https://github.com/owner/repo/issues/1", labels: [] },
|
||||
];
|
||||
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce(issues);
|
||||
|
||||
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
|
||||
fireEvent.change(screen.getByLabelText("Owner"), { target: { value: "owner" } });
|
||||
fireEvent.change(screen.getByLabelText("Repo"), { target: { value: "repo" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /Load/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("First Issue")).toBeTruthy();
|
||||
describe("with multiple remotes", () => {
|
||||
it("shows a dropdown with all remotes", async () => {
|
||||
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(multipleRemotes);
|
||||
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
|
||||
await waitFor(() => {
|
||||
const select = screen.getByRole("combobox");
|
||||
expect(select).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
const importButton = screen.getByRole("button", { name: /Import$/i }) as HTMLButtonElement;
|
||||
expect(importButton.disabled).toBe(true);
|
||||
it("dropdown has placeholder and all remote options", async () => {
|
||||
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(multipleRemotes);
|
||||
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
|
||||
await waitFor(() => {
|
||||
const select = screen.getByRole("combobox") as HTMLSelectElement;
|
||||
expect(select).toBeTruthy();
|
||||
// Check options are rendered
|
||||
const options = Array.from(select.options).map(o => o.text);
|
||||
expect(options).toContain("Select a remote...");
|
||||
expect(options).toContain("origin (dustinbyrne/kb)");
|
||||
expect(options).toContain("upstream (upstream/kb)");
|
||||
});
|
||||
});
|
||||
|
||||
it("disables Load button when no remote is selected", async () => {
|
||||
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(multipleRemotes);
|
||||
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
|
||||
await waitFor(() => {
|
||||
const loadButton = screen.getByRole("button", { name: /Load/i }) as HTMLButtonElement;
|
||||
expect(loadButton.disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("enables Load button after selecting a remote", async () => {
|
||||
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(multipleRemotes);
|
||||
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("combobox")).toBeTruthy();
|
||||
});
|
||||
|
||||
const select = screen.getByRole("combobox");
|
||||
fireEvent.change(select, { target: { value: "origin" } });
|
||||
|
||||
const loadButton = screen.getByRole("button", { name: /Load/i }) as HTMLButtonElement;
|
||||
expect(loadButton.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("switches owner/repo when changing remote selection", async () => {
|
||||
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(multipleRemotes);
|
||||
vi.mocked(apiFetchGitHubIssues)
|
||||
.mockResolvedValueOnce([{ number: 1, title: "Issue from origin", body: "", html_url: "https://github.com/dustinbyrne/kb/issues/1", labels: [] }])
|
||||
.mockResolvedValueOnce([{ number: 2, title: "Issue from upstream", body: "", html_url: "https://github.com/upstream/kb/issues/2", labels: [] }]);
|
||||
|
||||
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("combobox")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Select origin and load
|
||||
const select = screen.getByRole("combobox");
|
||||
fireEvent.change(select, { target: { value: "origin" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /Load/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(apiFetchGitHubIssues).toHaveBeenCalledWith("dustinbyrne", "kb", 30, undefined);
|
||||
expect(screen.getByText("Issue from origin")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Switch to upstream and load again
|
||||
fireEvent.change(select, { target: { value: "upstream" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /Load/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(apiFetchGitHubIssues).toHaveBeenLastCalledWith("upstream", "kb", 30, undefined);
|
||||
expect(screen.getByText("Issue from upstream")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("calls apiImportGitHubIssue and onImport when Import is clicked", async () => {
|
||||
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce([
|
||||
{ number: 1, title: "First Issue", body: "Body 1", html_url: "https://github.com/owner/repo/issues/1", labels: [] },
|
||||
]);
|
||||
vi.mocked(apiImportGitHubIssue).mockResolvedValueOnce(mockTask);
|
||||
describe("issue loading and import", () => {
|
||||
it("displays fetched issues after loading", async () => {
|
||||
const issues = [
|
||||
{ number: 1, title: "First Issue", body: "Body 1", html_url: "https://github.com/owner/repo/issues/1", labels: [] },
|
||||
{ number: 2, title: "Second Issue", body: "Body 2", html_url: "https://github.com/owner/repo/issues/2", labels: [] },
|
||||
];
|
||||
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
|
||||
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce(issues);
|
||||
|
||||
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
|
||||
fireEvent.change(screen.getByLabelText("Owner"), { target: { value: "owner" } });
|
||||
fireEvent.change(screen.getByLabelText("Repo"), { target: { value: "repo" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /Load/i }));
|
||||
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/origin/)).toBeTruthy();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("First Issue")).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: /Load/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("First Issue")).toBeTruthy();
|
||||
expect(screen.getByText("Second Issue")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("radio"));
|
||||
fireEvent.click(screen.getByRole("button", { name: /Import$/i }));
|
||||
it("selects an issue when clicked", async () => {
|
||||
const issues = [
|
||||
{ number: 1, title: "First Issue", body: "Body 1", html_url: "https://github.com/owner/repo/issues/1", labels: [] },
|
||||
];
|
||||
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
|
||||
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce(issues);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(apiImportGitHubIssue).toHaveBeenCalledWith("owner", "repo", 1);
|
||||
expect(onImport).toHaveBeenCalledWith(mockTask);
|
||||
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/origin/)).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Load/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("First Issue")).toBeTruthy();
|
||||
});
|
||||
|
||||
const radio = screen.getByRole("radio") as HTMLInputElement;
|
||||
fireEvent.click(radio);
|
||||
|
||||
expect(radio.checked).toBe(true);
|
||||
});
|
||||
|
||||
it("disables Import button when no issue is selected", async () => {
|
||||
const issues = [
|
||||
{ number: 1, title: "First Issue", body: "Body 1", html_url: "https://github.com/owner/repo/issues/1", labels: [] },
|
||||
];
|
||||
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
|
||||
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce(issues);
|
||||
|
||||
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/origin/)).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Load/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("First Issue")).toBeTruthy();
|
||||
});
|
||||
|
||||
const importButton = screen.getByRole("button", { name: /Import$/i }) as HTMLButtonElement;
|
||||
expect(importButton.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("calls apiImportGitHubIssue and onImport when Import is clicked", async () => {
|
||||
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
|
||||
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce([
|
||||
{ number: 1, title: "First Issue", body: "Body 1", html_url: "https://github.com/owner/repo/issues/1", labels: [] },
|
||||
]);
|
||||
vi.mocked(apiImportGitHubIssue).mockResolvedValueOnce(mockTask);
|
||||
|
||||
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/origin/)).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Load/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("First Issue")).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("radio"));
|
||||
fireEvent.click(screen.getByRole("button", { name: /Import$/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(apiImportGitHubIssue).toHaveBeenCalledWith("dustinbyrne", "kb", 1);
|
||||
expect(onImport).toHaveBeenCalledWith(mockTask);
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows 'Imported' badge for already imported issues", async () => {
|
||||
const existingTask: Task = {
|
||||
...mockTask,
|
||||
description: "Existing\n\nSource: https://github.com/owner/repo/issues/1",
|
||||
};
|
||||
const issues = [
|
||||
{ number: 1, title: "First Issue", body: "Body 1", html_url: "https://github.com/owner/repo/issues/1", labels: [] },
|
||||
];
|
||||
vi.mocked(fetchGitRemotes).mockResolvedValueOnce([{ name: "origin", owner: "owner", repo: "repo", url: "" }]);
|
||||
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce(issues);
|
||||
|
||||
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[existingTask]} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/origin/)).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Load/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Imported")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("disables radio buttons for already imported issues", async () => {
|
||||
const existingTask: Task = {
|
||||
...mockTask,
|
||||
description: "Existing\n\nSource: https://github.com/owner/repo/issues/1",
|
||||
};
|
||||
const issues = [
|
||||
{ number: 1, title: "First Issue", body: "Body 1", html_url: "https://github.com/owner/repo/issues/1", labels: [] },
|
||||
];
|
||||
vi.mocked(fetchGitRemotes).mockResolvedValueOnce([{ name: "origin", owner: "owner", repo: "repo", url: "" }]);
|
||||
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce(issues);
|
||||
|
||||
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[existingTask]} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/origin/)).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Load/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
const radio = screen.getByRole("radio") as HTMLInputElement;
|
||||
expect(radio.disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("displays error on fetch failure", async () => {
|
||||
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
|
||||
vi.mocked(apiFetchGitHubIssues).mockRejectedValueOnce(new Error("Repository not found"));
|
||||
|
||||
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/origin/)).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Load/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Repository not found")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("displays label chips for issues with labels", async () => {
|
||||
const issues = [
|
||||
{ number: 1, title: "Bug Issue", body: "Body", html_url: "https://github.com/owner/repo/issues/1", labels: [{ name: "bug" }, { name: "urgent" }] },
|
||||
];
|
||||
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
|
||||
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce(issues);
|
||||
|
||||
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/origin/)).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Load/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("bug")).toBeTruthy();
|
||||
expect(screen.getByText("urgent")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("modal actions", () => {
|
||||
it("closes modal on Cancel button click", async () => {
|
||||
vi.mocked(fetchGitRemotes).mockResolvedValueOnce([]);
|
||||
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: /Cancel/i })).toBeTruthy();
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: /Cancel/i }));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closes modal on X button click", async () => {
|
||||
vi.mocked(fetchGitRemotes).mockResolvedValueOnce([]);
|
||||
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("×")).toBeTruthy();
|
||||
});
|
||||
fireEvent.click(screen.getByText("×"));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows 'Imported' badge for already imported issues", async () => {
|
||||
const existingTask: Task = {
|
||||
...mockTask,
|
||||
description: "Existing\n\nSource: https://github.com/owner/repo/issues/1",
|
||||
};
|
||||
const issues = [
|
||||
{ number: 1, title: "First Issue", body: "Body 1", html_url: "https://github.com/owner/repo/issues/1", labels: [] },
|
||||
];
|
||||
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce(issues);
|
||||
|
||||
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[existingTask]} />);
|
||||
fireEvent.change(screen.getByLabelText("Owner"), { target: { value: "owner" } });
|
||||
fireEvent.change(screen.getByLabelText("Repo"), { target: { value: "repo" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /Load/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Imported")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("disables radio buttons for already imported issues", async () => {
|
||||
const existingTask: Task = {
|
||||
...mockTask,
|
||||
description: "Existing\n\nSource: https://github.com/owner/repo/issues/1",
|
||||
};
|
||||
const issues = [
|
||||
{ number: 1, title: "First Issue", body: "Body 1", html_url: "https://github.com/owner/repo/issues/1", labels: [] },
|
||||
];
|
||||
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce(issues);
|
||||
|
||||
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[existingTask]} />);
|
||||
fireEvent.change(screen.getByLabelText("Owner"), { target: { value: "owner" } });
|
||||
fireEvent.change(screen.getByLabelText("Repo"), { target: { value: "repo" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /Load/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
const radio = screen.getByRole("radio") as HTMLInputElement;
|
||||
expect(radio.disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("displays error on fetch failure", async () => {
|
||||
vi.mocked(apiFetchGitHubIssues).mockRejectedValueOnce(new Error("Repository not found"));
|
||||
|
||||
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
|
||||
fireEvent.change(screen.getByLabelText("Owner"), { target: { value: "owner" } });
|
||||
fireEvent.change(screen.getByLabelText("Repo"), { target: { value: "repo" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /Load/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Repository not found")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("closes modal on Cancel button click", () => {
|
||||
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /Cancel/i }));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closes modal on X button click", () => {
|
||||
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
|
||||
fireEvent.click(screen.getByText("×"));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("displays label chips for issues with labels", async () => {
|
||||
const issues = [
|
||||
{ number: 1, title: "Bug Issue", body: "Body", html_url: "https://github.com/owner/repo/issues/1", labels: [{ name: "bug" }, { name: "urgent" }] },
|
||||
];
|
||||
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce(issues);
|
||||
|
||||
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
|
||||
fireEvent.change(screen.getByLabelText("Owner"), { target: { value: "owner" } });
|
||||
fireEvent.change(screen.getByLabelText("Repo"), { target: { value: "repo" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /Load/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("bug")).toBeTruthy();
|
||||
expect(screen.getByText("urgent")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user