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:
7
.changeset/fn-7442-github-import-return-list.md
Normal file
7
.changeset/fn-7442-github-import-return-list.md
Normal 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.
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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", {
|
||||
|
||||
Reference in New Issue
Block a user