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. 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. 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. 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: Use GitHub import on mobile:

View File

@@ -842,6 +842,16 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
setMobileView('list'); 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 () => { const handleImport = useCallback(async () => {
if (activeTab === "issues") { if (activeTab === "issues") {
if (selectedIssueNumber === null) return; if (selectedIssueNumber === null) return;
@@ -852,10 +862,7 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
try { try {
const task = await apiImportGitHubIssue(owner.trim(), repo.trim(), selectedIssueNumber, projectId); const task = await apiImportGitHubIssue(owner.trim(), repo.trim(), selectedIssueNumber, projectId);
onImport(task); onImport(task);
setSelectedIssueNumber(null); returnToIssueListAfterSuccess();
if (isMobile && mobileView === "preview") {
setMobileView("list");
}
} catch (err) { } catch (err) {
const msg = getErrorMessage(err); const msg = getErrorMessage(err);
if (msg?.includes("already imported")) { if (msg?.includes("already imported")) {
@@ -890,7 +897,7 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
setImporting(false); 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: FNXC:GitHubImport 2026-06-23-01:00:
@@ -995,13 +1002,14 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
return next; return next;
}); });
setCloseToast({ type: "success", message: t("git.issueClosedToast", "Issue #{{number}} closed", { number: issueNumber }) }); setCloseToast({ type: "success", message: t("git.issueClosedToast", "Issue #{{number}} closed", { number: issueNumber }) });
returnToIssueListAfterSuccess();
} catch (err: unknown) { } catch (err: unknown) {
setCloseToast({ type: "error", message: getErrorMessage(err) }); setCloseToast({ type: "error", message: getErrorMessage(err) });
} finally { } finally {
setClosingIssue(false); setClosingIssue(false);
closeToastTimerRef.current = setTimeout(() => setCloseToast(null), 4000); closeToastTimerRef.current = setTimeout(() => setCloseToast(null), 4000);
} }
}, [selectedIssueNumber, owner, repo, t]); }, [selectedIssueNumber, owner, repo, t, returnToIssueListAfterSuccess]);
const selectedIssue = issues.find((i) => i.number === selectedIssueNumber); const selectedIssue = issues.find((i) => i.number === selectedIssueNumber);
const selectedPull = pulls.find((p) => p.number === selectedPullNumber); 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 = [ 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(fetchGitRemotes).mockResolvedValueOnce([{ name: "origin", owner: "owner", repo: "repo", url: "" }]);
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce(issues); vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce(issues);
@@ -731,7 +731,9 @@ describe("GitHubImportModal", () => {
}); });
const importButton = screen.getByTestId("github-import-action-top") as HTMLButtonElement; 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); expect(importButton.disabled).toBe(false);
fireEvent.click(importButton); fireEvent.click(importButton);
@@ -739,9 +741,9 @@ describe("GitHubImportModal", () => {
await waitFor(() => { await waitFor(() => {
expect(apiImportGitHubIssue).toHaveBeenCalledWith("owner", "repo", 1, "project-1"); expect(apiImportGitHubIssue).toHaveBeenCalledWith("owner", "repo", 1, "project-1");
expect(onClose).not.toHaveBeenCalled(); expect(onClose).not.toHaveBeenCalled();
}); expect(radio.checked).toBe(false);
expect(screen.queryByTestId("github-import-preview-card")).toBeNull();
await waitFor(() => { expect(screen.getByTestId("github-import-preview-empty")).toHaveTextContent("No issue selected");
expect((screen.getByTestId("github-import-action-top") as HTMLButtonElement).disabled).toBe(true); 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 () => { it("shows 'Imported' badge for already imported issues", async () => {
const existingTask: Task = { const existingTask: Task = {
...mockTask, ...mockTask,
@@ -967,6 +1020,108 @@ describe("GitHubImportModal", () => {
expect(previewPane.classList.contains("active")).toBe(false); 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 () => { it("renders long selected issue body in full on mobile without a truncation ellipsis", async () => {
Object.defineProperty(window, "innerWidth", { Object.defineProperty(window, "innerWidth", {
writable: true, 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. // 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 reflects the closed state", async () => { 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 }); Object.defineProperty(window, "innerWidth", { writable: true, configurable: true, value: 1200 });
const issues = [ const issues = [
@@ -1289,20 +1444,47 @@ describe("GitHubImportModal", () => {
expect(vi.mocked(apiCloseGitHubIssue)).toHaveBeenCalledWith("dustinbyrne/kb", 5); expect(vi.mocked(apiCloseGitHubIssue)).toHaveBeenCalledWith("dustinbyrne/kb", 5);
}); });
// Success toast surfaces without dismissing the preview. // Success toast surfaces without dismissing the modal, while the completed issue preview is cleared.
expect(await screen.findByTestId("github-import-issue-close-toast")).toBeTruthy(); 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(() => { await waitFor(() => {
const previewCard = screen.getByTestId("github-import-preview-card"); expect((screen.getByRole("radio", { name: /Select issue #5/i }) as HTMLInputElement).checked).toBe(false);
expect(within(previewCard).getByText("closed")).toBeTruthy(); 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(); expect(screen.queryByTestId("github-import-issue-close")).toBeNull();
}); });
// Preview is NOT dismissed.
expect(onClose).not.toHaveBeenCalled(); 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. // 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 () => { it("renders long selected issue body in full on desktop without a truncation ellipsis", async () => {
Object.defineProperty(window, "innerWidth", { Object.defineProperty(window, "innerWidth", {