FN-7657: persist GitHub issue import modal state across close/reopen
Retains the GitHub Import Tasks modal's provider/tab/filter/selection state so returning to Import Tasks doesn't reset the user's in-progress import setup. - GitHubImportModal now persists provider, active tab, label filter, remote, and issue selection per project via a new modalPersistence hook, restoring them on remount instead of always defaulting. - Added packages/dashboard/app/hooks/modalPersistence.ts to encapsulate the persisted-state read/write logic backed by projectStorage. - projectStorage.ts gains the `kb-dashboard-github-import-state` storage key. - Falls back to the existing default-remote auto-detect behavior when no persisted state exists. - Added extensive test coverage in GitHubImportModal.test.tsx for the new persistence behavior. - Updated docs/dashboard-guide.md to describe the retained state. - Added a patch changeset for @runfusion/fusion. Files changed: .changeset/fn-7657-github-import-state-retained.md | 7 + docs/dashboard-guide.md | 2 + .../dashboard/app/components/GitHubImportModal.tsx | 204 +++++++++++++-- .../__tests__/GitHubImportModal.test.tsx | 282 +++++++++++++++++++++ packages/dashboard/app/hooks/modalPersistence.ts | 72 ++++++ packages/dashboard/app/utils/projectStorage.ts | 1 + 6 files changed, 547 insertions(+), 21 deletions(-) Fusion-Task-Id: FN-7657 Fusion-Task-Lineage: c8086340-368c-4efd-a12a-4cddeeb0aa26 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7657-github-import-state-retained.md
Normal file
7
.changeset/fn-7657-github-import-state-retained.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Retain GitHub issue import state when leaving and returning to Import Tasks.
|
||||
category: fix
|
||||
dev: Persists GitHubImportModal provider/tab/label filter/remote/selection per project via projectStorage (`kb-dashboard-github-import-state`) and hydrates on remount; falls back to the existing default-remote auto-detect when nothing is persisted.
|
||||
@@ -277,6 +277,8 @@ Use Import Tasks on desktop/tablet:
|
||||
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. 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.
|
||||
|
||||
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. 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:
|
||||
|
||||
1. Open the compact Header actions or bottom **More** sheet and select **Import from GitHub**.
|
||||
|
||||
@@ -36,6 +36,7 @@ import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
||||
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
|
||||
import { useEmbeddedPresentation, type ModalPresentation } from "../hooks/useEmbeddedPresentation";
|
||||
import { getScopedItem, setScopedItem } from "../utils/projectStorage";
|
||||
import { getGitHubImportState, saveGitHubImportState } from "../hooks/modalPersistence";
|
||||
|
||||
interface GitHubImportModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -421,6 +422,26 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
|
||||
// Track which owner/repo we've already auto-loaded to prevent duplicate loads
|
||||
const autoLoadedRef = useRef<{ owner: string; repo: string; labels: string; tab: TabType } | null>(null);
|
||||
|
||||
/*
|
||||
FNXC:GitHubImport 2026-07-07-00:00:
|
||||
Restored selections (issue/pull/GitLab) must not be applied until the reloaded list actually contains them, otherwise a
|
||||
stale selection would either render a dead preview or (worse) silently point at the wrong item. `handleLoad`/
|
||||
`handleLoadPulls`/`handleLoadGitLab` all clear the selection at the START of a fetch (existing behavior for user-triggered
|
||||
reloads), so the hydrated-on-mount selection is parked here and only re-applied once the corresponding fetch resolves AND
|
||||
the item is still present in the reloaded list; otherwise it is silently dropped (graceful degrade, no stuck/empty preview).
|
||||
*/
|
||||
const pendingRestoreSelectionRef = useRef<{ issueNumber: number | null; pullNumber: number | null; gitlabKey: string | null }>({
|
||||
issueNumber: null,
|
||||
pullNumber: null,
|
||||
gitlabKey: null,
|
||||
});
|
||||
// Set true during mount hydration when the persisted provider is GitLab with enough input to load; consumed by a
|
||||
// one-shot effect below once gitlabProject/gitlabGroup state actually reflects the hydrated values.
|
||||
const needsGitlabAutoLoadRef = useRef(false);
|
||||
// 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);
|
||||
|
||||
// Build set of already imported URLs from existing tasks
|
||||
const importedUrls = new Set<string>();
|
||||
for (const task of tasks) {
|
||||
@@ -440,23 +461,48 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
|
||||
}
|
||||
}
|
||||
|
||||
// Reset state when modal opens and fetch remotes
|
||||
/*
|
||||
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
|
||||
fresh on return, so this "reset on open" effect must HYDRATE persisted per-project state instead of hard-resetting it when
|
||||
a prior value exists, and fall back to the exact previous reset/default-remote-auto-detect behavior when nothing is
|
||||
persisted (first-time opens keep their existing UX unchanged). Only the cheap, restorable fields are hydrated here
|
||||
(provider/tab/labels/remote/owner/repo/GitLab inputs/selection) — fetched issue/pull/GitLab lists, loading flags, and
|
||||
detail caches are ALWAYS reset and re-derived via the existing auto-load, never persisted.
|
||||
|
||||
Decision: the modal presentation (`AppModals.tsx`, mobile overflow path) shares this same effect/state and therefore
|
||||
ALSO restores persisted state on open rather than starting fresh — keeping the two presentations coherent, since both
|
||||
read/write the same per-project storage key and a user may open either one first. Modal open/close otherwise behaves
|
||||
exactly as before (Cancel/close still unmount-discards in-memory-only state such as the fetched lists/loading flags).
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setReadyToPersistImportState(false);
|
||||
const persisted = getGitHubImportState(projectId);
|
||||
|
||||
/*
|
||||
FNXC:GitHubImport 2026-07-07-00:00:
|
||||
owner/repo/selectedRemoteName are intentionally NOT hydrated synchronously here (unlike the other fields) — doing so
|
||||
would populate them before the remote-detection fetch below resolves, which flips the auto-load effect on
|
||||
immediately (synchronously, within the same mount commit) instead of after the async remote fetch as before. That
|
||||
earlier timing can disable the tab buttons (loading=true) before a user's next interaction lands. They are applied
|
||||
instead inside the fetchGitRemotes().then() below, preserving the original async timing while still taking
|
||||
precedence over the default-remote auto-detect once remotes are known.
|
||||
*/
|
||||
setOwner("");
|
||||
setRepo("");
|
||||
setLabels("");
|
||||
setProvider("github");
|
||||
setGitlabResource("project_issue");
|
||||
setGitlabProject("");
|
||||
setGitlabGroup("");
|
||||
setLabels(persisted?.labels ?? "");
|
||||
setProvider(persisted?.provider ?? "github");
|
||||
setGitlabResource(persisted?.gitlabResource ?? "project_issue");
|
||||
setGitlabProject(persisted?.gitlabProject ?? "");
|
||||
setGitlabGroup(persisted?.gitlabGroup ?? "");
|
||||
setGitlabItems([]);
|
||||
setSelectedGitlabKey(null);
|
||||
setIssues([]);
|
||||
setSelectedIssueNumber(null);
|
||||
setPulls([]);
|
||||
setSelectedPullNumber(null);
|
||||
setActiveTab("issues");
|
||||
setActiveTab(persisted?.activeTab ?? "issues");
|
||||
setError(null);
|
||||
setIsIssuesEmptyState(false);
|
||||
setIsPullsEmptyState(false);
|
||||
@@ -466,6 +512,22 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
|
||||
setSelectedRemoteName("");
|
||||
autoLoadedRef.current = null;
|
||||
|
||||
// Stash the restored selections; they are re-applied once the corresponding reload resolves and confirms the item
|
||||
// is still present (see handleLoad/handleLoadPulls/handleLoadGitLab), never applied blindly.
|
||||
pendingRestoreSelectionRef.current = {
|
||||
issueNumber: persisted?.selectedIssueNumber ?? null,
|
||||
pullNumber: persisted?.selectedPullNumber ?? null,
|
||||
gitlabKey: persisted?.selectedGitlabKey ?? null,
|
||||
};
|
||||
needsGitlabAutoLoadRef.current =
|
||||
persisted?.provider === "gitlab" &&
|
||||
(persisted.gitlabResource === "group_issue" ? Boolean(persisted.gitlabGroup) : Boolean(persisted.gitlabProject));
|
||||
|
||||
// Applying the hydrated (possibly-empty) values above completes the synchronous portion of hydration; flipping this
|
||||
// now lets the persist-on-change effect start writing from the NEXT render, which already reflects these values —
|
||||
// never the pre-hydration defaults from this render's initial mount.
|
||||
setReadyToPersistImportState(true);
|
||||
|
||||
mountedRef.current = true;
|
||||
const remoteLoadRequestId = remoteLoadRequestIdRef.current + 1;
|
||||
remoteLoadRequestIdRef.current = remoteLoadRequestId;
|
||||
@@ -487,21 +549,38 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
|
||||
setRemotes(fetchedRemotes);
|
||||
setLoadingRemotes(false);
|
||||
|
||||
const defaultRemote = fetchedRemotes.length === 1
|
||||
? fetchedRemotes[0]
|
||||
: fetchedRemotes.find((remote) => remote.name === "origin");
|
||||
// Hydrated remote/owner/repo take precedence when the named remote still exists in the freshly detected list.
|
||||
const hydratedRemoteName = persisted?.selectedRemoteName;
|
||||
const hydratedRemote = hydratedRemoteName
|
||||
? fetchedRemotes.find((remote) => remote.name === hydratedRemoteName)
|
||||
: undefined;
|
||||
|
||||
if (defaultRemote) {
|
||||
setOwner(defaultRemote.owner);
|
||||
setRepo(defaultRemote.repo);
|
||||
setSelectedRemoteName(defaultRemote.name);
|
||||
} else if (fetchedRemotes.length > 1) {
|
||||
// Multiple remotes without origin: don't auto-select, user must choose.
|
||||
setOwner("");
|
||||
setRepo("");
|
||||
setSelectedRemoteName("");
|
||||
if (hydratedRemote) {
|
||||
setOwner(hydratedRemote.owner);
|
||||
setRepo(hydratedRemote.repo);
|
||||
setSelectedRemoteName(hydratedRemote.name);
|
||||
} else if (persisted?.owner && persisted?.repo) {
|
||||
// Persisted owner/repo survive even without a matching named remote (e.g. remote renamed/removed).
|
||||
setOwner(persisted.owner);
|
||||
setRepo(persisted.repo);
|
||||
setSelectedRemoteName(persisted.selectedRemoteName ?? "");
|
||||
} else {
|
||||
const defaultRemote = fetchedRemotes.length === 1
|
||||
? fetchedRemotes[0]
|
||||
: fetchedRemotes.find((remote) => remote.name === "origin");
|
||||
|
||||
if (defaultRemote) {
|
||||
setOwner(defaultRemote.owner);
|
||||
setRepo(defaultRemote.repo);
|
||||
setSelectedRemoteName(defaultRemote.name);
|
||||
} else if (fetchedRemotes.length > 1) {
|
||||
// Multiple remotes without origin: don't auto-select, user must choose.
|
||||
setOwner("");
|
||||
setRepo("");
|
||||
setSelectedRemoteName("");
|
||||
}
|
||||
// If no remotes, owner/repo remain empty
|
||||
}
|
||||
// If no remotes, owner/repo remain empty
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled && mountedRef.current && remoteLoadRequestId === remoteLoadRequestIdRef.current) {
|
||||
@@ -554,6 +633,14 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
|
||||
if (fetchedIssues.length === 0) {
|
||||
setIsIssuesEmptyState(true);
|
||||
}
|
||||
// FNXC:GitHubImport 2026-07-07-00:00: Re-apply a hydrated-on-mount selection only if it survived the reload; otherwise drop it silently (already null from the reset above).
|
||||
const restoreIssueNumber = pendingRestoreSelectionRef.current.issueNumber;
|
||||
if (restoreIssueNumber !== null) {
|
||||
pendingRestoreSelectionRef.current.issueNumber = null;
|
||||
if (fetchedIssues.some((issue) => issue.number === restoreIssueNumber)) {
|
||||
setSelectedIssueNumber(restoreIssueNumber);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err) || t("git.failedToFetchIssues", "Failed to fetch issues"));
|
||||
} finally {
|
||||
@@ -580,6 +667,14 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
|
||||
if (fetchedPulls.length === 0) {
|
||||
setIsPullsEmptyState(true);
|
||||
}
|
||||
// FNXC:GitHubImport 2026-07-07-00:00: Re-apply a hydrated-on-mount selection only if it survived the reload; otherwise drop it silently (already null from the reset above).
|
||||
const restorePullNumber = pendingRestoreSelectionRef.current.pullNumber;
|
||||
if (restorePullNumber !== null) {
|
||||
pendingRestoreSelectionRef.current.pullNumber = null;
|
||||
if (fetchedPulls.some((pull) => pull.number === restorePullNumber)) {
|
||||
setSelectedPullNumber(restorePullNumber);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err) || t("git.failedToFetchPulls", "Failed to fetch pull requests"));
|
||||
} finally {
|
||||
@@ -631,6 +726,14 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
|
||||
: await apiFetchGitLabMergeRequests(project, 30, labelArray.length > 0 ? labelArray : undefined);
|
||||
setGitlabItems(fetched);
|
||||
if (fetched.length === 0) setIsIssuesEmptyState(true);
|
||||
// FNXC:GitHubImport 2026-07-07-00:00: Re-apply a hydrated-on-mount GitLab selection only if it survived the reload; otherwise drop it silently (already null from the reset above).
|
||||
const restoreGitlabKey = pendingRestoreSelectionRef.current.gitlabKey;
|
||||
if (restoreGitlabKey !== null) {
|
||||
pendingRestoreSelectionRef.current.gitlabKey = null;
|
||||
if (fetched.some((item) => `${item.resourceKind}:${item.projectId ?? item.projectPath ?? ""}:${item.iid}` === restoreGitlabKey)) {
|
||||
setSelectedGitlabKey(restoreGitlabKey);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err) || t("git.failedToFetchGitlab", "Failed to fetch GitLab resources"));
|
||||
} finally {
|
||||
@@ -658,6 +761,65 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
|
||||
}
|
||||
}, [selectedGitlabItem, gitlabEnabled, gitlabResource, gitlabProject, gitlabGroup, projectId, onImport, isMobile, mobileView, t]);
|
||||
|
||||
/*
|
||||
FNXC:GitHubImport 2026-07-07-00:00:
|
||||
Mirrors the GitHub auto-load effect below for GitLab: on mount hydration with a persisted GitLab provider + enough input
|
||||
(project for project_issue/merge_request, group for group_issue), trigger exactly one auto-load so the restored
|
||||
selection has a list to be validated/re-applied against (see handleLoadGitLab). One-shot via the ref flag so manual
|
||||
reloads/tab switches never re-trigger this.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!needsGitlabAutoLoadRef.current) return;
|
||||
if (provider !== "gitlab") return;
|
||||
const ready = gitlabResource === "group_issue" ? Boolean(gitlabGroup.trim()) : Boolean(gitlabProject.trim());
|
||||
if (!ready) return;
|
||||
needsGitlabAutoLoadRef.current = false;
|
||||
handleLoadGitLab();
|
||||
}, [provider, gitlabResource, gitlabProject, gitlabGroup, handleLoadGitLab]);
|
||||
|
||||
/*
|
||||
FNXC:GitHubImport 2026-07-07-00:00:
|
||||
Persist the cheap/restorable import-state fields per-project whenever any of them change, so leaving and returning to
|
||||
the embedded view resumes the user's prior context. Gated on readyToPersistImportState so the FIRST commit after mount
|
||||
(still holding pre-hydration defaults) never overwrites a real persisted value; the mount-hydration effect above flips
|
||||
this flag only after applying the (possibly restored) values to state.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!readyToPersistImportState) return;
|
||||
saveGitHubImportState(
|
||||
{
|
||||
provider,
|
||||
activeTab,
|
||||
labels,
|
||||
selectedRemoteName,
|
||||
owner,
|
||||
repo,
|
||||
gitlabResource,
|
||||
gitlabProject,
|
||||
gitlabGroup,
|
||||
selectedIssueNumber,
|
||||
selectedPullNumber,
|
||||
selectedGitlabKey,
|
||||
},
|
||||
projectId,
|
||||
);
|
||||
}, [
|
||||
readyToPersistImportState,
|
||||
provider,
|
||||
activeTab,
|
||||
labels,
|
||||
selectedRemoteName,
|
||||
owner,
|
||||
repo,
|
||||
gitlabResource,
|
||||
gitlabProject,
|
||||
gitlabGroup,
|
||||
selectedIssueNumber,
|
||||
selectedPullNumber,
|
||||
selectedGitlabKey,
|
||||
projectId,
|
||||
]);
|
||||
|
||||
// Auto-load data when owner and repo are set and valid
|
||||
useEffect(() => {
|
||||
if (provider !== "github") return;
|
||||
|
||||
@@ -154,8 +154,25 @@ describe("GitHubImportModal", () => {
|
||||
expect(activeRule).toContain("background: color-mix(in srgb, var(--todo) 12%, transparent);");
|
||||
});
|
||||
|
||||
/*
|
||||
* FNXC:GitHubImport 2026-07-07-00:00:
|
||||
* FN-7657 introduced per-project persistence for the import view (provider/tab/labels/remote/selection) under
|
||||
* `kb-dashboard-github-import-state` (unscoped when no projectId is passed, `kb:{projectId}:...` otherwise). Most
|
||||
* pre-existing tests in this file render without a projectId and therefore share the SAME unscoped storage key, so
|
||||
* that key (and the projectIds exercised anywhere in this file) must be cleared before EVERY test or state written
|
||||
* by one test would leak into the next test's initial render.
|
||||
*/
|
||||
const GITHUB_IMPORT_STATE_KEY = "kb-dashboard-github-import-state";
|
||||
const clearAllPersistedImportState = () => {
|
||||
window.localStorage.removeItem(GITHUB_IMPORT_STATE_KEY);
|
||||
for (const projectId of ["project-1", "project-2", "project-a", "project-b"]) {
|
||||
window.localStorage.removeItem(`kb:${projectId}:${GITHUB_IMPORT_STATE_KEY}`);
|
||||
}
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
clearAllPersistedImportState();
|
||||
vi.mocked(fetchGitRemotes).mockReset();
|
||||
vi.mocked(apiFetchGitHubIssues).mockReset();
|
||||
vi.mocked(apiImportGitHubIssue).mockReset();
|
||||
@@ -2232,4 +2249,269 @@ describe("GitHubImportModal", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
* FNXC:GitHubImport 2026-07-07-00:00:
|
||||
* FN-7657 symptom verification. The embedded Import Tasks view fully unmounts on navigation away (e.g. to Board) and
|
||||
* remounts fresh on return; before this fix every one of these fields reset to defaults on remount. These tests
|
||||
* unmount + remount a fresh instance with the SAME projectId to simulate exactly that, and assert restoration.
|
||||
*/
|
||||
describe("import state retention on exit and return (FN-7657)", () => {
|
||||
const GITHUB_IMPORT_STATE_KEY = "kb-dashboard-github-import-state";
|
||||
const originalInnerWidth = window.innerWidth;
|
||||
|
||||
const clearImportState = (projectId?: string) => {
|
||||
const key = projectId ? `kb:${projectId}:${GITHUB_IMPORT_STATE_KEY}` : GITHUB_IMPORT_STATE_KEY;
|
||||
window.localStorage.removeItem(key);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
clearImportState("project-1");
|
||||
clearImportState("project-2");
|
||||
clearImportState(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearImportState("project-1");
|
||||
clearImportState("project-2");
|
||||
clearImportState(undefined);
|
||||
Object.defineProperty(window, "innerWidth", {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: originalInnerWidth,
|
||||
});
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
});
|
||||
|
||||
it("restores the active tab, label filter, and selected issue after unmount and remount for the same project", async () => {
|
||||
vi.mocked(fetchGitRemotes).mockResolvedValue(singleRemote);
|
||||
vi.mocked(apiFetchGitHubIssues).mockResolvedValue([
|
||||
{ number: 1, title: "Persisted Issue", body: "Body", html_url: "https://github.com/dustinbyrne/kb/issues/1", labels: [] },
|
||||
]);
|
||||
|
||||
const first = render(
|
||||
<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} projectId="project-1" presentation="embedded" />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Persisted Issue")).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(/Filter:/), { target: { value: "bug" } });
|
||||
// The label change re-triggers auto-load (briefly disabling the list); wait for it to settle before selecting.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("radio", { name: /Select issue #1/i })).not.toBeDisabled();
|
||||
});
|
||||
fireEvent.click(screen.getByRole("radio", { name: /Select issue #1/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(within(screen.getByTestId("github-import-preview-card")).getByText("Persisted Issue")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Simulate navigating away from the embedded view (component fully unmounts).
|
||||
first.unmount();
|
||||
|
||||
// Simulate returning to the view: a brand-new instance mounts for the same project.
|
||||
render(
|
||||
<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} projectId="project-1" presentation="embedded" />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect((screen.getByPlaceholderText(/Filter:/) as HTMLInputElement).value).toBe("bug");
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(within(screen.getByTestId("github-import-preview-card")).getByText("Persisted Issue")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("restores the Pull Requests tab and selected PR after unmount and remount", async () => {
|
||||
vi.mocked(fetchGitRemotes).mockResolvedValue(singleRemote);
|
||||
vi.mocked(apiFetchGitHubPulls).mockResolvedValue(mockPulls);
|
||||
|
||||
const first = render(
|
||||
<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} projectId="project-1" presentation="embedded" />,
|
||||
);
|
||||
|
||||
fireEvent.click(await screen.findByRole("tab", { name: /Pull Requests/i }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Test PR")).toBeTruthy();
|
||||
});
|
||||
fireEvent.click(screen.getByRole("radio", { name: /Select pull request #1/i }));
|
||||
await waitFor(() => {
|
||||
expect(within(screen.getByTestId("github-import-preview-card")).getByText("Test PR")).toBeTruthy();
|
||||
});
|
||||
|
||||
first.unmount();
|
||||
|
||||
render(
|
||||
<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} projectId="project-1" presentation="embedded" />,
|
||||
);
|
||||
|
||||
// Restored straight to the Pull Requests tab, with the prior PR selection re-applied.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("tab", { name: /Pull Requests/i })).toHaveAttribute("aria-selected", "true");
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(within(screen.getByTestId("github-import-preview-card")).getByText("Test PR")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("restores GitLab provider, resource inputs, and selection after unmount and remount", async () => {
|
||||
vi.mocked(fetchGitRemotes).mockResolvedValue([]);
|
||||
vi.mocked(apiFetchGitLabProjectIssues).mockResolvedValue([
|
||||
{ resourceKind: "project_issue", id: 1, iid: 2, projectId: 3, projectPath: "group/project", title: "GitLab bug", description: "Body", webUrl: "https://gitlab.example.com/group/project/-/issues/2", state: "opened", labels: ["bug"] },
|
||||
]);
|
||||
|
||||
const first = render(
|
||||
<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} projectId="project-1" presentation="embedded" />,
|
||||
);
|
||||
|
||||
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.findByText(/#2 GitLab bug/));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("gitlab-import-preview-card")).toBeTruthy();
|
||||
});
|
||||
|
||||
first.unmount();
|
||||
|
||||
render(
|
||||
<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} projectId="project-1" presentation="embedded" />,
|
||||
);
|
||||
|
||||
// Provider tab and GitLab project input are restored immediately from persisted state.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "GitLab" })).toHaveAttribute("aria-pressed", "true");
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect((screen.getByLabelText("GitLab project path or ID") as HTMLInputElement).value).toBe("group/project");
|
||||
});
|
||||
// The hydrated-on-mount auto-load re-fetches the list and re-applies the restored selection.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("gitlab-import-preview-card")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the existing default remote auto-detect behavior when no state has ever been persisted", async () => {
|
||||
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
|
||||
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce([
|
||||
{ number: 9, title: "Fresh Issue", body: "", html_url: "https://github.com/dustinbyrne/kb/issues/9", labels: [] },
|
||||
]);
|
||||
|
||||
render(
|
||||
<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} projectId="project-1" presentation="embedded" />,
|
||||
);
|
||||
|
||||
// No persisted state exists for this project: the single detected remote is still auto-selected and its issues load.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("github-import-single-remote")).toBeTruthy();
|
||||
expect(apiFetchGitHubIssues).toHaveBeenCalledWith("dustinbyrne", "kb", 30, undefined);
|
||||
expect(screen.getByText("Fresh Issue")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("does not leak persisted state across different projects", async () => {
|
||||
vi.mocked(fetchGitRemotes).mockResolvedValue(singleRemote);
|
||||
vi.mocked(apiFetchGitHubIssues).mockResolvedValue([
|
||||
{ number: 1, title: "Project One Issue", body: "", html_url: "https://github.com/dustinbyrne/kb/issues/1", labels: [] },
|
||||
]);
|
||||
|
||||
const first = render(
|
||||
<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} projectId="project-1" presentation="embedded" />,
|
||||
);
|
||||
await waitFor(() => expect(screen.getByText("Project One Issue")).toBeTruthy());
|
||||
fireEvent.change(screen.getByPlaceholderText(/Filter:/), { target: { value: "bug" } });
|
||||
// The label change re-triggers auto-load (briefly disabling the list); wait for it to settle before selecting.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("radio", { name: /Select issue #1/i })).not.toBeDisabled();
|
||||
});
|
||||
fireEvent.click(screen.getByRole("radio", { name: /Select issue #1/i }));
|
||||
await waitFor(() => {
|
||||
expect(within(screen.getByTestId("github-import-preview-card")).getByText("Project One Issue")).toBeTruthy();
|
||||
});
|
||||
first.unmount();
|
||||
|
||||
// (project-1's own selection is verified above; now assert isolation for project-2.)
|
||||
// A different project must NOT see project-1's persisted filter/selection.
|
||||
render(
|
||||
<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} projectId="project-2" presentation="embedded" />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect((screen.getByPlaceholderText(/Filter:/) as HTMLInputElement).value).toBe("");
|
||||
});
|
||||
expect(screen.getByTestId("github-import-preview-empty")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("clears gracefully when a persisted selection is no longer present in the reloaded list", async () => {
|
||||
vi.mocked(fetchGitRemotes).mockResolvedValue(singleRemote);
|
||||
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce([
|
||||
{ number: 1, title: "Will Vanish", body: "", html_url: "https://github.com/dustinbyrne/kb/issues/1", labels: [] },
|
||||
]);
|
||||
|
||||
const first = render(
|
||||
<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} projectId="project-1" presentation="embedded" />,
|
||||
);
|
||||
await waitFor(() => expect(screen.getByText("Will Vanish")).toBeTruthy());
|
||||
fireEvent.click(screen.getByRole("radio", { name: /Select issue #1/i }));
|
||||
await waitFor(() => {
|
||||
expect(within(screen.getByTestId("github-import-preview-card")).getByText("Will Vanish")).toBeTruthy();
|
||||
});
|
||||
first.unmount();
|
||||
|
||||
// On return, the reloaded list no longer contains issue #1 (e.g. closed/merged/deleted upstream).
|
||||
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce([
|
||||
{ number: 2, title: "Still Here", body: "", html_url: "https://github.com/dustinbyrne/kb/issues/2", labels: [] },
|
||||
]);
|
||||
|
||||
render(
|
||||
<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} projectId="project-1" presentation="embedded" />,
|
||||
);
|
||||
|
||||
// No crash and no stuck preview: the stale selection is dropped and the list/empty-preview state renders cleanly.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Still Here")).toBeTruthy();
|
||||
});
|
||||
expect(screen.getByTestId("github-import-preview-empty")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not strand a restored selection on an empty mobile preview pane after remount", async () => {
|
||||
Object.defineProperty(window, "innerWidth", {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: 480,
|
||||
});
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
|
||||
vi.mocked(fetchGitRemotes).mockResolvedValue(singleRemote);
|
||||
vi.mocked(apiFetchGitHubIssues).mockResolvedValue([
|
||||
{ number: 1, title: "Mobile Persisted Issue", body: "", html_url: "https://github.com/dustinbyrne/kb/issues/1", labels: [] },
|
||||
]);
|
||||
|
||||
const first = render(
|
||||
<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} projectId="project-1" presentation="embedded" />,
|
||||
);
|
||||
await waitFor(() => expect(screen.getByText("Mobile Persisted Issue")).toBeTruthy());
|
||||
fireEvent.click(screen.getByRole("radio", { name: /Select issue #1/i }));
|
||||
first.unmount();
|
||||
|
||||
render(
|
||||
<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} projectId="project-1" presentation="embedded" />,
|
||||
);
|
||||
|
||||
// A fresh mount always starts on the list pane (never the preview pane), so a restored selection can never strand
|
||||
// the user staring at a bare/empty preview — the list (with the restored selection re-applied) is visible instead.
|
||||
await waitFor(() => {
|
||||
const listPane = screen.getByTestId("github-import-list-pane");
|
||||
expect(listPane.classList.contains("active")).toBe(true);
|
||||
expect(screen.getByTestId("github-import-preview-pane").classList.contains("active")).toBe(false);
|
||||
});
|
||||
await waitFor(() => {
|
||||
const radio = screen.getByRole("radio", { name: /Select issue #1/i }) as HTMLInputElement;
|
||||
expect(radio.checked).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { getScopedItem, removeScopedItem, setScopedItem } from "../utils/project
|
||||
export const STORED_PLANNING_KEY = "kb-planning-last-description";
|
||||
export const STORED_SUBTASK_KEY = "kb-subtask-last-description";
|
||||
export const STORED_MISSION_KEY = "kb-mission-last-goal";
|
||||
export const STORED_GITHUB_IMPORT_KEY = "kb-dashboard-github-import-state";
|
||||
|
||||
// Planning persistence
|
||||
|
||||
@@ -46,3 +47,74 @@ export function getMissionGoal(projectId?: string): string {
|
||||
export function clearMissionGoal(projectId?: string): void {
|
||||
removeScopedItem(STORED_MISSION_KEY, projectId);
|
||||
}
|
||||
|
||||
// GitHub/GitLab import persistence
|
||||
|
||||
/*
|
||||
FNXC:GitHubImport 2026-07-07-00:00:
|
||||
The embedded Import Tasks view (`GitHubImportModal` rendered with `presentation="embedded"`, constant `isOpen={true}`) fully
|
||||
unmounts when the user navigates to another main-content view and remounts from scratch on return, so its "reset state on
|
||||
open" effect previously wiped provider/tab/filter/remote/selection every time. Persist ONLY the cheap, restorable fields
|
||||
listed below (never the fetched issues/pulls/gitlab lists, loading flags, or detail caches — those re-derive via the
|
||||
existing auto-load) per-project so returning to the view resumes where the user left off. First-time opens with no
|
||||
persisted value must keep the existing default-remote auto-detect behavior untouched.
|
||||
*/
|
||||
export interface GitHubImportPersistedState {
|
||||
provider: "github" | "gitlab";
|
||||
activeTab: "issues" | "pulls";
|
||||
labels: string;
|
||||
selectedRemoteName: string;
|
||||
owner: string;
|
||||
repo: string;
|
||||
gitlabResource: "project_issue" | "group_issue" | "merge_request";
|
||||
gitlabProject: string;
|
||||
gitlabGroup: string;
|
||||
selectedIssueNumber: number | null;
|
||||
selectedPullNumber: number | null;
|
||||
selectedGitlabKey: string | null;
|
||||
}
|
||||
|
||||
export function saveGitHubImportState(state: GitHubImportPersistedState, projectId?: string): void {
|
||||
try {
|
||||
setScopedItem(STORED_GITHUB_IMPORT_KEY, JSON.stringify(state), projectId);
|
||||
} catch {
|
||||
// Best-effort persistence; ignore storage failures (e.g. quota, disabled storage).
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads and defensively re-shapes the persisted GitHub/GitLab import state.
|
||||
* Returns null when nothing is stored, or when the stored value is corrupt/not an object, so callers can fall back
|
||||
* to the existing reset/default-remote-auto-detect behavior exactly as before. Each field is individually validated
|
||||
* and defaulted so a partially-corrupt or schema-drifted blob still yields a usable (if partial) restore.
|
||||
*/
|
||||
export function getGitHubImportState(projectId?: string): GitHubImportPersistedState | null {
|
||||
try {
|
||||
const raw = getScopedItem(STORED_GITHUB_IMPORT_KEY, projectId);
|
||||
if (!raw) return null;
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== "object") return null;
|
||||
const p = parsed as Record<string, unknown>;
|
||||
return {
|
||||
provider: p.provider === "gitlab" ? "gitlab" : "github",
|
||||
activeTab: p.activeTab === "pulls" ? "pulls" : "issues",
|
||||
labels: typeof p.labels === "string" ? p.labels : "",
|
||||
selectedRemoteName: typeof p.selectedRemoteName === "string" ? p.selectedRemoteName : "",
|
||||
owner: typeof p.owner === "string" ? p.owner : "",
|
||||
repo: typeof p.repo === "string" ? p.repo : "",
|
||||
gitlabResource:
|
||||
p.gitlabResource === "group_issue" || p.gitlabResource === "merge_request" ? p.gitlabResource : "project_issue",
|
||||
gitlabProject: typeof p.gitlabProject === "string" ? p.gitlabProject : "",
|
||||
gitlabGroup: typeof p.gitlabGroup === "string" ? p.gitlabGroup : "",
|
||||
selectedIssueNumber: typeof p.selectedIssueNumber === "number" ? p.selectedIssueNumber : null,
|
||||
selectedPullNumber: typeof p.selectedPullNumber === "number" ? p.selectedPullNumber : null,
|
||||
selectedGitlabKey: typeof p.selectedGitlabKey === "string" ? p.selectedGitlabKey : null,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearGitHubImportState(projectId?: string): void {
|
||||
removeScopedItem(STORED_GITHUB_IMPORT_KEY, projectId);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ export const PROJECT_STORAGE_KEYS: string[] = [
|
||||
"kb-dashboard-mailbox-sidebar-width",
|
||||
"kb-dashboard-agents-sidebar-width",
|
||||
"kb-dashboard-github-import-list-width",
|
||||
"kb-dashboard-github-import-state",
|
||||
"kb-quick-entry-text",
|
||||
"kb-inline-create-text",
|
||||
"fn-agent-view",
|
||||
|
||||
Reference in New Issue
Block a user