diff --git a/.changeset/fn-7991-import-screen-optimistic-imported.md b/.changeset/fn-7991-import-screen-optimistic-imported.md new file mode 100644 index 0000000000..2ffe4e155b --- /dev/null +++ b/.changeset/fn-7991-import-screen-optimistic-imported.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: The GitHub/GitLab Import Tasks screen now marks an issue, PR, or item as "Imported" immediately after importing it. +category: fix +dev: GitHubImportModal unions a local optimistic imported-URL set (populated in handleImport/handleImportGitLab success handlers, cleared on modal reset and on provider/owner/repo/GitLab-resource change) with the tasks-derived importedUrls at every consumer (rows, count labels, top/bottom/GitLab Import buttons), so a just-imported row shows the badge and disables re-import without waiting for the tasks prop round-trip. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index e39c02a19f..25ee65387a 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -302,7 +302,7 @@ Use Import Tasks on desktop/tablet: 2. Choose or enter a repository (`owner/repo`). If Git remotes are detected, use the remote selector. Expected outcome: Fusion loads import candidates for the selected repository and shows repository/load state feedback. 3. Stay on **Issues** or switch to **Pull Requests**, then optionally enter issue label filters before loading results. - Expected outcome: the list pane shows matching open issues or pull requests and marks entries that already exist on the board. + Expected outcome: the list pane shows matching open issues or pull requests and marks entries that already exist on the board. After a successful GitHub or GitLab import, the source row is marked **Imported** and made unavailable immediately, without waiting for the board list to refresh. 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. When the selected title/body appear to be in a language other than the current dashboard language, the preview offers **Translate** (into the dashboard language), **Show original** / **Show translation** after a successful translation, and **Dismiss**. Translation is display-only in the preview; imported task text stays the original source language. 5. Select the import action. diff --git a/packages/dashboard/app/components/GitHubImportModal.tsx b/packages/dashboard/app/components/GitHubImportModal.tsx index a5366836b6..e55d7fe8f1 100644 --- a/packages/dashboard/app/components/GitHubImportModal.tsx +++ b/packages/dashboard/app/components/GitHubImportModal.tsx @@ -485,6 +485,14 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId, // state, so the FIRST commit's still-default state is never written over a real persisted value. const [readyToPersistImportState, setReadyToPersistImportState] = useState(false); + /* + FNXC:GitHubImport 2026-07-15-15:30: + A just-imported source must immediately display as imported without waiting for the parent `tasks` prop round-trip. + Keep local optimistic URLs unioned with, never replacing, the tasks-derived URLs; reset and import-source effects clear + the local set when its source context is no longer valid. + */ + const [optimisticImportedUrls, setOptimisticImportedUrls] = useState>(new Set()); + // Build set of already imported URLs from existing tasks const importedUrls = new Set(); for (const task of tasks) { @@ -504,6 +512,11 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId, } } + const isUrlImported = useCallback((url?: string | null) => { + if (!url) return false; + return importedUrls.has(url) || optimisticImportedUrls.has(url); + }, [importedUrls, optimisticImportedUrls]); + /* FNXC:GitHubImport 2026-07-07-00:00: Retain-state-on-exit-and-return (FN-7657): the embedded Import Tasks view fully unmounts on navigation away and remounts @@ -541,6 +554,7 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId, setGitlabGroup(persisted?.gitlabGroup ?? ""); setGitlabItems([]); setSelectedGitlabKey(null); + setOptimisticImportedUrls(new Set()); setIssues([]); setSelectedIssueNumber(null); setPulls([]); @@ -638,6 +652,16 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId, } }, [isOpen, projectId]); + /* + FNXC:GitHubImport 2026-07-15-15:30: + Optimistic URLs are meaningful only for their current import source. Changing provider, GitHub owner/repo, or a GitLab + project/group/resource re-scopes the list, so discard them; omit activeTab so same-source Issues/Pull Requests switches + retain an optimistic mark. + */ + useEffect(() => { + setOptimisticImportedUrls(new Set()); + }, [provider, owner, repo, gitlabProject, gitlabGroup, gitlabResource]); + // Handle remote selection change const handleRemoteChange = useCallback((remoteName: string) => { setSelectedRemoteName(remoteName); @@ -809,6 +833,9 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId, ? await apiImportGitLabGroupIssue(selectedGitlabItem, gitlabGroup.trim(), projectId) : await apiImportGitLabMergeRequest(gitlabProject.trim(), selectedGitlabItem.iid, projectId); onImport(task); + if (selectedGitlabItem.webUrl) { + setOptimisticImportedUrls((previous) => new Set(previous).add(selectedGitlabItem.webUrl)); + } setSelectedGitlabKey(null); if (isMobile && mobileView === "preview") setMobileView("list"); } catch (err) { @@ -1098,6 +1125,7 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId, const handleImport = useCallback(async () => { if (activeTab === "issues") { if (selectedIssueNumber === null) return; + const importedIssueUrl = issues.find((issue) => issue.number === selectedIssueNumber)?.html_url; setImporting(true); setError(null); @@ -1115,6 +1143,9 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId, translateTargetLocale, ); onImport(task); + if (importedIssueUrl) { + setOptimisticImportedUrls((previous) => new Set(previous).add(importedIssueUrl)); + } returnToIssueListAfterSuccess(); } catch (err) { const msg = getErrorMessage(err); @@ -1128,6 +1159,7 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId, } } else { if (selectedPullNumber === null) return; + const importedPullUrl = pulls.find((pull) => pull.number === selectedPullNumber)?.html_url; setImporting(true); setError(null); @@ -1135,6 +1167,9 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId, try { const task = await apiImportGitHubPull(owner.trim(), repo.trim(), selectedPullNumber, projectId); onImport(task); + if (importedPullUrl) { + setOptimisticImportedUrls((previous) => new Set(previous).add(importedPullUrl)); + } setSelectedPullNumber(null); if (isMobile && mobileView === "preview") { setMobileView("list"); @@ -1150,7 +1185,7 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId, setImporting(false); } } - }, [activeTab, selectedIssueNumber, selectedPullNumber, owner, repo, projectId, onImport, isMobile, mobileView, returnToIssueListAfterSuccess]); + }, [activeTab, selectedIssueNumber, selectedPullNumber, issues, pulls, owner, repo, projectId, onImport, isMobile, mobileView, returnToIssueListAfterSuccess]); /* FNXC:GitHubImport 2026-06-23-01:00: @@ -1338,8 +1373,8 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId, const singleRemote = remotes.length === 1; // Tab-specific counts - const importedIssueCount = issues.filter((issue) => importedUrls.has(issue.html_url)).length; - const importedPullCount = pulls.filter((pull) => importedUrls.has(pull.html_url)).length; + const importedIssueCount = issues.filter((issue) => isUrlImported(issue.html_url)).length; + const importedPullCount = pulls.filter((pull) => isUrlImported(pull.html_url)).length; // Empty states const isIssuesEmpty = isIssuesEmptyState; @@ -1605,7 +1640,7 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId, {activeTab === "issues" && issues.length > 0 && (
{issues.map((issue) => { - const isImported = importedUrls.has(issue.html_url); + const isImported = isUrlImported(issue.html_url); return (
0 && (
{pulls.map((pull) => { - const isImported = importedUrls.has(pull.html_url); + const isImported = isUrlImported(pull.html_url); return (
{importing ? : t("git.import", "Import")} @@ -1977,9 +2012,9 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
{gitlabItems.map((item) => { const key = `${item.resourceKind}:${item.projectId ?? item.projectPath ?? ""}:${item.iid}`; - const imported = importedUrls.has(item.webUrl); + const imported = isUrlImported(item.webUrl); return ( - @@ -1994,7 +2029,7 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId, {/* FNXC:GitHubImportTranslate 2026-07-14-12:00: GitLab import preview reuses the same language-detect + translate controls as GitHub. */} {importTranslation.controls} - +
) :
{t("git.gitlabNoSelection", "No GitLab resource selected")}{t("git.gitlabNoSelectionHint", "Choose a resource from the list to preview it.")}
}
@@ -2018,8 +2053,8 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId, onClick={provider === "gitlab" ? handleImportGitLab : handleImport} disabled={ provider === "gitlab" - ? !gitlabEnabled || selectedGitlabItem === null || importing || (selectedGitlabItem ? importedUrls.has(selectedGitlabItem.webUrl) : false) - : (activeTab === "issues" ? selectedIssueNumber === null : selectedPullNumber === null) || importing + ? !gitlabEnabled || selectedGitlabItem === null || importing || (selectedGitlabItem ? isUrlImported(selectedGitlabItem.webUrl) : false) + : (activeTab === "issues" ? selectedIssueNumber === null || isUrlImported(selectedIssue?.html_url) : selectedPullNumber === null || isUrlImported(selectedPull?.html_url)) || importing } > {importing ? : t("git.import", "Import")} diff --git a/packages/dashboard/app/components/__tests__/GitHubImportModal.test.tsx b/packages/dashboard/app/components/__tests__/GitHubImportModal.test.tsx index 32cc22a327..e324be964f 100644 --- a/packages/dashboard/app/components/__tests__/GitHubImportModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/GitHubImportModal.test.tsx @@ -266,11 +266,14 @@ describe("GitHubImportModal", () => { await waitFor(() => expect(apiImportGitLabProjectIssue).toHaveBeenCalledWith("group/project", 2, undefined)); expect(onImport).toHaveBeenCalledWith(expect.objectContaining({ id: "FN-099" })); + const row = screen.getByText(/#2 GitLab bug/).closest("button") as HTMLButtonElement; + expect(row).toHaveClass("imported"); + expect(row.disabled).toBe(true); }); it("hides the GitLab import provider and keeps GitHub active when GitLab is off", async () => { vi.mocked(fetchGitRemotes).mockResolvedValueOnce([]); - vi.mocked(fetchSettings).mockResolvedValueOnce({ gitlabEnabled: false } as never); + vi.mocked(fetchSettings).mockResolvedValue({ gitlabEnabled: false } as never); render(); @@ -304,7 +307,7 @@ describe("GitHubImportModal", () => { it("coerces a persisted GitLab provider to GitHub without auto-loading when GitLab is off", async () => { vi.mocked(fetchGitRemotes).mockResolvedValueOnce([]); - vi.mocked(fetchSettings).mockResolvedValueOnce({ gitlabEnabled: false } as never); + vi.mocked(fetchSettings).mockResolvedValue({ gitlabEnabled: false } as never); window.localStorage.setItem(`kb:project-1:${GITHUB_IMPORT_STATE_KEY}`, JSON.stringify({ provider: "gitlab", activeTab: "issues", @@ -898,13 +901,44 @@ describe("GitHubImportModal", () => { fireEvent.click(screen.getByTestId("github-import-action-top")); await waitFor(() => { - expect(apiImportGitHubIssue).toHaveBeenCalledWith("dustinbyrne", "kb", 1, "project-1"); + expect(apiImportGitHubIssue).toHaveBeenCalledWith("dustinbyrne", "kb", 1, "project-1", "en"); expect(onImport).toHaveBeenCalledWith(mockTask); expect(onClose).not.toHaveBeenCalled(); expect(screen.getByText("Import from GitHub")).toBeTruthy(); + const row = screen.getByText("First Issue").closest(".issue-item") as HTMLElement; + expect(row).toHaveClass("imported"); + expect(within(row).getByText("Imported")).toBeTruthy(); + expect(screen.getByRole("radio", { name: /Select issue #1/i })).toBeDisabled(); + expect(screen.getByText("1 imported")).toBeTruthy(); }); }); + it("preserves optimistic imports across GitHub tabs and clears them after a provider switch", async () => { + const issues = [ + { number: 1, title: "Context Issue", body: "Body", 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).mockResolvedValueOnce(issues); + vi.mocked(apiFetchGitHubPulls).mockResolvedValueOnce(mockPulls); + vi.mocked(apiImportGitHubIssue).mockResolvedValueOnce(mockTask); + + render(); + await screen.findByText("Context Issue"); + fireEvent.click(screen.getByRole("radio", { name: /Select issue #1/i })); + fireEvent.click(screen.getByTestId("github-import-action-top")); + await waitFor(() => expect(screen.getByText("Context Issue").closest(".issue-item")).toHaveClass("imported")); + + fireEvent.click(screen.getByRole("tab", { name: /Pull Requests/i })); + await screen.findByText("Test PR"); + fireEvent.click(screen.getByRole("tab", { name: /Issues/i })); + await screen.findByText("Context Issue"); + expect(screen.getByText("Context Issue").closest(".issue-item")).toHaveClass("imported"); + + fireEvent.click(await screen.findByRole("button", { name: "GitLab" })); + fireEvent.click(screen.getByRole("button", { name: "GitHub" })); + await waitFor(() => expect(screen.getByText("Context Issue").closest(".issue-item")).not.toHaveClass("imported")); + }); + it("stays open and returns desktop issue imports to the no-selection list state", async () => { const issues = [ { number: 1, title: "First Issue", body: null, html_url: "https://github.com/owner/repo/issues/1", labels: [] }, @@ -930,7 +964,7 @@ describe("GitHubImportModal", () => { fireEvent.click(importButton); await waitFor(() => { - expect(apiImportGitHubIssue).toHaveBeenCalledWith("owner", "repo", 1, "project-1"); + expect(apiImportGitHubIssue).toHaveBeenCalledWith("owner", "repo", 1, "project-1", "en"); expect(onClose).not.toHaveBeenCalled(); expect(radio.checked).toBe(false); expect(screen.queryByTestId("github-import-preview-card")).toBeNull(); @@ -966,7 +1000,7 @@ describe("GitHubImportModal", () => { fireEvent.click(bottomImportButton); await waitFor(() => { - expect(apiImportGitHubIssue).toHaveBeenCalledWith("owner", "repo", 2, "project-1"); + expect(apiImportGitHubIssue).toHaveBeenCalledWith("owner", "repo", 2, "project-1", "en"); expect(onClose).not.toHaveBeenCalled(); expect(screen.queryByTestId("github-import-preview-card")).toBeNull(); expect(screen.getByTestId("github-import-preview-empty")).toHaveTextContent("No issue selected"); @@ -1239,11 +1273,14 @@ describe("GitHubImportModal", () => { fireEvent.click(screen.getByTestId("github-import-action-top")); await waitFor(() => { - expect(apiImportGitHubIssue).toHaveBeenCalledWith("dustinbyrne", "kb", 1, undefined); + expect(apiImportGitHubIssue).toHaveBeenCalledWith("dustinbyrne", "kb", 1, undefined, "en"); 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"); + const row = screen.getByText("Mobile Import Issue").closest(".issue-item") as HTMLElement; + expect(row).toHaveClass("imported"); + expect(screen.getByRole("radio", { name: /Select issue #1/i })).toBeDisabled(); }); }); @@ -1812,7 +1849,7 @@ describe("GitHubImportModal", () => { fireEvent.click(screen.getByTestId("github-import-action-top")); await waitFor(() => { - expect(apiImportGitHubIssue).toHaveBeenCalledWith("owner", "repo", 1, "project-1"); + expect(apiImportGitHubIssue).toHaveBeenCalledWith("owner", "repo", 1, "project-1", "en"); }); expect(previewPane.classList.contains("active")).toBe(false); @@ -2246,6 +2283,11 @@ describe("GitHubImportModal", () => { expect(onImport).toHaveBeenCalledWith(mockPRTask); expect(onClose).not.toHaveBeenCalled(); expect(screen.getByText("Import from GitHub")).toBeTruthy(); + const row = screen.getByText("Test PR").closest(".issue-item") as HTMLElement; + expect(row).toHaveClass("imported"); + expect(within(row).getByText("Imported")).toBeTruthy(); + expect(screen.getByRole("radio", { name: /Select pull request #1/i })).toBeDisabled(); + expect(screen.getByText("1 imported")).toBeTruthy(); }); });