FN-8017: add imported-item filter to task import

Add a persisted control that declutters Import Tasks by hiding items already on the board.

- Filter imported GitHub issues, pull requests, and GitLab resources while retaining full imported counts.
- Clear hidden selections, show an all-imported empty state, and preserve the preference per project.
- Document the toggle, add styling, coverage, and a minor changeset.

Files changed:
 .changeset/fn-8017-hide-imported-toggle.md         |   7 ++
 docs/dashboard-guide.md                            |   4 +-
 .../dashboard/app/components/GitHubImportModal.css |  21 ++++
 .../dashboard/app/components/GitHubImportModal.tsx |  70 ++++++++++++-
 .../__tests__/GitHubImportModal.test.tsx           | 108 +++++++++++++++++++++
 packages/dashboard/app/hooks/modalPersistence.ts   |   6 ++
 6 files changed, 209 insertions(+), 7 deletions(-)

Fusion-Task-Id: FN-8017

Fusion-Task-Lineage: 5f03fe92-6dce-4de7-a745-0c8a46dc2dbb

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-15 19:27:00 -07:00
parent 5523c19fb4
commit adcba0eed2
6 changed files with 209 additions and 7 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Add a Hide imported toggle that filters imported issues, PRs, and GitLab items from Import Tasks.
category: feature
dev: GitHubImportModal renders a persisted per-project hideImported toggle in the list-pane header (and the GitLab toolbar/header); when on, imported rows (importedUrls predicate) are excluded from the issues/pulls/GitLab render sets while the "{n} imported" count still reflects the full fetched set, with a dedicated all-imported empty state. Toggle persists via GitHubImportPersistedState.hideImported.

View File

@@ -302,13 +302,13 @@ 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. 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.
Expected outcome: the list pane shows matching open issues or pull requests and marks entries that already exist on the board. Use **Hide imported** beside the imported count to remove those unavailable rows from the current Issues, Pull Requests, or GitLab list; turning it off restores the greyed **Imported** rows. 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 full-width candidate list stays visible while its title, source link, body, labels or PR metadata, and import controls open in a draggable and resizable detail window. On mobile, that detail is a full-screen sheet. When selected title/body content is in another language, the detail offers **Translate**, **Show original** / **Show translation**, and **Dismiss**; translation is display-only.
5. Select the import action in the detail window.
Expected outcome: Fusion creates a task (or review task for a pull request), preserves GitHub provenance/tracking metadata, closes the detail window, and returns to the list.
Leaving and returning to **Import Tasks** (for example switching to Board and back) restores the prior context for the current project — provider (GitHub/GitLab), active Issues/PRs tab, label filter, selected repository/remote, GitLab project/group inputs, and the previously selected issue/PR — instead of resetting to defaults. When GitLab integration is disabled in Settings, the GitLab provider tab is hidden and any restored GitLab provider preference opens on GitHub instead; saved GitLab URLs and tokens remain configured. The restored selection re-validates against the freshly reloaded list; a selection that no longer exists (e.g. the issue was closed upstream) clears gracefully rather than showing a stuck or empty preview. First-time opens with no prior state keep the existing default-remote auto-detect behavior. State is scoped per project and does not leak across projects.
Leaving and returning to **Import Tasks** (for example switching to Board and back) restores the prior context for the current project — provider (GitHub/GitLab), active Issues/PRs tab, label filter, selected repository/remote, GitLab project/group inputs, the **Hide imported** preference, and the previously selected issue/PR — instead of resetting to defaults. When GitLab integration is disabled in Settings, the GitLab provider tab is hidden and any restored GitLab provider preference opens on GitHub instead; saved GitLab URLs and tokens remain configured. The restored selection re-validates against the freshly reloaded list; a selection that no longer exists (e.g. the issue was closed upstream) clears gracefully rather than showing a stuck or empty preview. First-time opens with no prior state keep the existing default-remote auto-detect behavior. State is scoped per project and does not leak across projects.
Use GitHub import on mobile:

View File

