FN-7991: mark import-screen items as imported immediately

Mark successful GitHub/GitLab import rows as Imported right away via optimistic local URL state, without waiting for the parent tasks prop round-trip.

- Add optimisticImportedUrls unioned with tasks-derived importedUrls via isUrlImported
- Populate on successful GitHub issue/PR and GitLab imports; clear on modal reset and source change
- Disable re-import and show Imported badge on rows, counts, and import buttons for optimistic URLs
- Cover optimistic import surfaces in GitHubImportModal tests
- Document the behavior in the dashboard guide and add a patch changeset

Files changed:
 .changeset/fn-7991-import-screen-optimistic-imported.md   |  7 +++
 docs/dashboard-guide.md                                    |  2 +-
 packages/dashboard/app/components/GitHubImportModal.tsx    | 57 +++++++++++++++++-----
 packages/dashboard/app/components/__tests__/GitHubImportModal.test.tsx | 56 ++++++++++++++++++---
 4 files changed, 103 insertions(+), 19 deletions(-)

Fusion-Task-Id: FN-7991

Fusion-Task-Lineage: ddfb249a-e2e8-4723-a86d-7f6edc74305c

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-15 15:51:54 -07:00
parent 80f202831d
commit e83116a970
4 changed files with 103 additions and 19 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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<Set<string>>(new Set());
// Build set of already imported URLs from existing tasks
const importedUrls = new Set<string>();
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 && (
<div className="issues-list" aria-live="polite">
{issues.map((issue) => {
const isImported = importedUrls.has(issue.html_url);
const isImported = isUrlImported(issue.html_url);
return (
<div
key={issue.number}
@@ -1656,7 +1691,7 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
{activeTab === "pulls" && pulls.length > 0 && (
<div className="issues-list" aria-live="polite">
{pulls.map((pull) => {
const isImported = importedUrls.has(pull.html_url);
const isImported = isUrlImported(pull.html_url);
return (
<div
key={pull.number}
@@ -1751,7 +1786,7 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
data-testid="github-import-action-top"
onClick={handleImport}
disabled={
(activeTab === "issues" ? selectedIssueNumber === null : selectedPullNumber === null) || importing
(activeTab === "issues" ? selectedIssueNumber === null || isUrlImported(selectedIssue?.html_url) : selectedPullNumber === null || isUrlImported(selectedPull?.html_url)) || importing
}
>
{importing ? <Loader2 size={14} className="spin" /> : t("git.import", "Import")}
@@ -1977,9 +2012,9 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
<div className="issues-list" aria-live="polite">
{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 (
<button key={key} type="button" className={`issue-item ${selectedGitlabKey === key ? "selected" : ""} ${imported ? "imported" : ""}`} onClick={() => { setSelectedGitlabKey(key); if (isMobile) setMobileView("preview"); }}>
<button key={key} type="button" className={`issue-item ${selectedGitlabKey === key ? "selected" : ""} ${imported ? "imported" : ""}`} onClick={() => { if (!imported) { setSelectedGitlabKey(key); if (isMobile) setMobileView("preview"); } }} disabled={imported}>
<div className="issue-title">{item.resourceKind === "merge_request" ? "!" : "#"}{item.iid} {item.title}</div>
<div className="issue-meta"><span>{item.projectPath ?? item.projectId}</span><span>{item.state}</span>{imported && <span>{t("git.alreadyImported", "Imported")}</span>}</div>
</button>
@@ -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}
<MailboxMessageContent className="preview-body preview-body--markdown" content={importTranslation.display.body?.trim() || t("git.noDescription", "(no description)")} testId="gitlab-import-preview-body" />
<button type="button" className="btn btn-primary" onClick={handleImportGitLab} disabled={!gitlabEnabled || importing || importedUrls.has(selectedGitlabItem.webUrl)}>{importing ? <Loader2 size={14} className="spin" /> : t("git.import", "Import")}</button>
<button type="button" className="btn btn-primary" onClick={handleImportGitLab} disabled={!gitlabEnabled || importing || isUrlImported(selectedGitlabItem.webUrl)}>{importing ? <Loader2 size={14} className="spin" /> : t("git.import", "Import")}</button>
</div>
) : <div className="github-import-state github-import-state--idle" data-testid="gitlab-import-preview-empty"><strong>{t("git.gitlabNoSelection", "No GitLab resource selected")}</strong><span>{t("git.gitlabNoSelectionHint", "Choose a resource from the list to preview it.")}</span></div>}
</div>
@@ -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 ? <Loader2 size={14} className="spin" /> : t("git.import", "Import")}

View File

@@ -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(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
@@ -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(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
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();
});
});