FN-7442: return GitHub issue actions to list

Return successful GitHub issue import and close flows to the issue list.

- Clear the selected GitHub issue and switch mobile preview back to the list after successful import or close actions.
- Preserve the selected preview on failures so users can retry with the existing error affordance.
- Add regression coverage for desktop and mobile import/close success and failure navigation states.
- Document the post-import list reset behavior and add a patch changeset.

Files changed:
 .changeset/fn-7442-github-import-return-list.md    |   7 +
 docs/dashboard-guide.md                            |   2 +-
 .../dashboard/app/components/GitHubImportModal.tsx |  20 +-
 .../__tests__/GitHubImportModal.test.tsx           | 210 +++++++++++++++++++--
 4 files changed, 218 insertions(+), 21 deletions(-)

Fusion-Task-Id: FN-7442

Fusion-Task-Lineage: e334a7c1-68de-48f7-be31-b036ba741685

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-02 13:30:08 -07:00
parent 72adb5231a
commit 25fecd7843
4 changed files with 218 additions and 21 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Return GitHub issue import actions to the main issue list.
category: fix
dev: Updates Import Tasks issue import/close navigation and regression coverage.

View File

@@ -207,7 +207,7 @@ Use Import Tasks on desktop/tablet:
4. Select an issue or pull request row.
Expected outcome: the preview pane shows its title, source link, body excerpt/content, labels or PR metadata, and import availability.
5. Select the import action.
Expected outcome: Fusion creates a task (or review task for a pull request) on the board and preserves GitHub provenance/tracking metadata.
Expected outcome: Fusion creates a task (or review task for a pull request) on the board and preserves GitHub provenance/tracking metadata. After a successful issue import, the issue selection clears and the view returns to the main issue list/no-selection preview so completed issue actions do not leave stale buttons active.
Use GitHub import on mobile:

View File

