Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down Expand Up @@ -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(
<DeveloperEditorLayout
{...defaultProps({ selectedIri: "http://example.org/Foo" })}
/>,
);
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(
<DeveloperEditorLayout
{...defaultProps({ selectedIri: "http://example.org/Foo" })}
/>,
);
unmount();
expect(useSelectionStore.getState().iri).toBe("http://example.org/Foo");
expect(useSelectionStore.getState().type).toBe("class");
});
});
});
30 changes: 30 additions & 0 deletions __tests__/components/editor/standard/StandardEditorLayout.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down Expand Up @@ -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(
<StandardEditorLayout
{...defaultProps({ selectedIri: "http://example.org/Foo" })}
/>,
);
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(
<StandardEditorLayout
{...defaultProps({ selectedIri: "http://example.org/Foo" })}
/>,
);
unmount();
expect(useSelectionStore.getState().iri).toBe("http://example.org/Foo");
expect(useSelectionStore.getState().type).toBe("class");
});
});
});
150 changes: 150 additions & 0 deletions __tests__/lib/hooks/useProjectHomeHref.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>).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<string, string>();
(globalThis as Record<string, unknown>).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}`);
});
});
18 changes: 17 additions & 1 deletion __tests__/lib/stores/selectionStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,16 +33,32 @@ 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", () => {
useSelectionStore.getState().setSelection(null, null);
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");
});
});
19 changes: 17 additions & 2 deletions app/projects/[id]/editor/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string | undefined>(undefined);
Expand Down Expand Up @@ -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[] => [
Expand Down
9 changes: 9 additions & 0 deletions app/projects/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down
13 changes: 9 additions & 4 deletions components/editor/developer/DeveloperEditorLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?<type>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 ?<type>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);
Expand All @@ -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 {
Expand Down
13 changes: 9 additions & 4 deletions components/editor/standard/StandardEditorLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?<type>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 ?<type>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);
Expand All @@ -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 {
Expand Down
Loading
Loading