@@ -565,6 +565,26 @@ The Import Tasks sub-header tab bar should match the Artifacts view's button bar
background: var(--surface);
}
/*
FNXC:GitHubImport 2026-07-15-16:30:
Hide imported must remain a compact, reachable control beside loaded-result metadata and within the GitLab toolbar, including
when either layout wraps on mobile. Token-based spacing keeps it consistent with the surrounding import controls.
*/
.github-import-hide-imported {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
color: var(--text-muted);
font-size: 12px;
white-space: nowrap;
cursor: pointer;
}
.github-import-hide-imported input {
margin: 0;
accent-color: var(--accent);
}
.github-import-state {
display: flex;
align-items: flex-start;
@@ -1246,6 +1266,7 @@ Across the thread: a top filter (All/Human/Bot) and prev/next chevrons live in t
justify-content: flex-start;
}
.issue-item {
flex-wrap: wrap;
min-height: 36px;

View File

@@ -429,6 +429,13 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
// Gates the persist-on-change effect until the mount hydration effect has applied its (possibly restored) values to
// state, so the FIRST commit's still-default state is never written over a real persisted value.
const [readyToPersistImportState, setReadyToPersistImportState] = useState(false);
const [hideImported, setHideImported] = useState(false);
/*
FNXC:GitHubImport 2026-07-15-16:30:
The Hide imported control is a view-only filter over already-imported candidates. Persist it per project with the other
cheap Import Tasks preferences so navigation preserves the operator's decluttering choice without retaining fetched lists.
*/
/*
FNXC:GitHubImport 2026-07-15-15:30:
@@ -493,6 +500,7 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
setOwner("");
setRepo("");
setLabels(persisted?.labels ?? "");
setHideImported(persisted?.hideImported ?? false);
setProvider(persisted?.provider ?? "github");
setGitlabResource(persisted?.gitlabResource ?? "project_issue");
setGitlabProject(persisted?.gitlabProject ?? "");
@@ -832,6 +840,7 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
selectedIssueNumber,
selectedPullNumber,
selectedGitlabKey,
hideImported,
},
projectId,
);
@@ -849,6 +858,7 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
selectedIssueNumber,
selectedPullNumber,
selectedGitlabKey,
hideImported,
projectId,
]);
@@ -1147,6 +1157,13 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
autoTranslateEnabled,
});
useEffect(() => {
if (!hideImported) return;
if (selectedIssue && isUrlImported(selectedIssue.html_url)) setSelectedIssueNumber(null);
if (selectedPull && isUrlImported(selectedPull.html_url)) setSelectedPullNumber(null);
if (selectedGitlabItem && isUrlImported(selectedGitlabItem.webUrl)) setSelectedGitlabKey(null);
}, [hideImported, selectedIssue, selectedPull, selectedGitlabItem, isUrlImported]);
if (!isOpen) return null;
// Determine state flags
@@ -1156,6 +1173,17 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
// Tab-specific counts
const importedIssueCount = issues.filter((issue) => isUrlImported(issue.html_url)).length;
const importedPullCount = pulls.filter((pull) => isUrlImported(pull.html_url)).length;
/*
FNXC:GitHubImport 2026-07-15-16:30:
Hide imported removes imported rows from every provider's render set, while toggle-off preserves the original arrays and
imported counts always use the full fetched sets. Use `isUrlImported` so optimistic imports also disappear immediately.
*/
const visibleIssues = hideImported ? issues.filter((issue) => !isUrlImported(issue.html_url)) : issues;
const visiblePulls = hideImported ? pulls.filter((pull) => !isUrlImported(pull.html_url)) : pulls;
const visibleGitlabItems = hideImported ? gitlabItems.filter((item) => !isUrlImported(item.webUrl)) : gitlabItems;
const allIssuesHidden = hideImported && issues.length > 0 && visibleIssues.length === 0;
const allPullsHidden = hideImported && pulls.length > 0 && visiblePulls.length === 0;
const allGitlabItemsHidden = hideImported && gitlabItems.length > 0 && visibleGitlabItems.length === 0;
// Empty states
const isIssuesEmpty = isIssuesEmptyState;
@@ -1357,12 +1385,20 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
<div className="github-import-results-meta" aria-live="polite">
<span>{t("git.issueCount", { count: issues.length, defaultValue_one: "{{count}} issue", defaultValue_other: "{{count}} issues" })}</span>
<span>{t("git.importedCount", "{{count}} imported", { count: importedIssueCount })}</span>
<label className="github-import-hide-imported">
<input type="checkbox" checked={hideImported} onChange={(event) => setHideImported(event.target.checked)} data-testid="github-import-hide-imported-toggle" />
{t("git.hideImported", "Hide imported")}
</label>
</div>
)}
{activeTab === "pulls" && pulls.length > 0 && (
<div className="github-import-results-meta" aria-live="polite">
<span>{t("git.pullCount", { count: pulls.length, defaultValue_one: "{{count}} pull request", defaultValue_other: "{{count}} pull requests" })}</span>
<span>{t("git.importedCount", "{{count}} imported", { count: importedPullCount })}</span>
<label className="github-import-hide-imported">
<input type="checkbox" checked={hideImported} onChange={(event) => setHideImported(event.target.checked)} data-testid="github-import-hide-imported-toggle" />
{t("git.hideImported", "Hide imported")}
</label>
</div>
)}
</div>
@@ -1405,10 +1441,16 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
</div>
)}
{activeTab === "issues" && allIssuesHidden && (
<div className="github-import-state github-import-state--empty" data-testid="github-import-all-imported-empty" role="status">
<div><strong>{t("git.allImportedHidden", "All loaded items are already imported")}</strong></div>
</div>
)}
{/* Issues list */}
{activeTab === "issues" && issues.length > 0 && (
{activeTab === "issues" && visibleIssues.length > 0 && (
<div className="issues-list" aria-live="polite">
{issues.map((issue) => {
{visibleIssues.map((issue) => {
const isImported = isUrlImported(issue.html_url);
return (
<div
@@ -1456,10 +1498,16 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
</div>
)}
{activeTab === "pulls" && allPullsHidden && (
<div className="github-import-state github-import-state--empty" data-testid="github-import-all-imported-empty" role="status">
<div><strong>{t("git.allImportedHidden", "All loaded items are already imported")}</strong></div>
</div>
)}
{/* Pulls list */}
{activeTab === "pulls" && pulls.length > 0 && (
{activeTab === "pulls" && visiblePulls.length > 0 && (
<div className="issues-list" aria-live="polite">
{pulls.map((pull) => {
{visiblePulls.map((pull) => {
const isImported = isUrlImported(pull.html_url);
return (
<div
@@ -1746,13 +1794,24 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
{loading ? <Loader2 size={14} className="spin" /> : <RefreshCw size={14} />}
{t("git.load", "Load")}
</button>
{gitlabItems.length > 0 && (
<label className="github-import-hide-imported">
<input type="checkbox" checked={hideImported} onChange={(event) => setHideImported(event.target.checked)} data-testid="github-import-hide-imported-toggle" />
{t("git.hideImported", "Hide imported")}
</label>
)}
</div>
{!gitlabEnabled && <div className="github-import-state github-import-state--idle" data-testid="gitlab-import-disabled"><strong>{t("git.gitlabDisabledHeading", "GitLab integration disabled")}</strong><span>{t("git.gitlabDisabledHint", "Enable GitLab integration in Settings to fetch or import GitLab resources. Saved GitLab URLs and tokens remain configured.")}</span></div>}
{error && <div className="github-import-state github-import-state--error" data-testid="gitlab-import-error"><strong>{t("git.gitlabError", "GitLab import unavailable")}</strong><span>{error}</span></div>}
{gitlabItems.length === 0 && !loading && !error ? <div className="github-import-state github-import-state--idle" data-testid="gitlab-import-empty"><strong>{t("git.gitlabNoResources", "No GitLab resources loaded")}</strong><span>{t("git.gitlabLoadHint", "Enter a project or group and load resources from the configured GitLab instance.")}</span></div> : null}
<div className="github-import-gitlab__workspace">
{allGitlabItemsHidden ? (
<div className="github-import-state github-import-state--empty" data-testid="github-import-all-imported-empty" role="status">
<div><strong>{t("git.allImportedHidden", "All loaded items are already imported")}</strong></div>
</div>
) : visibleGitlabItems.length > 0 ? (
<div className="issues-list" aria-live="polite">
{gitlabItems.map((item) => {
{visibleGitlabItems.map((item) => {
const key = `${item.resourceKind}:${item.projectId ?? item.projectPath ?? ""}:${item.iid}`;
const imported = isUrlImported(item.webUrl);
return (
@@ -1763,6 +1822,7 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
);
})}
</div>
) : null}
{selectedGitlabItem && (
<FloatingWindow
windowKey="github-import-detail"

View File

@@ -1912,4 +1912,112 @@ describe("GitHubImportModal", () => {
expect(source).toMatch(/@media \(max-width: 768px\)[\s\S]*\.floating-window--github-import-detail[\s\S]*width: 100vw !important/);
expect(source).toContain(".floating-window--github-import-detail .floating-window__resize-handle");
});
describe("Hide imported", () => {
const issueItems = [
{ number: 1, title: "Imported issue", body: "", html_url: "https://github.com/owner/repo/issues/1", labels: [] },
{ number: 2, title: "Available issue", body: "", html_url: "https://github.com/owner/repo/issues/2", labels: [] },
];
const gitlabItems = [
{ resourceKind: "project_issue" as const, id: 1, iid: 1, projectId: 3, projectPath: "group/project", title: "Imported GitLab item", description: "", webUrl: "https://gitlab.example.com/group/project/-/issues/1", state: "opened", labels: [] },
{ resourceKind: "project_issue" as const, id: 2, iid: 2, projectId: 3, projectPath: "group/project", title: "Available GitLab item", description: "", webUrl: "https://gitlab.example.com/group/project/-/issues/2", state: "opened", labels: [] },
];
it("filters imported issues and pulls without changing their full imported counts", async () => {
const importedIssueTask = { ...mockTask, description: "Source: https://github.com/owner/repo/issues/1" };
const importedPullTask = { ...mockPRTask, description: "PR: https://github.com/owner/repo/pull/1" };
vi.mocked(fetchGitRemotes).mockResolvedValueOnce([{ name: "origin", owner: "owner", repo: "repo", url: "" }]);
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce(issueItems);
vi.mocked(apiFetchGitHubPulls).mockResolvedValueOnce(mockPulls);
render(<GitHubImportModal isOpen onClose={onClose} onImport={onImport} tasks={[importedIssueTask, importedPullTask]} projectId="project-1" />);
await screen.findByText("Imported issue");
const toggle = screen.getByRole("checkbox", { name: /hide imported/i });
fireEvent.click(toggle);
expect(screen.queryByText("Imported issue")).toBeNull();
expect(screen.getByText("Available issue")).toBeTruthy();
expect(screen.getByText("1 imported")).toBeTruthy();
fireEvent.click(toggle);
expect(await screen.findByText("Imported issue")).toBeTruthy();
expect(screen.getByText("Imported issue").closest(".issue-item")).toHaveClass("imported");
fireEvent.click(screen.getByRole("tab", { name: /pull requests/i }));
await screen.findByText("Test PR");
fireEvent.click(screen.getByRole("checkbox", { name: /hide imported/i }));
expect(screen.queryByText("Test PR")).toBeNull();
expect(screen.getByText("Another PR")).toBeTruthy();
});
it("persists the toggle per project and clears a selection that becomes hidden", async () => {
vi.mocked(fetchGitRemotes).mockResolvedValue([{ name: "origin", owner: "owner", repo: "repo", url: "" }]);
vi.mocked(apiFetchGitHubIssues).mockResolvedValue(issueItems);
const view = render(<GitHubImportModal isOpen onClose={onClose} onImport={onImport} tasks={[]} projectId="project-1" />);
await screen.findByText("Imported issue");
fireEvent.click(screen.getByRole("radio", { name: /select issue #1/i }));
expect(await screen.findByTestId("github-import-preview-card")).toHaveTextContent("Imported issue");
view.rerender(<GitHubImportModal isOpen onClose={onClose} onImport={onImport} tasks={[{ ...mockTask, description: "Source: https://github.com/owner/repo/issues/1" }]} projectId="project-1" />);
fireEvent.click(screen.getByTestId("github-import-hide-imported-toggle"));
await waitFor(() => expect(screen.queryByTestId("github-import-preview-card")).toBeNull());
view.unmount();
render(<GitHubImportModal isOpen onClose={onClose} onImport={onImport} tasks={[]} projectId="project-1" />);
await screen.findByText("Imported issue");
expect(screen.getByTestId("github-import-hide-imported-toggle")).toBeChecked();
});
it("filters GitLab rows and renders a dedicated all-imported state for every provider", async () => {
const importedGitlabTask = { ...mockTask, description: "Source: https://gitlab.example.com/group/project/-/issues/1" };
vi.mocked(fetchGitRemotes).mockResolvedValueOnce([]);
vi.mocked(apiFetchGitLabProjectIssues).mockResolvedValueOnce(gitlabItems);
const filteredView = render(<GitHubImportModal isOpen onClose={onClose} onImport={onImport} tasks={[importedGitlabTask]} projectId="project-2" />);
fireEvent.click(await screen.findByRole("button", { name: "GitLab" }));
fireEvent.change(screen.getByLabelText("GitLab project path or ID"), { target: { value: "group/project" } });
fireEvent.click(screen.getByRole("button", { name: "Load" }));
await screen.findByText(/Imported GitLab item/);
fireEvent.click(screen.getByTestId("github-import-hide-imported-toggle"));
expect(screen.queryByText(/Imported GitLab item/)).toBeNull();
expect(screen.getByText(/Available GitLab item/)).toBeTruthy();
const allImportedTask = { ...mockTask, description: "Source: https://gitlab.example.com/group/project/-/issues/2" };
filteredView.unmount();
window.localStorage.removeItem(`kb:project-2:${GITHUB_IMPORT_STATE_KEY}`);
vi.mocked(fetchGitRemotes).mockResolvedValueOnce([]);
vi.mocked(apiFetchGitLabProjectIssues).mockResolvedValueOnce(gitlabItems);
render(<GitHubImportModal isOpen onClose={onClose} onImport={onImport} tasks={[importedGitlabTask, allImportedTask]} projectId="project-a" />);
fireEvent.click(await screen.findByRole("button", { name: "GitLab" }));
fireEvent.change(screen.getByLabelText("GitLab project path or ID"), { target: { value: "group/project" } });
fireEvent.click(screen.getByRole("button", { name: "Load" }));
fireEvent.click(await screen.findByTestId("github-import-hide-imported-toggle"));
expect(await screen.findByTestId("github-import-all-imported-empty")).toHaveTextContent("All loaded items are already imported");
});
it("keeps the shared toggle reachable and functional at the mobile breakpoint", async () => {
const originalInnerWidth = window.innerWidth;
Object.defineProperty(window, "innerWidth", { writable: true, configurable: true, value: 480 });
window.dispatchEvent(new Event("resize"));
const importedIssueTask = { ...mockTask, description: "Source: https://github.com/owner/repo/issues/1" };
vi.mocked(fetchGitRemotes).mockResolvedValueOnce([{ name: "origin", owner: "owner", repo: "repo", url: "" }]);
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce(issueItems);
const view = render(<GitHubImportModal isOpen onClose={onClose} onImport={onImport} tasks={[importedIssueTask]} projectId="project-b" />);
await screen.findByText("Imported issue");
fireEvent.click(screen.getByTestId("github-import-hide-imported-toggle"));
expect(screen.queryByText("Imported issue")).toBeNull();
expect(screen.getByText("Available issue")).toBeTruthy();
view.unmount();
window.localStorage.removeItem(`kb:project-b:${GITHUB_IMPORT_STATE_KEY}`);
vi.mocked(fetchGitRemotes).mockResolvedValueOnce([]);
vi.mocked(apiFetchGitLabProjectIssues).mockResolvedValueOnce(gitlabItems);
render(<GitHubImportModal isOpen onClose={onClose} onImport={onImport} tasks={[{ ...mockTask, description: "Source: https://gitlab.example.com/group/project/-/issues/1" }]} projectId="project-b" />);
fireEvent.click(await screen.findByRole("button", { name: "GitLab" }));
fireEvent.change(screen.getByLabelText("GitLab project path or ID"), { target: { value: "group/project" } });
fireEvent.click(screen.getByRole("button", { name: "Load" }));
await screen.findByText(/Imported GitLab item/);
fireEvent.click(screen.getByTestId("github-import-hide-imported-toggle"));
expect(screen.queryByText(/Imported GitLab item/)).toBeNull();
expect(screen.getByText(/Available GitLab item/)).toBeTruthy();
Object.defineProperty(window, "innerWidth", { writable: true, configurable: true, value: originalInnerWidth });
window.dispatchEvent(new Event("resize"));
});
});
});

View File

@@ -72,6 +72,11 @@ export interface GitHubImportPersistedState {
selectedIssueNumber: number | null;
selectedPullNumber: number | null;
selectedGitlabKey: string | null;
/*
FNXC:GitHubImport 2026-07-15-16:30:
Hide imported is a per-project view preference: it filters already-imported candidates without persisting fetched data or changing import state.
*/
hideImported?: boolean;
}
export function saveGitHubImportState(state: GitHubImportPersistedState, projectId?: string): void {
@@ -109,6 +114,7 @@ export function getGitHubImportState(projectId?: string): GitHubImportPersistedS
selectedIssueNumber: typeof p.selectedIssueNumber === "number" ? p.selectedIssueNumber : null,
selectedPullNumber: typeof p.selectedPullNumber === "number" ? p.selectedPullNumber : null,
selectedGitlabKey: typeof p.selectedGitlabKey === "string" ? p.selectedGitlabKey : null,
hideImported: typeof p.hideImported === "boolean" ? p.hideImported : undefined,
};
} catch {
return null;