diff --git a/__tests__/components/editor/developer/DeveloperEditorLayout.test.tsx b/__tests__/components/editor/developer/DeveloperEditorLayout.test.tsx index 978f490c..1c41d669 100644 --- a/__tests__/components/editor/developer/DeveloperEditorLayout.test.tsx +++ b/__tests__/components/editor/developer/DeveloperEditorLayout.test.tsx @@ -2,6 +2,8 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import React from "react"; +import { useSelectionStore } from "@/lib/stores/selectionStore"; + // Provide localStorage polyfill vi.hoisted(() => { if (!globalThis.localStorage || typeof globalThis.localStorage.setItem !== "function") { @@ -1332,4 +1334,32 @@ describe("DeveloperEditorLayout", () => { expect(navigateToNode).toHaveBeenCalledWith("http://example.org/ClassA"); }); }); + + describe("selection store cross-page contract", () => { + beforeEach(() => useSelectionStore.getState().clear()); + + it("populates the selection store from selectedIri on mount", () => { + render( + , + ); + expect(useSelectionStore.getState().iri).toBe("http://example.org/Foo"); + expect(useSelectionStore.getState().type).toBe("class"); + }); + + it("preserves the selection store on unmount so side-page Back-to-project links can read it", () => { + // Regression: editor used to clear the store on unmount, racing with + // side-page navigation — by the time settings/PRs/etc rendered, the + // store was empty and useProjectHomeHref dropped the selection. + const { unmount } = render( + , + ); + unmount(); + expect(useSelectionStore.getState().iri).toBe("http://example.org/Foo"); + expect(useSelectionStore.getState().type).toBe("class"); + }); + }); }); diff --git a/__tests__/components/editor/standard/StandardEditorLayout.test.tsx b/__tests__/components/editor/standard/StandardEditorLayout.test.tsx index c15c3ecc..72c10e2b 100644 --- a/__tests__/components/editor/standard/StandardEditorLayout.test.tsx +++ b/__tests__/components/editor/standard/StandardEditorLayout.test.tsx @@ -2,6 +2,8 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; import { act, render, screen, fireEvent } from "@testing-library/react"; import React from "react"; +import { useSelectionStore } from "@/lib/stores/selectionStore"; + // Provide localStorage polyfill vi.hoisted(() => { if (!globalThis.localStorage || typeof globalThis.localStorage.setItem !== "function") { @@ -1040,4 +1042,32 @@ describe("StandardEditorLayout", () => { expect(navigateToNode).toHaveBeenCalledWith("http://example.org/Unknown"); }); }); + + describe("selection store cross-page contract", () => { + beforeEach(() => useSelectionStore.getState().clear()); + + it("populates the selection store from selectedIri on mount", () => { + render( + , + ); + expect(useSelectionStore.getState().iri).toBe("http://example.org/Foo"); + expect(useSelectionStore.getState().type).toBe("class"); + }); + + it("preserves the selection store on unmount so side-page Back-to-project links can read it", () => { + // Regression: editor used to clear the store on unmount, racing with + // side-page navigation — by the time settings/PRs/etc rendered, the + // store was empty and useProjectHomeHref dropped the selection. + const { unmount } = render( + , + ); + unmount(); + expect(useSelectionStore.getState().iri).toBe("http://example.org/Foo"); + expect(useSelectionStore.getState().type).toBe("class"); + }); + }); }); diff --git a/__tests__/lib/hooks/useProjectHomeHref.test.ts b/__tests__/lib/hooks/useProjectHomeHref.test.ts new file mode 100644 index 00000000..66e8e03c --- /dev/null +++ b/__tests__/lib/hooks/useProjectHomeHref.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { renderHook } from "@testing-library/react"; + +// editorModeStore subscribes to matchMedia at module load — provide a stub +// before the hook (and therefore the store) are imported. +vi.hoisted(() => { + (globalThis as Record).matchMedia = vi.fn().mockReturnValue({ + matches: false, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }); + + // Zustand persist middleware needs a working localStorage at module-load. + if ( + !globalThis.localStorage || + typeof globalThis.localStorage.setItem !== "function" + ) { + const store = new Map(); + (globalThis as Record).localStorage = { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => store.set(key, value), + removeItem: (key: string) => store.delete(key), + clear: () => store.clear(), + get length() { + return store.size; + }, + key: (index: number) => [...store.keys()][index] ?? null, + }; + } +}); + +import { useProjectHomeHref } from "@/lib/hooks/useProjectHomeHref"; +import { useSelectionStore } from "@/lib/stores/selectionStore"; +import { useEditorModeStore } from "@/lib/stores/editorModeStore"; + +// next-auth: simple session-with-token mock; individual tests can override. +let mockSession: { accessToken?: string } | null = { accessToken: "tok" }; +vi.mock("next-auth/react", () => ({ + useSession: () => ({ data: mockSession }), +})); + +// next/navigation: only useSearchParams is consumed by the hook. +let mockSearch = ""; +vi.mock("next/navigation", () => ({ + useSearchParams: () => new URLSearchParams(mockSearch), +})); + +// useProject: mocked so we can dial canSuggest via derivePermissions. +const mockUseProject = vi.fn(); +const mockDerivePermissions = vi.fn(); +vi.mock("@/lib/hooks/useProject", () => ({ + useProject: (...args: unknown[]) => mockUseProject(...args), + derivePermissions: (...args: unknown[]) => mockDerivePermissions(...args), +})); + +const ENCODED_PERSON = encodeURIComponent("http://example.org/Person"); +const ENCODED_HAS_NAME = encodeURIComponent("http://example.org/hasName"); +const ENCODED_ALICE = encodeURIComponent("http://example.org/alice"); + +describe("useProjectHomeHref", () => { + beforeEach(() => { + mockSession = { accessToken: "tok" }; + mockSearch = ""; + useSelectionStore.getState().clear(); + useEditorModeStore.setState({ preferEditMode: false }); + mockUseProject.mockReturnValue({ project: { id: "proj-1" } }); + // Default: user can suggest (covers most assertions). + mockDerivePermissions.mockReturnValue({ canSuggest: true }); + }); + + it("returns the bare viewer URL when no selection is set", () => { + const { result } = renderHook(() => useProjectHomeHref("proj-1")); + expect(result.current).toBe("/projects/proj-1"); + }); + + it("returns the editor URL when preferEditMode is on and user can suggest", () => { + useEditorModeStore.setState({ preferEditMode: true }); + const { result } = renderHook(() => useProjectHomeHref("proj-1")); + expect(result.current).toBe("/projects/proj-1/editor"); + }); + + it("routes prefer-edit-mode users to the viewer when they cannot suggest", () => { + useEditorModeStore.setState({ preferEditMode: true }); + mockDerivePermissions.mockReturnValue({ canSuggest: false }); + const { result } = renderHook(() => useProjectHomeHref("proj-1")); + expect(result.current).toBe("/projects/proj-1"); + }); + + it("routes back to the editor when the store records mode='editor', even with preferEditMode off", () => { + // Regression: preferEditMode false used to force viewer-routing, so a user + // who switched to the editor mid-session via the switcher would lose the + // mode on Back-to-project. + useEditorModeStore.setState({ preferEditMode: false }); + useSelectionStore.getState().setMode("editor"); + const { result } = renderHook(() => useProjectHomeHref("proj-1")); + expect(result.current).toBe("/projects/proj-1/editor"); + }); + + it("routes back to the viewer when the store records mode='viewer', even with preferEditMode on", () => { + useEditorModeStore.setState({ preferEditMode: true }); + useSelectionStore.getState().setMode("viewer"); + const { result } = renderHook(() => useProjectHomeHref("proj-1")); + expect(result.current).toBe("/projects/proj-1"); + }); + + it("permission-gates editor routing even when the store says mode='editor'", () => { + useSelectionStore.getState().setMode("editor"); + mockDerivePermissions.mockReturnValue({ canSuggest: false }); + const { result } = renderHook(() => useProjectHomeHref("proj-1")); + expect(result.current).toBe("/projects/proj-1"); + }); + + it("appends a class IRI selection from the store", () => { + useSelectionStore.getState().setSelection("http://example.org/Person", "class"); + const { result } = renderHook(() => useProjectHomeHref("proj-1")); + expect(result.current).toBe(`/projects/proj-1?classIri=${ENCODED_PERSON}`); + }); + + it("appends a property IRI selection from the store", () => { + useSelectionStore.getState().setSelection("http://example.org/hasName", "property"); + const { result } = renderHook(() => useProjectHomeHref("proj-1")); + expect(result.current).toBe(`/projects/proj-1?propertyIri=${ENCODED_HAS_NAME}`); + }); + + it("appends an individual IRI selection from the store", () => { + useSelectionStore.getState().setSelection("http://example.org/alice", "individual"); + const { result } = renderHook(() => useProjectHomeHref("proj-1")); + expect(result.current).toBe(`/projects/proj-1?individualIri=${ENCODED_ALICE}`); + }); + + it("appends selection to the editor URL when preferEditMode is on", () => { + useEditorModeStore.setState({ preferEditMode: true }); + useSelectionStore.getState().setSelection("http://example.org/Person", "class"); + const { result } = renderHook(() => useProjectHomeHref("proj-1")); + expect(result.current).toBe(`/projects/proj-1/editor?classIri=${ENCODED_PERSON}`); + }); + + it("falls back to URL params when the store is empty", () => { + mockSearch = `individualIri=${ENCODED_ALICE}`; + const { result } = renderHook(() => useProjectHomeHref("proj-1")); + expect(result.current).toBe(`/projects/proj-1?individualIri=${ENCODED_ALICE}`); + }); + + it("prefers the store over URL params when both are set (store wins)", () => { + mockSearch = `classIri=${encodeURIComponent("http://example.org/Stale")}`; + useSelectionStore.getState().setSelection("http://example.org/hasName", "property"); + const { result } = renderHook(() => useProjectHomeHref("proj-1")); + expect(result.current).toBe(`/projects/proj-1?propertyIri=${ENCODED_HAS_NAME}`); + }); +}); diff --git a/__tests__/lib/stores/selectionStore.test.ts b/__tests__/lib/stores/selectionStore.test.ts index 41022211..244ab128 100644 --- a/__tests__/lib/stores/selectionStore.test.ts +++ b/__tests__/lib/stores/selectionStore.test.ts @@ -33,11 +33,13 @@ describe("useSelectionStore", () => { }); }); - it("clear resets back to null/null", () => { + it("clear resets selection and mode to null", () => { useSelectionStore.getState().setSelection("ex:Person", "class"); + useSelectionStore.getState().setMode("editor"); useSelectionStore.getState().clear(); expect(useSelectionStore.getState().iri).toBeNull(); expect(useSelectionStore.getState().type).toBeNull(); + expect(useSelectionStore.getState().mode).toBeNull(); }); it("supports null iri with null type for an empty active tab", () => { @@ -45,4 +47,18 @@ describe("useSelectionStore", () => { expect(useSelectionStore.getState().iri).toBeNull(); expect(useSelectionStore.getState().type).toBeNull(); }); + + it("starts with mode null", () => { + expect(useSelectionStore.getState().mode).toBeNull(); + }); + + it("setMode records viewer / editor independently of selection", () => { + useSelectionStore.getState().setMode("editor"); + expect(useSelectionStore.getState().mode).toBe("editor"); + // Selection untouched + expect(useSelectionStore.getState().iri).toBeNull(); + + useSelectionStore.getState().setMode("viewer"); + expect(useSelectionStore.getState().mode).toBe("viewer"); + }); }); diff --git a/app/projects/[id]/editor/page.tsx b/app/projects/[id]/editor/page.tsx index b8ae215a..186e3773 100644 --- a/app/projects/[id]/editor/page.tsx +++ b/app/projects/[id]/editor/page.tsx @@ -23,6 +23,7 @@ import { BranchProvider, branchQueryKeys } from "@/lib/context/BranchContext"; import { useProjectViewer } from "@/lib/hooks/useProjectViewer"; import { ConnectionStatus } from "@/components/ui/ConnectionStatus"; import { useEditorModeStore } from "@/lib/stores/editorModeStore"; +import { useSelectionStore } from "@/lib/stores/selectionStore"; import { revisionsApi } from "@/lib/api/revisions"; import { projectOntologyApi, type ClassUpdatePayload } from "@/lib/api/client"; import { getLocalName } from "@/lib/utils"; @@ -71,6 +72,14 @@ export default function EditorPage() { const editorMode = useEditorModeStore((s) => s.editorMode); + // Mark this surface as the user's most recent project view so side-page + // Back-to-project links route them back to the editor — regardless of + // whether their global preferEditMode preference says viewer or editor. + const setProjectViewMode = useSelectionStore((s) => s.setMode); + useEffect(() => { + setProjectViewMode("editor"); + }, [setProjectViewMode]); + // Branch state const queryClient = useQueryClient(); const [activeBranch, setActiveBranch] = useState(undefined); @@ -693,8 +702,14 @@ export default function EditorPage() { } setActiveBranch(branchName); resetSourceState(); - router.replace(`${pathname}?branch=${encodeURIComponent(branchName)}`); - }, [pathname, router, suggestionSession, resetSourceState]); + // Merge into existing search params instead of replacing — BranchSelector + // fires this on mount, and replacing would wipe ?classIri= / ?propertyIri= + // / ?individualIri= / ?resumeSession= that the page also depends on. + const next = new URLSearchParams(searchParamsString); + next.set("branch", branchName); + const qs = next.toString(); + router.replace(qs ? `${pathname}?${qs}` : pathname); + }, [pathname, router, suggestionSession, resetSourceState, searchParamsString]); // --- Keyboard shortcuts --- const keyboardShortcuts = useMemo((): ShortcutDefinition[] => [ diff --git a/app/projects/[id]/page.tsx b/app/projects/[id]/page.tsx index b364e2b6..45dd58a2 100644 --- a/app/projects/[id]/page.tsx +++ b/app/projects/[id]/page.tsx @@ -16,6 +16,7 @@ import { StandardEditorLayout } from "@/components/editor/standard/StandardEdito import { BranchProvider, useBranch } from "@/lib/context/BranchContext"; import { useProjectViewer } from "@/lib/hooks/useProjectViewer"; import { useEditorModeStore } from "@/lib/stores/editorModeStore"; +import { useSelectionStore } from "@/lib/stores/selectionStore"; import { useToast } from "@/lib/context/ToastContext"; import { useProject, derivePermissions } from "@/lib/hooks/useProject"; import type { OntologySourceEditorRef } from "@/components/editor/OntologySourceEditor"; @@ -170,6 +171,14 @@ function ViewerContent({ ); const editorMode = useEditorModeStore((s) => s.editorMode); + // Mark this surface as the user's most recent project view so side-page + // Back-to-project links route them back to the viewer — regardless of + // whether their global preferEditMode preference says viewer or editor. + const setProjectViewMode = useSelectionStore((s) => s.setMode); + useEffect(() => { + setProjectViewMode("viewer"); + }, [setProjectViewMode]); + // Use the default branch from the BranchProvider context const { defaultBranch, isLoading: isBranchLoading } = useBranch(); const resolvedBranch = isBranchLoading ? undefined : defaultBranch; diff --git a/components/editor/developer/DeveloperEditorLayout.tsx b/components/editor/developer/DeveloperEditorLayout.tsx index e4502a61..d6638d7b 100644 --- a/components/editor/developer/DeveloperEditorLayout.tsx +++ b/components/editor/developer/DeveloperEditorLayout.tsx @@ -287,10 +287,16 @@ export function DeveloperEditorLayout(props: DeveloperEditorLayoutProps) { }, [entityNavigationRef, navToNode, setScrollIri]); // Mirror the active-tab selection into the shared store so cross-page chrome - // (e.g. the Viewer/Editor switcher) can synthesize the right ?Iri= key - // without having to know about local-state plumbing here. + // (e.g. the Viewer/Editor switcher, the side-page Back-to-project link via + // useProjectHomeHref) can synthesize the right ?Iri= key without having + // to know about local-state plumbing here. + // + // No unmount cleanup: an unmount-clear races with side-page navigation — + // the editor unmounts before settings/PRs/etc render, so reading the store + // there returns null and the back-link drops the selection. The next editor + // mount (same or different project) overwrites the store from its own URL + // params, so stale values are short-lived and harmless. const setSelection = useSelectionStore((s) => s.setSelection); - const clearSelection = useSelectionStore((s) => s.clear); useEffect(() => { if (activeTab === "classes") { setSelection(selectedIri ?? null, selectedIri ? "class" : null); @@ -300,7 +306,6 @@ export function DeveloperEditorLayout(props: DeveloperEditorLayoutProps) { setSelection(selectedIndividualIri ?? null, selectedIndividualIri ? "individual" : null); } }, [activeTab, selectedIri, selectedPropertyIri, selectedIndividualIri, setSelection]); - useEffect(() => () => clearSelection(), [clearSelection]); // Shared search state const { diff --git a/components/editor/standard/StandardEditorLayout.tsx b/components/editor/standard/StandardEditorLayout.tsx index e7a81e94..7bdeca78 100644 --- a/components/editor/standard/StandardEditorLayout.tsx +++ b/components/editor/standard/StandardEditorLayout.tsx @@ -252,10 +252,16 @@ export function StandardEditorLayout(props: StandardEditorLayoutProps) { }, [entityNavigationRef, navToNode]); // Mirror the active-tab selection into the shared store so cross-page chrome - // (e.g. the Viewer/Editor switcher) can synthesize the right ?Iri= key - // without having to know about local-state plumbing here. + // (e.g. the Viewer/Editor switcher, the side-page Back-to-project link via + // useProjectHomeHref) can synthesize the right ?Iri= key without having + // to know about local-state plumbing here. + // + // No unmount cleanup: an unmount-clear races with side-page navigation — + // the editor unmounts before settings/PRs/etc render, so reading the store + // there returns null and the back-link drops the selection. The next editor + // mount (same or different project) overwrites the store from its own URL + // params, so stale values are short-lived and harmless. const setSelection = useSelectionStore((s) => s.setSelection); - const clearSelection = useSelectionStore((s) => s.clear); useEffect(() => { if (activeTab === "classes") { setSelection(selectedIri ?? null, selectedIri ? "class" : null); @@ -265,7 +271,6 @@ export function StandardEditorLayout(props: StandardEditorLayoutProps) { setSelection(selectedIndividualIri ?? null, selectedIndividualIri ? "individual" : null); } }, [activeTab, selectedIri, selectedPropertyIri, selectedIndividualIri, setSelection]); - useEffect(() => () => clearSelection(), [clearSelection]); // Shared search state const { diff --git a/lib/hooks/useProjectHomeHref.ts b/lib/hooks/useProjectHomeHref.ts index 307fdcdc..886d4238 100644 --- a/lib/hooks/useProjectHomeHref.ts +++ b/lib/hooks/useProjectHomeHref.ts @@ -1,22 +1,32 @@ "use client"; import { useSession } from "next-auth/react"; +import { useSearchParams } from "next/navigation"; import { derivePermissions, useProject } from "@/lib/hooks/useProject"; import { useEditorModeStore } from "@/lib/stores/editorModeStore"; +import { useSelectionStore } from "@/lib/stores/selectionStore"; +import { + buildSelectionQuery, + readSelectionFromSearchParams, +} from "@/lib/utils/selectionUrl"; /** - * Resolve the URL that should land the user "back at the project" — either - * the read-only viewer or the editor, depending on the user's - * preferEditMode preference and their permissions on this project. + * Resolve the URL that should land the user "back at the project" — the + * viewer or the editor, carrying the active entity selection (?classIri= / + * ?propertyIri= / ?individualIri=) so cross-page round-trips don't lose the + * user's place. * - * Used by every "Back to project" link in side pages (settings, PRs, - * analytics, suggestions, dashboard) so that a user who has prefer-edit-mode - * enabled and the right permissions returns to the editor — not always to - * the viewer like the previous hard-coded `/projects/${projectId}` href did. + * Mode resolution, in order: + * 1. The `mode` field in useSelectionStore — set whenever the user is on + * the viewer or editor page. This makes Back-to-project semantically + * "go back where I just was," matching user expectation regardless of + * whether the user explicitly switched mode mid-session. + * 2. Fallback to the global preferEditMode preference (when the side page + * is the user's first project surface, e.g. via a deep link). * - * Read-only viewer-role users and unauthenticated visitors always get the - * viewer URL regardless of preference, so the affordance can never land - * someone where they have no rights. + * Permission gate: editor mode requires `canSuggest` — read-only viewer-role + * users and unauthenticated visitors are always routed to the viewer so the + * affordance can never land someone where they have no rights. */ export function useProjectHomeHref(projectId: string): string { const { data: session } = useSession(); @@ -24,7 +34,22 @@ export function useProjectHomeHref(projectId: string): string { const { canSuggest } = derivePermissions(project, session?.accessToken); const preferEditMode = useEditorModeStore((s) => s.preferEditMode); - return preferEditMode && canSuggest - ? `/projects/${projectId}/editor` - : `/projects/${projectId}`; + // Mirror ViewerEditorSwitcher: prefer the in-memory selection store + // (populated by the viewer/editor the user came from), fall back to URL + // params for first render or hard-deep-link cases. + const searchParams = useSearchParams(); + const storeIri = useSelectionStore((s) => s.iri); + const storeType = useSelectionStore((s) => s.type); + const storeMode = useSelectionStore((s) => s.mode); + const selection = + storeIri && storeType + ? { iri: storeIri, type: storeType } + : readSelectionFromSearchParams(searchParams); + + const wantsEditor = storeMode ? storeMode === "editor" : preferEditMode; + const base = + wantsEditor && canSuggest + ? `/projects/${projectId}/editor` + : `/projects/${projectId}`; + return `${base}${buildSelectionQuery(selection)}`; } diff --git a/lib/stores/selectionStore.ts b/lib/stores/selectionStore.ts index cd6fec3f..e96a9551 100644 --- a/lib/stores/selectionStore.ts +++ b/lib/stores/selectionStore.ts @@ -2,23 +2,37 @@ import { create } from "zustand"; import type { SelectableEntityType } from "@/lib/utils/selectionUrl"; +/** + * Which project surface the user is most recently on. Used by side-page + * Back-to-project links to route the user back where they came from instead + * of forcing them through the global preferEditMode preference. + */ +export type ProjectViewMode = "viewer" | "editor"; + interface SelectionState { /** IRI of the entity the user is currently focused on (whichever tab is active). */ iri: string | null; /** Entity type that resolves the punning ambiguity for {@link iri}. */ type: SelectableEntityType | null; + /** Most recent viewer/editor surface the user was on, or null on cold load. */ + mode: ProjectViewMode | null; setSelection: (iri: string | null, type: SelectableEntityType | null) => void; + setMode: (mode: ProjectViewMode) => void; clear: () => void; } /** - * In-session active-selection state shared between the entity-tab layouts and - * cross-page chrome (e.g. the Viewer/Editor switcher). Not persisted — on a - * full page reload, the URL search params drive initial state. + * In-session active-selection + viewer/editor mode state shared between + * project pages and cross-page chrome (Viewer/Editor switcher, side-page + * Back-to-project links via useProjectHomeHref). Not persisted — on a full + * page reload, the URL search params drive selection and the preferEditMode + * preference drives mode. */ export const useSelectionStore = create()((set) => ({ iri: null, type: null, + mode: null, setSelection: (iri, type) => set({ iri, type }), - clear: () => set({ iri: null, type: null }), + setMode: (mode) => set({ mode }), + clear: () => set({ iri: null, type: null, mode: null }), }));