diff --git a/.changeset/fn-8017-hide-imported-toggle.md b/.changeset/fn-8017-hide-imported-toggle.md new file mode 100644 index 0000000000..107f8e47b9 --- /dev/null +++ b/.changeset/fn-8017-hide-imported-toggle.md @@ -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. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 54ac5ea0d5..5ef69ee138 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -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: diff --git a/packages/dashboard/app/components/GitHubImportModal.css b/packages/dashboard/app/components/GitHubImportModal.css index 83a0b002ca..ca1a5cc309 100644 --- a/packages/dashboard/app/components/GitHubImportModal.css +++ b/packages/dashboard/app/components/GitHubImportModal.css @@ -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; diff --git a/packages/dashboard/app/components/GitHubImportModal.tsx b/packages/dashboard/app/components/GitHubImportModal.tsx index 0fa310185a..296668462d 100644 --- a/packages/dashboard/app/components/GitHubImportModal.tsx +++ b/packages/dashboard/app/components/GitHubImportModal.tsx @@ -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,
{t("git.issueCount", { count: issues.length, defaultValue_one: "{{count}} issue", defaultValue_other: "{{count}} issues" })} {t("git.importedCount", "{{count}} imported", { count: importedIssueCount })} +
)} {activeTab === "pulls" && pulls.length > 0 && (
{t("git.pullCount", { count: pulls.length, defaultValue_one: "{{count}} pull request", defaultValue_other: "{{count}} pull requests" })} {t("git.importedCount", "{{count}} imported", { count: importedPullCount })} +
)} @@ -1405,10 +1441,16 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId, )} + {activeTab === "issues" && allIssuesHidden && ( +
+
{t("git.allImportedHidden", "All loaded items are already imported")}
+
+ )} + {/* Issues list */} - {activeTab === "issues" && issues.length > 0 && ( + {activeTab === "issues" && visibleIssues.length > 0 && (
- {issues.map((issue) => { + {visibleIssues.map((issue) => { const isImported = isUrlImported(issue.html_url); return (
)} + {activeTab === "pulls" && allPullsHidden && ( +
+
{t("git.allImportedHidden", "All loaded items are already imported")}
+
+ )} + {/* Pulls list */} - {activeTab === "pulls" && pulls.length > 0 && ( + {activeTab === "pulls" && visiblePulls.length > 0 && (
- {pulls.map((pull) => { + {visiblePulls.map((pull) => { const isImported = isUrlImported(pull.html_url); return (
: } {t("git.load", "Load")} + {gitlabItems.length > 0 && ( + + )}
{!gitlabEnabled &&
{t("git.gitlabDisabledHeading", "GitLab integration disabled")}{t("git.gitlabDisabledHint", "Enable GitLab integration in Settings to fetch or import GitLab resources. Saved GitLab URLs and tokens remain configured.")}
} {error &&
{t("git.gitlabError", "GitLab import unavailable")}{error}
} {gitlabItems.length === 0 && !loading && !error ?
{t("git.gitlabNoResources", "No GitLab resources loaded")}{t("git.gitlabLoadHint", "Enter a project or group and load resources from the configured GitLab instance.")}
: null}
+ {allGitlabItemsHidden ? ( +
+
{t("git.allImportedHidden", "All loaded items are already imported")}
+
+ ) : visibleGitlabItems.length > 0 ? (
- {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, ); })}
+ ) : null} {selectedGitlabItem && ( { 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(); + + 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(); + 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(); + fireEvent.click(screen.getByTestId("github-import-hide-imported-toggle")); + await waitFor(() => expect(screen.queryByTestId("github-import-preview-card")).toBeNull()); + view.unmount(); + + render(); + 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(); + 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(); + 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(); + 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(); + 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")); + }); + }); }); diff --git a/packages/dashboard/app/hooks/modalPersistence.ts b/packages/dashboard/app/hooks/modalPersistence.ts index 1afc3e7e38..9e2d57b8db 100644 --- a/packages/dashboard/app/hooks/modalPersistence.ts +++ b/packages/dashboard/app/hooks/modalPersistence.ts @@ -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;