@@ -842,6 +842,16 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
setMobileView('list');
}, []);
/**
* FNXC:GitHubImport 2026-07-02-00:00:
* Successful GitHub issue import/close actions must return users to the main issue list instead of leaving a completed issue's preview, action buttons, or selected radio active.
* Failures intentionally do not call this helper so the selected preview remains available for retry with the existing error affordance.
*/
const returnToIssueListAfterSuccess = useCallback(() => {
setSelectedIssueNumber(null);
setMobileView("list");
}, []);
const handleImport = useCallback(async () => {
if (activeTab === "issues") {
if (selectedIssueNumber === null) return;
@@ -852,10 +862,7 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
try {
const task = await apiImportGitHubIssue(owner.trim(), repo.trim(), selectedIssueNumber, projectId);
onImport(task);
setSelectedIssueNumber(null);
if (isMobile && mobileView === "preview") {
setMobileView("list");
}
returnToIssueListAfterSuccess();
} catch (err) {
const msg = getErrorMessage(err);
if (msg?.includes("already imported")) {
@@ -890,7 +897,7 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
setImporting(false);
}
}
}, [activeTab, selectedIssueNumber, selectedPullNumber, owner, repo, projectId, onImport, isMobile, mobileView]);
}, [activeTab, selectedIssueNumber, selectedPullNumber, owner, repo, projectId, onImport, isMobile, mobileView, returnToIssueListAfterSuccess]);
/*
FNXC:GitHubImport 2026-06-23-01:00:
@@ -995,13 +1002,14 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
return next;
});
setCloseToast({ type: "success", message: t("git.issueClosedToast", "Issue #{{number}} closed", { number: issueNumber }) });
returnToIssueListAfterSuccess();
} catch (err: unknown) {
setCloseToast({ type: "error", message: getErrorMessage(err) });
} finally {
setClosingIssue(false);
closeToastTimerRef.current = setTimeout(() => setCloseToast(null), 4000);
}
}, [selectedIssueNumber, owner, repo, t]);
}, [selectedIssueNumber, owner, repo, t, returnToIssueListAfterSuccess]);
const selectedIssue = issues.find((i) => i.number === selectedIssueNumber);
const selectedPull = pulls.find((p) => p.number === selectedPullNumber);

View File

@@ -714,9 +714,9 @@ describe("GitHubImportModal", () => {
});
});
it("stays open and resets selection after successful issue import", async () => {
it("stays open and returns desktop issue imports to the no-selection list state", async () => {
const issues = [
{ number: 1, title: "First Issue", body: "Body 1", html_url: "https://github.com/owner/repo/issues/1", labels: [] },
{ number: 1, title: "First Issue", body: null, 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);
@@ -731,7 +731,9 @@ describe("GitHubImportModal", () => {
});
const importButton = screen.getByTestId("github-import-action-top") as HTMLButtonElement;
fireEvent.click(screen.getByRole("radio", { name: /Select issue #1/i }));
const radio = screen.getByRole("radio", { name: /Select issue #1/i }) as HTMLInputElement;
fireEvent.click(radio);
expect(await screen.findByTestId("github-import-preview-card")).toBeTruthy();
expect(importButton.disabled).toBe(false);
fireEvent.click(importButton);
@@ -739,9 +741,9 @@ describe("GitHubImportModal", () => {
await waitFor(() => {
expect(apiImportGitHubIssue).toHaveBeenCalledWith("owner", "repo", 1, "project-1");
expect(onClose).not.toHaveBeenCalled();
});
await waitFor(() => {
expect(radio.checked).toBe(false);
expect(screen.queryByTestId("github-import-preview-card")).toBeNull();
expect(screen.getByTestId("github-import-preview-empty")).toHaveTextContent("No issue selected");
expect((screen.getByTestId("github-import-action-top") as HTMLButtonElement).disabled).toBe(true);
});
@@ -755,6 +757,57 @@ describe("GitHubImportModal", () => {
});
});
it("returns modal bottom issue imports to the list without dismissing the modal", async () => {
const issues = [
{ number: 2, title: "Bottom Action Issue", body: "Body", html_url: "https://github.com/owner/repo/issues/2", labels: [] },
];
vi.mocked(fetchGitRemotes).mockResolvedValueOnce([{ name: "origin", owner: "owner", repo: "repo", url: "" }]);
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce(issues);
vi.mocked(apiImportGitHubIssue).mockResolvedValueOnce({ ...mockTask, id: "FN-002" });
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} projectId="project-1" />);
await waitFor(() => expect(screen.getByText("Bottom Action Issue")).toBeTruthy());
fireEvent.click(screen.getByRole("radio", { name: /Select issue #2/i }));
expect(await screen.findByTestId("github-import-preview-card")).toBeTruthy();
const bottomImportButton = screen.getAllByRole("button", { name: "Import" }).at(-1) as HTMLButtonElement;
fireEvent.click(bottomImportButton);
await waitFor(() => {
expect(apiImportGitHubIssue).toHaveBeenCalledWith("owner", "repo", 2, "project-1");
expect(onClose).not.toHaveBeenCalled();
expect(screen.queryByTestId("github-import-preview-card")).toBeNull();
expect(screen.getByTestId("github-import-preview-empty")).toHaveTextContent("No issue selected");
expect((screen.getByRole("radio", { name: /Select issue #2/i }) as HTMLInputElement).checked).toBe(false);
});
});
it("keeps the selected issue preview open when issue import fails", async () => {
const issues = [
{ number: 3, title: "Retry Issue", body: "Retry body", html_url: "https://github.com/owner/repo/issues/3", labels: [] },
];
vi.mocked(fetchGitRemotes).mockResolvedValueOnce([{ name: "origin", owner: "owner", repo: "repo", url: "" }]);
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce(issues);
vi.mocked(apiImportGitHubIssue).mockRejectedValueOnce(new Error("already imported elsewhere"));
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} projectId="project-1" />);
await waitFor(() => expect(screen.getByText("Retry Issue")).toBeTruthy());
const radio = screen.getByRole("radio", { name: /Select issue #3/i }) as HTMLInputElement;
fireEvent.click(radio);
expect(await screen.findByTestId("github-import-preview-card")).toHaveTextContent("Retry Issue");
fireEvent.click(screen.getByTestId("github-import-action-top"));
await waitFor(() => {
expect(screen.getByText("already imported elsewhere")).toBeTruthy();
expect(radio.checked).toBe(true);
expect(screen.getByTestId("github-import-preview-card")).toHaveTextContent("Retry Issue");
expect(screen.queryByTestId("github-import-preview-empty")).toBeNull();
});
});
it("shows 'Imported' badge for already imported issues", async () => {
const existingTask: Task = {
...mockTask,
@@ -967,6 +1020,108 @@ describe("GitHubImportModal", () => {
expect(previewPane.classList.contains("active")).toBe(false);
});
it("returns mobile issue imports from preview to the active list pane", async () => {
Object.defineProperty(window, "innerWidth", {
writable: true,
configurable: true,
value: 480,
});
window.dispatchEvent(new Event("resize"));
const issues = [
{ number: 1, title: "Mobile Import Issue", body: "Body", html_url: "https://github.com/owner/repo/issues/1", labels: [] },
];
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce(issues);
vi.mocked(apiImportGitHubIssue).mockResolvedValueOnce(mockTask);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
await waitFor(() => expect(screen.getByText("Mobile Import Issue")).toBeTruthy());
fireEvent.click(screen.getByRole("radio", { name: /Select issue #1/i }));
const previewPane = screen.getByTestId("github-import-preview-pane");
const listPane = screen.getByTestId("github-import-list-pane");
await waitFor(() => expect(previewPane.classList.contains("active")).toBe(true));
expect(listPane.classList.contains("active")).toBe(false);
fireEvent.click(screen.getByTestId("github-import-action-top"));
await waitFor(() => {
expect(apiImportGitHubIssue).toHaveBeenCalledWith("dustinbyrne", "kb", 1, undefined);
expect(previewPane.classList.contains("active")).toBe(false);
expect(listPane.classList.contains("active")).toBe(true);
expect(screen.queryByTestId("github-import-preview-card")).toBeNull();
expect(screen.getByTestId("github-import-preview-empty")).toHaveTextContent("No issue selected");
});
});
it("returns mobile issue close from preview to the active list pane", async () => {
Object.defineProperty(window, "innerWidth", {
writable: true,
configurable: true,
value: 480,
});
window.dispatchEvent(new Event("resize"));
const issues = [
{ number: 4, title: "Mobile Close Issue", body: "Body", html_url: "https://github.com/owner/repo/issues/4", labels: [], state: "open" as const },
];
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce(issues);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
await waitFor(() => expect(screen.getByText("Mobile Close Issue")).toBeTruthy());
fireEvent.click(screen.getByRole("radio", { name: /Select issue #4/i }));
const previewPane = screen.getByTestId("github-import-preview-pane");
const listPane = screen.getByTestId("github-import-list-pane");
await waitFor(() => expect(previewPane.classList.contains("active")).toBe(true));
fireEvent.click(await screen.findByTestId("github-import-issue-close"));
await waitFor(() => {
expect(apiCloseGitHubIssue).toHaveBeenCalledWith("dustinbyrne/kb", 4);
expect(previewPane.classList.contains("active")).toBe(false);
expect(listPane.classList.contains("active")).toBe(true);
expect(screen.queryByTestId("github-import-preview-card")).toBeNull();
expect(screen.getByTestId("github-import-preview-empty")).toHaveTextContent("No issue selected");
});
expect(await screen.findByTestId("github-import-issue-close-toast")).toHaveTextContent("Issue #4 closed");
});
it("keeps the mobile issue preview active when import fails", async () => {
Object.defineProperty(window, "innerWidth", {
writable: true,
configurable: true,
value: 480,
});
window.dispatchEvent(new Event("resize"));
const issues = [
{ number: 6, title: "Mobile Retry Issue", body: "Body", html_url: "https://github.com/owner/repo/issues/6", labels: [] },
];
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce(issues);
vi.mocked(apiImportGitHubIssue).mockRejectedValueOnce(new Error("Duplicate server error"));
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
await waitFor(() => expect(screen.getByText("Mobile Retry Issue")).toBeTruthy());
fireEvent.click(screen.getByRole("radio", { name: /Select issue #6/i }));
const previewPane = screen.getByTestId("github-import-preview-pane");
await waitFor(() => expect(previewPane.classList.contains("active")).toBe(true));
fireEvent.click(screen.getByTestId("github-import-action-top"));
await waitFor(() => {
expect(screen.getByText("Duplicate server error")).toBeTruthy();
expect(previewPane.classList.contains("active")).toBe(true);
expect(screen.getByTestId("github-import-preview-card")).toHaveTextContent("Mobile Retry Issue");
});
});
it("renders long selected issue body in full on mobile without a truncation ellipsis", async () => {
Object.defineProperty(window, "innerWidth", {
writable: true,
@@ -1263,8 +1418,8 @@ describe("GitHubImportModal", () => {
});
});
// FNXC:GitHubImport 2026-06-23-03:15: The Close issue button calls the close API and reflects the closed state locally without dismissing the preview.
it("closes the selected issue via the close API and reflects the closed state", async () => {
// FNXC:GitHubImport 2026-07-02-00:00: Successful Close issue returns to the issue list/no-selection state; failure stays on the preview so the user can retry.
it("closes the selected issue via the close API and returns desktop to the no-selection list state", async () => {
Object.defineProperty(window, "innerWidth", { writable: true, configurable: true, value: 1200 });
const issues = [
@@ -1289,20 +1444,47 @@ describe("GitHubImportModal", () => {
expect(vi.mocked(apiCloseGitHubIssue)).toHaveBeenCalledWith("dustinbyrne/kb", 5);
});
// Success toast surfaces without dismissing the preview.
expect(await screen.findByTestId("github-import-issue-close-toast")).toBeTruthy();
// Success toast surfaces without dismissing the modal, while the completed issue preview is cleared.
expect(await screen.findByTestId("github-import-issue-close-toast")).toHaveTextContent("Issue #5 closed");
// Closed state reflects locally: badge flips to "closed" and the Close button is gone (only OPEN issues show it).
await waitFor(() => {
const previewCard = screen.getByTestId("github-import-preview-card");
expect(within(previewCard).getByText("closed")).toBeTruthy();
expect((screen.getByRole("radio", { name: /Select issue #5/i }) as HTMLInputElement).checked).toBe(false);
expect(screen.queryByTestId("github-import-preview-card")).toBeNull();
expect(screen.getByTestId("github-import-preview-empty")).toHaveTextContent("No issue selected");
expect(screen.queryByTestId("github-import-issue-close")).toBeNull();
});
// Preview is NOT dismissed.
expect(onClose).not.toHaveBeenCalled();
});
it("keeps the selected issue preview open when close fails", async () => {
Object.defineProperty(window, "innerWidth", { writable: true, configurable: true, value: 1200 });
const issues = [
{ number: 8, title: "Close Retry Issue", body: "Retry close body", html_url: "https://github.com/owner/repo/issues/8", labels: [], state: "open" as const, author: "dave" },
];
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce(issues);
vi.mocked(apiCloseGitHubIssue).mockRejectedValueOnce(new Error("close failed"));
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
await waitFor(() => expect(screen.getByText("Close Retry Issue")).toBeTruthy());
const radio = screen.getByRole("radio", { name: /Select issue #8/i }) as HTMLInputElement;
fireEvent.click(radio);
expect(await screen.findByTestId("github-import-preview-card")).toHaveTextContent("Close Retry Issue");
fireEvent.click(await screen.findByTestId("github-import-issue-close"));
await waitFor(() => {
expect(apiCloseGitHubIssue).toHaveBeenCalledWith("dustinbyrne/kb", 8);
expect(screen.getByTestId("github-import-issue-close-toast")).toHaveTextContent("close failed");
expect(radio.checked).toBe(true);
expect(screen.getByTestId("github-import-preview-card")).toHaveTextContent("Close Retry Issue");
expect(screen.getByTestId("github-import-issue-close")).toBeTruthy();
});
});
// FNXC:GitHubImport 2026-06-22-18:30: Desktop preview must show the FULL issue/PR body (no 200-char clamp). The list response already carries the complete body, so no detail fetch is needed.
it("renders long selected issue body in full on desktop without a truncation ellipsis", async () => {
Object.defineProperty(window, "innerWidth", {