From 66a0b4c9cf039b8272a4b9afc114582beeea3bd3 Mon Sep 17 00:00:00 2001 From: "John R. D'Orazio" Date: Sat, 11 Apr 2026 01:16:02 +0200 Subject: [PATCH 01/21] feat: preserve entity selection across viewer/editor modes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pass classIri query param when navigating between viewer and editor. Replace "Back" link with "Close Editor" button that preserves selection. Remove ContinuousEditingToggle — being in the editor now implies edit mode for the selected entity. Simplify ClassDetailPanel, PropertyDetailPanel, and IndividualDetailPanel edit state by auto-entering edit mode when onUpdate handler is provided (editor context). Closes #98 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../editor/ClassDetailPanel.test.tsx | 133 ++++------ .../editor/ContinuousEditingToggle.test.tsx | 60 ----- .../editor/IndividualDetailPanel.test.tsx | 240 +++++++++++++----- .../editor/PropertyDetailPanel.test.tsx | 233 ++++++++++++----- __tests__/lib/stores/editorModeStore.test.ts | 15 -- app/projects/[id]/editor/page.tsx | 10 +- app/projects/[id]/page.tsx | 2 +- app/settings/page.tsx | 45 +--- components/editor/ClassDetailPanel.tsx | 13 +- components/editor/ContinuousEditingToggle.tsx | 33 --- components/editor/IndividualDetailPanel.tsx | 6 +- components/editor/PropertyDetailPanel.tsx | 6 +- lib/stores/editorModeStore.ts | 4 - 13 files changed, 403 insertions(+), 397 deletions(-) delete mode 100644 __tests__/components/editor/ContinuousEditingToggle.test.tsx delete mode 100644 components/editor/ContinuousEditingToggle.tsx diff --git a/__tests__/components/editor/ClassDetailPanel.test.tsx b/__tests__/components/editor/ClassDetailPanel.test.tsx index 4fac8c7d..bd0043ab 100644 --- a/__tests__/components/editor/ClassDetailPanel.test.tsx +++ b/__tests__/components/editor/ClassDetailPanel.test.tsx @@ -50,7 +50,7 @@ vi.mock("@/lib/hooks/useAutoSave", () => ({ vi.mock("@/lib/stores/editorModeStore", () => ({ useEditorModeStore: (selector: (s: Record) => unknown) => - selector({ editorMode: "standard", continuousEditing: false, ...editorModeOverrides }), + selector({ editorMode: "standard", ...editorModeOverrides }), })); // Stub child components @@ -418,7 +418,7 @@ describe("ClassDetailPanel", () => { // ── Edit mode (canEdit=true) ── - it("shows Edit Item button when canEdit is true and onUpdateClass provided", async () => { + it("auto-enters edit mode when canEdit is true and onUpdateClass provided", async () => { const onUpdateClass = vi.fn(); render( { ); await waitFor(() => { - expect(screen.getByText("Edit Item")).toBeDefined(); + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); }); }); @@ -579,11 +579,9 @@ describe("ClassDetailPanel", () => { ); await waitFor(() => { - expect(screen.getByText("Edit Item")).not.toBeNull(); + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); }); - await user.click(screen.getByText("Edit Item")); - // In edit mode, label should be rendered in an input await waitFor(() => { const labelInput = screen.getByDisplayValue("Person"); @@ -606,9 +604,8 @@ describe("ClassDetailPanel", () => { ); await waitFor(() => { - expect(screen.getByText("Edit Item")).not.toBeNull(); + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); }); - await user.click(screen.getByText("Edit Item")); await waitFor(() => { expect(screen.getByDisplayValue("Person")).not.toBeNull(); @@ -633,9 +630,8 @@ describe("ClassDetailPanel", () => { ); await waitFor(() => { - expect(screen.getByText("Edit Item")).not.toBeNull(); + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); }); - await user.click(screen.getByText("Edit Item")); await waitFor(() => { expect(screen.getAllByTitle("Language tag (e.g. en, de, fr)").length).toBeGreaterThanOrEqual(1); @@ -664,9 +660,8 @@ describe("ClassDetailPanel", () => { ); await waitFor(() => { - expect(screen.getByText("Edit Item")).not.toBeNull(); + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); }); - await user.click(screen.getByText("Edit Item")); await waitFor(() => { // Comment value should appear in a textarea @@ -688,9 +683,8 @@ describe("ClassDetailPanel", () => { ); await waitFor(() => { - expect(screen.getByText("Edit Item")).not.toBeNull(); + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); }); - await user.click(screen.getByText("Edit Item")); await waitFor(() => { expect(screen.getByDisplayValue("A human being")).not.toBeNull(); @@ -715,9 +709,8 @@ describe("ClassDetailPanel", () => { ); await waitFor(() => { - expect(screen.getByText("Edit Item")).not.toBeNull(); + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); }); - await user.click(screen.getByText("Edit Item")); await waitFor(() => { expect(screen.getByDisplayValue("A human being")).not.toBeNull(); @@ -744,9 +737,8 @@ describe("ClassDetailPanel", () => { ); await waitFor(() => { - expect(screen.getByText("Edit Item")).not.toBeNull(); + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); }); - await user.click(screen.getByText("Edit Item")); await waitFor(() => { expect(screen.getByTitle("Remove parent")).not.toBeNull(); @@ -767,9 +759,8 @@ describe("ClassDetailPanel", () => { ); await waitFor(() => { - expect(screen.getByText("Edit Item")).not.toBeNull(); + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); }); - await user.click(screen.getByText("Edit Item")); await waitFor(() => { expect(screen.getByTitle("Remove parent")).not.toBeNull(); @@ -796,9 +787,8 @@ describe("ClassDetailPanel", () => { ); await waitFor(() => { - expect(screen.getByText("Edit Item")).not.toBeNull(); + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); }); - await user.click(screen.getByText("Edit Item")); await waitFor(() => { expect(screen.getByText("Add parent")).not.toBeNull(); @@ -827,9 +817,8 @@ describe("ClassDetailPanel", () => { ); await waitFor(() => { - expect(screen.getByText("Edit Item")).not.toBeNull(); + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); }); - await user.click(screen.getByText("Edit Item")); await waitFor(() => { expect(screen.getByDisplayValue("Person")).not.toBeNull(); @@ -857,9 +846,8 @@ describe("ClassDetailPanel", () => { ); await waitFor(() => { - expect(screen.getByText("Edit Item")).not.toBeNull(); + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); }); - await user.click(screen.getByText("Edit Item")); await waitFor(() => { expect(screen.getByDisplayValue("Person")).not.toBeNull(); @@ -923,9 +911,8 @@ describe("ClassDetailPanel", () => { ); await waitFor(() => { - expect(screen.getByText("Edit Item")).not.toBeNull(); + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); }); - await user.click(screen.getByText("Edit Item")); await waitFor(() => { const removeBtns = screen.getAllByTitle("Remove label"); @@ -953,9 +940,8 @@ describe("ClassDetailPanel", () => { ); await waitFor(() => { - expect(screen.getByText("Edit Item")).not.toBeNull(); + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); }); - await user.click(screen.getByText("Edit Item")); await waitFor(() => { expect(screen.getByDisplayValue("Personne")).not.toBeNull(); @@ -983,9 +969,8 @@ describe("ClassDetailPanel", () => { ); await waitFor(() => { - expect(screen.getByText("Edit Item")).not.toBeNull(); + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); }); - await user.click(screen.getByText("Edit Item")); await waitFor(() => { expect(screen.queryByText("Edit Item")).toBeNull(); @@ -1017,9 +1002,8 @@ describe("ClassDetailPanel", () => { ); await waitFor(() => { - expect(screen.getByText("Edit Item")).not.toBeNull(); + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); }); - await user.click(screen.getByText("Edit Item")); // Definition section title should be visible await waitFor(() => { @@ -1208,9 +1192,8 @@ describe("ClassDetailPanel", () => { ); await waitFor(() => { - expect(screen.getByText("Edit Item")).not.toBeNull(); + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); }); - await user.click(screen.getByText("Edit Item")); await waitFor(() => { // Should have an empty label input placeholder @@ -1234,9 +1217,8 @@ describe("ClassDetailPanel", () => { ); await waitFor(() => { - expect(screen.getByText("Edit Item")).not.toBeNull(); + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); }); - await user.click(screen.getByText("Edit Item")); await waitFor(() => { // The comment "A human being" + a trailing empty ghost row @@ -1295,9 +1277,8 @@ describe("ClassDetailPanel", () => { ); await waitFor(() => { - expect(screen.getByText("Edit Item")).not.toBeNull(); + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); }); - await user.click(screen.getByText("Edit Item")); // The "Relationship(s)" section should appear in edit mode await waitFor(() => { @@ -1345,9 +1326,8 @@ describe("ClassDetailPanel", () => { ); await waitFor(() => { - expect(screen.getByText("Edit Item")).not.toBeNull(); + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); }); - await user.click(screen.getByText("Edit Item")); await waitFor(() => { expect(screen.getByText("Definition")).not.toBeNull(); @@ -1519,9 +1499,8 @@ describe("ClassDetailPanel", () => { ); await waitFor(() => { - expect(screen.getByText("Edit Item")).not.toBeNull(); + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); }); - await user.click(screen.getByText("Edit Item")); await waitFor(() => { expect(screen.getByText("Relationship(s)")).not.toBeNull(); @@ -1560,9 +1539,8 @@ describe("ClassDetailPanel", () => { ); await waitFor(() => { - expect(screen.getByText("Edit Item")).not.toBeNull(); + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); }); - await user.click(screen.getByText("Edit Item")); await waitFor(() => { expect(screen.getByDisplayValue("A human being")).not.toBeNull(); @@ -1607,9 +1585,8 @@ describe("ClassDetailPanel", () => { ); await waitFor(() => { - expect(screen.getByText("Edit Item")).not.toBeNull(); + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); }); - await user.click(screen.getByText("Edit Item")); // Definition section should be visible with values in annotation rows (mocked to null but section should render) await waitFor(() => { @@ -1715,9 +1692,8 @@ describe("ClassDetailPanel", () => { ); await waitFor(() => { - expect(screen.getByText("Edit Item")).not.toBeNull(); + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); }); - await user.click(screen.getByText("Edit Item")); await waitFor(() => { expect(screen.getByTestId("manual-save")).not.toBeNull(); @@ -1734,10 +1710,9 @@ describe("ClassDetailPanel", () => { expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); }); - // ── Continuous editing auto-enter ── + // ── Auto-enter edit mode in editor context ── - it("auto-enters edit mode when continuousEditing is true", async () => { - editorModeOverrides = { continuousEditing: true }; + it("auto-enters edit mode when onUpdateClass is provided (editor context)", async () => { const onUpdateClass = vi.fn(); render( { }); }); - // ── Continuous editing does not auto-enter after cancel ── + // ── Does not auto-enter after cancel ── - it("does not re-enter edit mode after cancel even with continuousEditing", async () => { - editorModeOverrides = { continuousEditing: true }; + it("does not re-enter edit mode after cancel in editor context", async () => { const user = userEvent.setup(); const onUpdateClass = vi.fn(); render( @@ -1860,9 +1834,8 @@ describe("ClassDetailPanel", () => { ); await waitFor(() => { - expect(screen.getByText("Edit Item")).not.toBeNull(); + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); }); - await user.click(screen.getByText("Edit Item")); await waitFor(() => { expect(capturedInlineAnnotationAdderProps).not.toBeNull(); @@ -1894,9 +1867,8 @@ describe("ClassDetailPanel", () => { ); await waitFor(() => { - expect(screen.getByText("Edit Item")).not.toBeNull(); + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); }); - await user.click(screen.getByText("Edit Item")); await waitFor(() => { expect(capturedInlineAnnotationAdderProps).not.toBeNull(); @@ -1935,9 +1907,8 @@ describe("ClassDetailPanel", () => { ); await waitFor(() => { - expect(screen.getByText("Edit Item")).not.toBeNull(); + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); }); - await user.click(screen.getByText("Edit Item")); await waitFor(() => { expect(capturedRelationshipSectionProps).not.toBeNull(); @@ -1974,9 +1945,8 @@ describe("ClassDetailPanel", () => { ); await waitFor(() => { - expect(screen.getByText("Edit Item")).not.toBeNull(); + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); }); - await user.click(screen.getByText("Edit Item")); await waitFor(() => { expect(capturedRelationshipSectionProps).not.toBeNull(); @@ -2012,9 +1982,8 @@ describe("ClassDetailPanel", () => { ); await waitFor(() => { - expect(screen.getByText("Edit Item")).not.toBeNull(); + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); }); - await user.click(screen.getByText("Edit Item")); await waitFor(() => { expect(capturedRelationshipSectionProps).not.toBeNull(); @@ -2039,9 +2008,8 @@ describe("ClassDetailPanel", () => { ); await waitFor(() => { - expect(screen.getByText("Edit Item")).not.toBeNull(); + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); }); - await user.click(screen.getByText("Edit Item")); await waitFor(() => { expect(capturedRelationshipSectionProps).not.toBeNull(); @@ -2082,9 +2050,8 @@ describe("ClassDetailPanel", () => { ); await waitFor(() => { - expect(screen.getByText("Edit Item")).not.toBeNull(); + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); }); - await user.click(screen.getByText("Edit Item")); await waitFor(() => { expect(capturedRelationshipSectionProps).not.toBeNull(); @@ -2121,9 +2088,8 @@ describe("ClassDetailPanel", () => { ); await waitFor(() => { - expect(screen.getByText("Edit Item")).not.toBeNull(); + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); }); - await user.click(screen.getByText("Edit Item")); await waitFor(() => { const annRow = capturedAnnotationRowProps.find( @@ -2173,9 +2139,8 @@ describe("ClassDetailPanel", () => { ); await waitFor(() => { - expect(screen.getByText("Edit Item")).not.toBeNull(); + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); }); - await user.click(screen.getByText("Edit Item")); await waitFor(() => { const annRows = capturedAnnotationRowProps.filter( @@ -2222,9 +2187,8 @@ describe("ClassDetailPanel", () => { ); await waitFor(() => { - expect(screen.getByText("Edit Item")).not.toBeNull(); + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); }); - await user.click(screen.getByText("Edit Item")); await waitFor(() => { const defRow = capturedAnnotationRowProps.find( @@ -2273,9 +2237,8 @@ describe("ClassDetailPanel", () => { ); await waitFor(() => { - expect(screen.getByText("Edit Item")).not.toBeNull(); + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); }); - await user.click(screen.getByText("Edit Item")); await waitFor(() => { const defRow = capturedAnnotationRowProps.find( @@ -2294,10 +2257,9 @@ describe("ClassDetailPanel", () => { // ── Does not auto-enter edit mode when canEdit is false ── - it("does not auto-enter edit mode when canEdit is false even with continuousEditing", async () => { - editorModeOverrides = { continuousEditing: true }; + it("does not auto-enter edit mode when canEdit is false even with onUpdateClass", async () => { render( - + ); await waitFor(() => { @@ -2367,9 +2329,8 @@ describe("ClassDetailPanel", () => { ); await waitFor(() => { - expect(screen.getByText("Edit Item")).not.toBeNull(); + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); }); - await user.click(screen.getByText("Edit Item")); await waitFor(() => { const annRow = capturedAnnotationRowProps.find( @@ -2421,9 +2382,8 @@ describe("ClassDetailPanel", () => { ); await waitFor(() => { - expect(screen.getByText("Edit Item")).not.toBeNull(); + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); }); - await user.click(screen.getByText("Edit Item")); await waitFor(() => { const defRow = capturedAnnotationRowProps.find( @@ -2469,9 +2429,8 @@ describe("ClassDetailPanel", () => { ); await waitFor(() => { - expect(screen.getByText("Edit Item")).not.toBeNull(); + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); }); - await user.click(screen.getByText("Edit Item")); await waitFor(() => { const annRow = capturedAnnotationRowProps.find( diff --git a/__tests__/components/editor/ContinuousEditingToggle.test.tsx b/__tests__/components/editor/ContinuousEditingToggle.test.tsx deleted file mode 100644 index a0bf6052..00000000 --- a/__tests__/components/editor/ContinuousEditingToggle.test.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { describe, expect, it, vi, beforeEach } from "vitest"; -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; - -const mockSetContinuousEditing = vi.fn(); -let mockContinuousEditing = false; - -vi.mock("@/lib/stores/editorModeStore", () => ({ - useEditorModeStore: (selector: (state: Record) => unknown) => - selector({ - continuousEditing: mockContinuousEditing, - setContinuousEditing: mockSetContinuousEditing, - }), -})); - -import { ContinuousEditingToggle } from "@/components/editor/ContinuousEditingToggle"; - -describe("ContinuousEditingToggle", () => { - beforeEach(() => { - mockContinuousEditing = false; - mockSetContinuousEditing.mockClear(); - }); - - it("renders the toggle button", () => { - render(); - expect(screen.getByRole("button")).toBeDefined(); - }); - - it("shows aria-pressed false when continuous editing is off", () => { - render(); - expect(screen.getByRole("button").getAttribute("aria-pressed")).toBe("false"); - }); - - it("shows aria-pressed true when continuous editing is on", () => { - mockContinuousEditing = true; - render(); - expect(screen.getByRole("button").getAttribute("aria-pressed")).toBe("true"); - }); - - it("toggles continuous editing on click", async () => { - render(); - await userEvent.click(screen.getByRole("button")); - expect(mockSetContinuousEditing).toHaveBeenCalledWith(true); - }); - - it("has correct aria-label when off", () => { - render(); - expect(screen.getByRole("button").getAttribute("aria-label")).toBe( - "Continuous editing OFF \u2014 classes open read-only" - ); - }); - - it("has correct aria-label when on", () => { - mockContinuousEditing = true; - render(); - expect(screen.getByRole("button").getAttribute("aria-label")).toBe( - "Continuous editing ON \u2014 classes open in edit mode" - ); - }); -}); diff --git a/__tests__/components/editor/IndividualDetailPanel.test.tsx b/__tests__/components/editor/IndividualDetailPanel.test.tsx index 566f571c..bc037d44 100644 --- a/__tests__/components/editor/IndividualDetailPanel.test.tsx +++ b/__tests__/components/editor/IndividualDetailPanel.test.tsx @@ -39,7 +39,7 @@ vi.mock("@/lib/hooks/useEntityAutoSave", () => ({ vi.mock("@/lib/stores/editorModeStore", () => ({ useEditorModeStore: (selector: (s: Record) => unknown) => - selector({ editorMode: "standard", continuousEditing: false, ...editorModeOverrides }), + selector({ editorMode: "standard", ...editorModeOverrides }), })); vi.mock("@/lib/hooks/useIriLabels", () => ({ @@ -352,7 +352,7 @@ describe("IndividualDetailPanel", () => { // ── Edit mode (canEdit=true) ── - it("shows Edit Item button when canEdit is true and onUpdateIndividual provided", () => { + it("auto-enters edit mode when canEdit is true and onUpdateIndividual provided", async () => { const onUpdateIndividual = vi.fn(); render( { onUpdateIndividual={onUpdateIndividual} /> ); - expect(screen.getByText("Edit Item")).toBeDefined(); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); }); it("enters edit mode when Edit Item is clicked", async () => { @@ -374,7 +376,9 @@ describe("IndividualDetailPanel", () => { onUpdateIndividual={onUpdateIndividual} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); expect(screen.getByTestId("auto-save-bar")).toBeDefined(); }); @@ -443,7 +447,9 @@ describe("IndividualDetailPanel", () => { onUpdateIndividual={onUpdateIndividual} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); const labelInputs = screen.getAllByPlaceholderText("Label text"); expect(labelInputs.length).toBeGreaterThanOrEqual(1); }); @@ -458,7 +464,9 @@ describe("IndividualDetailPanel", () => { onUpdateIndividual={onUpdateIndividual} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); expect(screen.getByText("Comment(s)")).not.toBeNull(); }); @@ -472,7 +480,9 @@ describe("IndividualDetailPanel", () => { onUpdateIndividual={onUpdateIndividual} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); expect(screen.getByText("Definition")).not.toBeNull(); }); @@ -486,7 +496,9 @@ describe("IndividualDetailPanel", () => { onUpdateIndividual={onUpdateIndividual} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); expect(screen.getByText("Type(s)")).not.toBeNull(); }); @@ -500,7 +512,9 @@ describe("IndividualDetailPanel", () => { onUpdateIndividual={onUpdateIndividual} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); expect(screen.getByText("Same As")).not.toBeNull(); }); @@ -514,7 +528,9 @@ describe("IndividualDetailPanel", () => { onUpdateIndividual={onUpdateIndividual} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); expect(screen.getByText("Different From")).not.toBeNull(); }); @@ -528,7 +544,9 @@ describe("IndividualDetailPanel", () => { onUpdateIndividual={onUpdateIndividual} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); expect(screen.getByText("Object Properties")).not.toBeNull(); }); @@ -542,7 +560,9 @@ describe("IndividualDetailPanel", () => { onUpdateIndividual={onUpdateIndividual} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); expect(screen.getByText("Data Properties")).not.toBeNull(); }); @@ -556,7 +576,9 @@ describe("IndividualDetailPanel", () => { onUpdateIndividual={onUpdateIndividual} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); expect(screen.getByText("Annotations")).not.toBeNull(); }); @@ -570,7 +592,9 @@ describe("IndividualDetailPanel", () => { onUpdateIndividual={onUpdateIndividual} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); expect(screen.getByText("Relationships")).not.toBeNull(); }); @@ -586,7 +610,9 @@ describe("IndividualDetailPanel", () => { onUpdateIndividual={onUpdateIndividual} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); const labelInput = screen.getAllByPlaceholderText("Label text")[0]; await user.clear(labelInput); await user.type(labelInput, "Jane Doe"); @@ -605,14 +631,15 @@ describe("IndividualDetailPanel", () => { onUpdateIndividual={onUpdateIndividual} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); }); - // ── Continuous editing auto-entry ── + // ── Auto-enter edit mode in editor context ── - it("auto-enters edit mode when continuousEditing is true", async () => { - editorModeOverrides = { continuousEditing: true }; + it("auto-enters edit mode when onUpdateIndividual is provided (editor context)", async () => { const onUpdateIndividual = vi.fn(); render( { // ── Does not auto-enter edit mode when canEdit is false ── - it("does not auto-enter edit mode when canEdit is false even with continuousEditing", () => { - editorModeOverrides = { continuousEditing: true }; + it("does not auto-enter edit mode when canEdit is false even with onUpdateIndividual", () => { render( - + ); expect(screen.queryByTestId("auto-save-bar")).toBeNull(); }); @@ -709,7 +735,9 @@ describe("IndividualDetailPanel", () => { onUpdateIndividual={onUpdateIndividual} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); const langInputs = container.querySelectorAll('input[title="Language tag"]'); expect(langInputs.length).toBeGreaterThanOrEqual(1); }); @@ -726,7 +754,9 @@ describe("IndividualDetailPanel", () => { onUpdateIndividual={onUpdateIndividual} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); const labelInput = screen.getAllByPlaceholderText("Label text")[0]; await user.click(labelInput); await user.tab(); @@ -749,7 +779,7 @@ describe("IndividualDetailPanel", () => { // ── Does not enter edit mode on draft restoration when entityType mismatches ── - it("does not restore draft when entityType does not match individual", () => { + it("does not restore draft when entityType does not match individual", async () => { autoSaveOverrides = { restoredDraft: { entityType: "class", @@ -765,7 +795,12 @@ describe("IndividualDetailPanel", () => { onUpdateIndividual={onUpdateIndividual} /> ); - expect(screen.queryByTestId("auto-save-bar")).toBeNull(); + // Edit mode auto-enters (editor context), but the wrong-type draft should not be applied + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); + // The "Wrong type" label from the class draft should not appear + expect(screen.queryByDisplayValue("Wrong type")).toBeNull(); }); // ── Multiple labels in read-only ── @@ -849,7 +884,9 @@ describe("IndividualDetailPanel", () => { onUpdateIndividual={onUpdateIndividual} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); const removeButtons = container.querySelectorAll('button[title="Remove"]'); expect(removeButtons.length).toBeGreaterThanOrEqual(1); }); @@ -866,7 +903,9 @@ describe("IndividualDetailPanel", () => { onUpdateIndividual={onUpdateIndividual} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); const langInput = container.querySelector('input[title="Language tag"]') as HTMLInputElement; expect(langInput).not.toBeNull(); await user.clear(langInput); @@ -889,7 +928,9 @@ describe("IndividualDetailPanel", () => { onUpdateIndividual={onUpdateIndividual} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); expect(screen.getByText("Comment(s)")).not.toBeNull(); }); @@ -908,7 +949,9 @@ describe("IndividualDetailPanel", () => { onUpdateIndividual={onUpdateIndividual} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); expect(screen.getByText("Definition")).not.toBeNull(); }); @@ -927,7 +970,9 @@ describe("IndividualDetailPanel", () => { onUpdateIndividual={onUpdateIndividual} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); expect(screen.getByText("Type(s)")).not.toBeNull(); }); @@ -946,7 +991,9 @@ describe("IndividualDetailPanel", () => { onUpdateIndividual={onUpdateIndividual} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); expect(screen.getByText("Same As")).not.toBeNull(); }); @@ -965,7 +1012,9 @@ describe("IndividualDetailPanel", () => { onUpdateIndividual={onUpdateIndividual} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); expect(screen.getByText("Different From")).not.toBeNull(); }); @@ -1129,7 +1178,9 @@ describe("IndividualDetailPanel", () => { onUpdateIndividual={onUpdateIndividual} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); expect(capturedAutoSaveBarProps).not.toBeNull(); const onCancel = capturedAutoSaveBarProps!.onCancel as () => void; onCancel(); @@ -1148,7 +1199,9 @@ describe("IndividualDetailPanel", () => { onUpdateIndividual={onUpdateIndividual} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); expect(capturedAutoSaveBarProps).not.toBeNull(); const onManualSave = capturedAutoSaveBarProps!.onManualSave as () => Promise; await onManualSave(); @@ -1168,7 +1221,9 @@ describe("IndividualDetailPanel", () => { onUpdateIndividual={onUpdateIndividual} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); expect(capturedAutoSaveBarProps).not.toBeNull(); const onRetry = capturedAutoSaveBarProps!.onRetry as () => void; onRetry(); @@ -1187,7 +1242,9 @@ describe("IndividualDetailPanel", () => { onUpdateIndividual={onUpdateIndividual} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); const commentRow = capturedAnnotationRowProps.find( (p) => p.propertyIri === "http://www.w3.org/2000/01/rdf-schema#comment" ); @@ -1225,7 +1282,9 @@ describe("IndividualDetailPanel", () => { onUpdateIndividual={onUpdateIndividual} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); const defRow = capturedAnnotationRowProps.find( (p) => p.propertyIri === "http://www.w3.org/2004/02/skos/core#definition" ); @@ -1253,7 +1312,9 @@ describe("IndividualDetailPanel", () => { onUpdateIndividual={onUpdateIndividual} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); const commentRow = capturedAnnotationRowProps.find( (p) => p.propertyIri === "http://www.w3.org/2000/01/rdf-schema#comment" ); @@ -1283,7 +1344,9 @@ describe("IndividualDetailPanel", () => { onUpdateIndividual={onUpdateIndividual} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); const commentRows = capturedAnnotationRowProps.filter( (p) => p.propertyIri === "http://www.w3.org/2000/01/rdf-schema#comment" ); @@ -1317,7 +1380,9 @@ describe("IndividualDetailPanel", () => { onUpdateIndividual={onUpdateIndividual} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); const defRows = capturedAnnotationRowProps.filter( (p) => p.propertyIri === "http://www.w3.org/2004/02/skos/core#definition" ); @@ -1343,7 +1408,9 @@ describe("IndividualDetailPanel", () => { onUpdateIndividual={onUpdateIndividual} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); const objectSection = capturedPropertyAssertionProps.find( (p) => p.assertionType === "object" ); @@ -1375,7 +1442,9 @@ describe("IndividualDetailPanel", () => { onUpdateIndividual={onUpdateIndividual} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); const objectSection = capturedPropertyAssertionProps.find( (p) => p.assertionType === "object" ); @@ -1397,7 +1466,9 @@ describe("IndividualDetailPanel", () => { onUpdateIndividual={onUpdateIndividual} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); const dataSection = capturedPropertyAssertionProps.find( (p) => p.assertionType === "data" ); @@ -1429,7 +1500,9 @@ describe("IndividualDetailPanel", () => { onUpdateIndividual={onUpdateIndividual} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); const dataSection = capturedPropertyAssertionProps.find( (p) => p.assertionType === "data" ); @@ -1458,7 +1531,9 @@ describe("IndividualDetailPanel", () => { onUpdateIndividual={onUpdateIndividual} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); expect(capturedRelationshipSectionProps).not.toBeNull(); const onAddTarget = capturedRelationshipSectionProps!.onAddTarget as (groupIdx: number, target: { iri: string; label: string }) => void; onAddTarget(0, { iri: "http://example.org/ontology#newRelated", label: "newRelated" }); @@ -1482,7 +1557,9 @@ describe("IndividualDetailPanel", () => { onUpdateIndividual={onUpdateIndividual} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); expect(capturedRelationshipSectionProps).not.toBeNull(); const onRemoveTarget = capturedRelationshipSectionProps!.onRemoveTarget as (groupIdx: number, targetIdx: number) => void; onRemoveTarget(0, 0); @@ -1501,7 +1578,9 @@ describe("IndividualDetailPanel", () => { onUpdateIndividual={onUpdateIndividual} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); expect(capturedRelationshipSectionProps).not.toBeNull(); const onChangeProperty = capturedRelationshipSectionProps!.onChangeProperty as (groupIdx: number, newIri: string, newLabel: string) => void; onChangeProperty(0, "http://www.w3.org/2000/01/rdf-schema#isDefinedBy", "Defined By"); @@ -1520,7 +1599,9 @@ describe("IndividualDetailPanel", () => { onUpdateIndividual={onUpdateIndividual} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); expect(capturedRelationshipSectionProps).not.toBeNull(); const groupsBefore = (capturedRelationshipSectionProps!.groups as unknown[]).length; const onAddGroup = capturedRelationshipSectionProps!.onAddGroup as () => void; @@ -1554,7 +1635,9 @@ describe("IndividualDetailPanel", () => { onUpdateIndividual={onUpdateIndividual} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); const annRow = capturedAnnotationRowProps.find( (p) => p.propertyIri === "http://www.w3.org/2004/02/skos/core#prefLabel" ); @@ -1603,7 +1686,9 @@ describe("IndividualDetailPanel", () => { onUpdateIndividual={onUpdateIndividual} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); const annRows = capturedAnnotationRowProps.filter( (p) => p.propertyIri === "http://www.w3.org/2004/02/skos/core#prefLabel" ); @@ -1635,7 +1720,9 @@ describe("IndividualDetailPanel", () => { onUpdateIndividual={onUpdateIndividual} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); expect(capturedRelationshipSectionProps).not.toBeNull(); expect(capturedRelationshipSectionProps!.isEditing).toBe(true); }); @@ -1660,7 +1747,9 @@ describe("IndividualDetailPanel", () => { onUpdateIndividual={onUpdateIndividual} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); const removeButtons = container.querySelectorAll('button[title="Remove"]'); expect(removeButtons.length).toBeGreaterThanOrEqual(1); await user.click(removeButtons[0]); @@ -1698,7 +1787,9 @@ describe("IndividualDetailPanel", () => { onUpdateIndividual={onUpdateIndividual} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); const objectSection = capturedPropertyAssertionProps.find( (p) => p.assertionType === "object" ); @@ -1721,7 +1812,9 @@ describe("IndividualDetailPanel", () => { onUpdateIndividual={onUpdateIndividual} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); expect(capturedAutoSaveBarProps).not.toBeNull(); const onManualSave = capturedAutoSaveBarProps!.onManualSave as () => Promise; await onManualSave(); @@ -1754,7 +1847,9 @@ describe("IndividualDetailPanel", () => { onUpdateIndividual={onUpdateIndividual} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); await waitFor(() => { expect(capturedInlineAnnotationAdderProps).not.toBeNull(); @@ -1786,7 +1881,9 @@ describe("IndividualDetailPanel", () => { onUpdateIndividual={onUpdateIndividual} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); await waitFor(() => { expect(capturedInlineAnnotationAdderProps).not.toBeNull(); @@ -1815,7 +1912,9 @@ describe("IndividualDetailPanel", () => { onUpdateIndividual={onUpdateIndividual} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); await waitFor(() => { expect(capturedInlineAnnotationAdderProps).not.toBeNull(); @@ -1838,7 +1937,9 @@ describe("IndividualDetailPanel", () => { onUpdateIndividual={onUpdateIndividual} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); await waitFor(() => { expect(capturedRelationshipSectionProps).not.toBeNull(); @@ -1856,10 +1957,9 @@ describe("IndividualDetailPanel", () => { expect(screen.getByText("Label(s)")).not.toBeNull(); }); - // ── Cancel after continuous editing prevents re-entry ── + // ── Cancel in editor context prevents re-entry ── - it("does not re-enter edit mode after cancel even with continuousEditing", async () => { - editorModeOverrides = { continuousEditing: true }; + it("does not re-enter edit mode after cancel in editor context", async () => { const onUpdateIndividual = vi.fn(); render( { onUpdateIndividual={onUpdateIndividual} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); expect(capturedRelationshipSectionProps).not.toBeNull(); const groups = capturedRelationshipSectionProps!.groups as Array<{ property_label: string }>; const definedByGroup = groups.find((g) => g.property_label === "Defined By"); @@ -1940,7 +2042,9 @@ describe("IndividualDetailPanel", () => { onUpdateIndividual={onUpdateIndividual} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); const labelInputs = screen.getAllByPlaceholderText("Label text"); expect(labelInputs.length).toBeGreaterThanOrEqual(1); expect((labelInputs[0] as HTMLInputElement).value).toBe(""); @@ -1977,7 +2081,9 @@ describe("IndividualDetailPanel", () => { onUpdateIndividual={onUpdateIndividual} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); const dataSection = capturedPropertyAssertionProps.find( (p) => p.assertionType === "data" ); @@ -2009,7 +2115,9 @@ describe("IndividualDetailPanel", () => { onUpdateIndividual={onUpdateIndividual} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); const annRow = capturedAnnotationRowProps.find( (p) => p.propertyIri === "http://www.w3.org/2004/02/skos/core#prefLabel" ); diff --git a/__tests__/components/editor/PropertyDetailPanel.test.tsx b/__tests__/components/editor/PropertyDetailPanel.test.tsx index a880e140..5d22d851 100644 --- a/__tests__/components/editor/PropertyDetailPanel.test.tsx +++ b/__tests__/components/editor/PropertyDetailPanel.test.tsx @@ -48,7 +48,7 @@ vi.mock("@/lib/hooks/useEntityAutoSave", () => ({ vi.mock("@/lib/stores/editorModeStore", () => ({ useEditorModeStore: (selector: (s: Record) => unknown) => - selector({ editorMode: "standard", continuousEditing: false, ...editorModeOverrides }), + selector({ editorMode: "standard", ...editorModeOverrides }), })); vi.mock("@/lib/hooks/useIriLabels", () => ({ @@ -367,7 +367,7 @@ describe("PropertyDetailPanel", () => { // ── Edit mode (canEdit=true) ── - it("shows Edit Item button when canEdit is true and onUpdateProperty provided", () => { + it("auto-enters edit mode when canEdit is true and onUpdateProperty provided", async () => { const onUpdateProperty = vi.fn(); render( { onUpdateProperty={onUpdateProperty} /> ); - expect(screen.getByText("Edit Item")).toBeDefined(); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); }); it("enters edit mode when Edit Item is clicked", async () => { @@ -389,7 +391,9 @@ describe("PropertyDetailPanel", () => { onUpdateProperty={onUpdateProperty} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); // Auto-save bar should appear expect(screen.getByTestId("auto-save-bar")).toBeDefined(); }); @@ -460,7 +464,9 @@ describe("PropertyDetailPanel", () => { onUpdateProperty={onUpdateProperty} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); const labelInputs = screen.getAllByPlaceholderText("Label text"); expect(labelInputs.length).toBeGreaterThanOrEqual(1); }); @@ -475,7 +481,9 @@ describe("PropertyDetailPanel", () => { onUpdateProperty={onUpdateProperty} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); // The comment section title should be visible expect(screen.getByText("Comment(s)")).not.toBeNull(); }); @@ -490,7 +498,9 @@ describe("PropertyDetailPanel", () => { onUpdateProperty={onUpdateProperty} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); expect(screen.getByText("Definition")).not.toBeNull(); }); @@ -504,7 +514,9 @@ describe("PropertyDetailPanel", () => { onUpdateProperty={onUpdateProperty} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); expect(screen.getByText("Domain")).not.toBeNull(); }); @@ -518,7 +530,9 @@ describe("PropertyDetailPanel", () => { onUpdateProperty={onUpdateProperty} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); expect(screen.getByText("Range")).not.toBeNull(); }); @@ -532,7 +546,9 @@ describe("PropertyDetailPanel", () => { onUpdateProperty={onUpdateProperty} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); expect(screen.getByText("Functional")).not.toBeNull(); expect(screen.getByText("Transitive")).not.toBeNull(); expect(screen.getByText("Symmetric")).not.toBeNull(); @@ -548,7 +564,9 @@ describe("PropertyDetailPanel", () => { onUpdateProperty={onUpdateProperty} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); expect(screen.getByText("Inverse Of")).not.toBeNull(); }); @@ -562,7 +580,9 @@ describe("PropertyDetailPanel", () => { onUpdateProperty={onUpdateProperty} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); expect(screen.getByText("Annotations")).not.toBeNull(); }); @@ -576,7 +596,9 @@ describe("PropertyDetailPanel", () => { onUpdateProperty={onUpdateProperty} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); expect(screen.getByText("Relationships")).not.toBeNull(); }); @@ -592,7 +614,9 @@ describe("PropertyDetailPanel", () => { onUpdateProperty={onUpdateProperty} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); const labelInput = screen.getAllByPlaceholderText("Label text")[0]; await user.clear(labelInput); await user.type(labelInput, "newLabel"); @@ -611,7 +635,9 @@ describe("PropertyDetailPanel", () => { onUpdateProperty={onUpdateProperty} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); // Find the "Functional" checkbox (unchecked by default since not in characteristics) const functionalCheckbox = container.querySelector( 'input[type="checkbox"]' @@ -639,7 +665,9 @@ describe("PropertyDetailPanel", () => { onUpdateProperty={onUpdateProperty} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); // Verify we are in edit mode expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); @@ -656,10 +684,9 @@ describe("PropertyDetailPanel", () => { }); }); - // ── Continuous editing auto-entry ── + // ── Auto-enter edit mode in editor context ── - it("auto-enters edit mode when continuousEditing is true", () => { - editorModeOverrides = { continuousEditing: true }; + it("auto-enters edit mode when onUpdateProperty is provided (editor context)", () => { const onUpdateProperty = vi.fn(); render( { // ── Does not auto-enter edit mode when canEdit is false ── - it("does not auto-enter edit mode when canEdit is false even with continuousEditing", () => { - editorModeOverrides = { continuousEditing: true }; + it("does not auto-enter edit mode when canEdit is false even with onUpdateProperty", () => { render( - + ); expect(screen.queryByTestId("auto-save-bar")).toBeNull(); }); @@ -786,7 +812,9 @@ describe("PropertyDetailPanel", () => { onUpdateProperty={onUpdateProperty} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); const langInputs = container.querySelectorAll('input[title="Language tag"]'); expect(langInputs.length).toBeGreaterThanOrEqual(1); }); @@ -803,7 +831,9 @@ describe("PropertyDetailPanel", () => { onUpdateProperty={onUpdateProperty} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); const labelInput = screen.getAllByPlaceholderText("Label text")[0]; await user.click(labelInput); await user.tab(); @@ -836,13 +866,15 @@ describe("PropertyDetailPanel", () => { onUpdateProperty={onUpdateProperty} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); expect(screen.getByText("Parent Properties")).not.toBeNull(); }); // ── Does not enter edit mode on draft restoration when entityType mismatches ── - it("does not restore draft when entityType does not match property", () => { + it("does not restore draft when entityType does not match property", async () => { autoSaveOverrides = { restoredDraft: { entityType: "class", @@ -858,8 +890,12 @@ describe("PropertyDetailPanel", () => { onUpdateProperty={onUpdateProperty} /> ); - // Should not auto-enter edit mode since entityType is "class" not "property" - expect(screen.queryByTestId("auto-save-bar")).toBeNull(); + // Edit mode auto-enters (editor context), but the wrong-type draft should not be applied + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); + // The "Wrong type" label from the class draft should not appear + expect(screen.queryByDisplayValue("Wrong type")).toBeNull(); }); // ── Remove label button in edit mode ── @@ -882,7 +918,9 @@ describe("PropertyDetailPanel", () => { onUpdateProperty={onUpdateProperty} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); const removeButtons = container.querySelectorAll('button[title="Remove"]'); expect(removeButtons.length).toBeGreaterThanOrEqual(1); }); @@ -899,7 +937,9 @@ describe("PropertyDetailPanel", () => { onUpdateProperty={onUpdateProperty} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); const langInput = container.querySelector('input[title="Language tag"]') as HTMLInputElement; expect(langInput).not.toBeNull(); await user.clear(langInput); @@ -922,7 +962,9 @@ describe("PropertyDetailPanel", () => { onUpdateProperty={onUpdateProperty} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); // Comment section should still render in edit mode expect(screen.getByText("Comment(s)")).not.toBeNull(); }); @@ -942,7 +984,9 @@ describe("PropertyDetailPanel", () => { onUpdateProperty={onUpdateProperty} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); expect(screen.getByText("Definition")).not.toBeNull(); }); @@ -961,7 +1005,9 @@ describe("PropertyDetailPanel", () => { onUpdateProperty={onUpdateProperty} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); expect(screen.getByText("Domain")).not.toBeNull(); }); @@ -978,7 +1024,9 @@ describe("PropertyDetailPanel", () => { onUpdateProperty={onUpdateProperty} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); expect(screen.getByText("Range")).not.toBeNull(); }); @@ -997,7 +1045,9 @@ describe("PropertyDetailPanel", () => { onUpdateProperty={onUpdateProperty} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); expect(screen.getByText("Parent Properties")).not.toBeNull(); }); @@ -1016,7 +1066,9 @@ describe("PropertyDetailPanel", () => { onUpdateProperty={onUpdateProperty} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); expect(screen.getByText("Inverse Of")).not.toBeNull(); }); @@ -1035,7 +1087,9 @@ describe("PropertyDetailPanel", () => { onUpdateProperty={onUpdateProperty} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); const checkboxes = container.querySelectorAll('input[type="checkbox"]'); // Should have 7 characteristic checkboxes expect(checkboxes.length).toBe(7); @@ -1109,7 +1163,9 @@ describe("PropertyDetailPanel", () => { onUpdateProperty={onUpdateProperty} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); // Click the "Functional" checkbox const functionalLabel = screen.getByText("Functional"); await user.click(functionalLabel); @@ -1174,7 +1230,9 @@ describe("PropertyDetailPanel", () => { onUpdateProperty={onUpdateProperty} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); expect(capturedAutoSaveBarProps).not.toBeNull(); // Call the onCancel callback from AutoSaveAffordanceBar const onCancel = capturedAutoSaveBarProps!.onCancel as () => void; @@ -1194,7 +1252,9 @@ describe("PropertyDetailPanel", () => { onUpdateProperty={onUpdateProperty} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); expect(capturedAutoSaveBarProps).not.toBeNull(); const onManualSave = capturedAutoSaveBarProps!.onManualSave as () => Promise; await onManualSave(); @@ -1214,7 +1274,9 @@ describe("PropertyDetailPanel", () => { onUpdateProperty={onUpdateProperty} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); expect(capturedAutoSaveBarProps).not.toBeNull(); const onRetry = capturedAutoSaveBarProps!.onRetry as () => void; onRetry(); @@ -1233,7 +1295,9 @@ describe("PropertyDetailPanel", () => { onUpdateProperty={onUpdateProperty} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); // Find the AnnotationRow for comments (COMMENT_IRI) const commentRow = capturedAnnotationRowProps.find( (p) => p.propertyIri === "http://www.w3.org/2000/01/rdf-schema#comment" @@ -1275,7 +1339,9 @@ describe("PropertyDetailPanel", () => { onUpdateProperty={onUpdateProperty} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); const defRow = capturedAnnotationRowProps.find( (p) => p.propertyIri === "http://www.w3.org/2004/02/skos/core#definition" ); @@ -1303,7 +1369,9 @@ describe("PropertyDetailPanel", () => { onUpdateProperty={onUpdateProperty} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); const commentRow = capturedAnnotationRowProps.find( (p) => p.propertyIri === "http://www.w3.org/2000/01/rdf-schema#comment" ); @@ -1333,7 +1401,9 @@ describe("PropertyDetailPanel", () => { onUpdateProperty={onUpdateProperty} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); const commentRows = capturedAnnotationRowProps.filter( (p) => p.propertyIri === "http://www.w3.org/2000/01/rdf-schema#comment" ); @@ -1368,7 +1438,9 @@ describe("PropertyDetailPanel", () => { onUpdateProperty={onUpdateProperty} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); const defRows = capturedAnnotationRowProps.filter( (p) => p.propertyIri === "http://www.w3.org/2004/02/skos/core#definition" ); @@ -1399,7 +1471,9 @@ describe("PropertyDetailPanel", () => { onUpdateProperty={onUpdateProperty} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); expect(capturedRelationshipSectionProps).not.toBeNull(); // Test addTarget @@ -1425,7 +1499,9 @@ describe("PropertyDetailPanel", () => { onUpdateProperty={onUpdateProperty} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); expect(capturedRelationshipSectionProps).not.toBeNull(); const onRemoveTarget = capturedRelationshipSectionProps!.onRemoveTarget as (groupIdx: number, targetIdx: number) => void; onRemoveTarget(0, 0); @@ -1444,7 +1520,9 @@ describe("PropertyDetailPanel", () => { onUpdateProperty={onUpdateProperty} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); expect(capturedRelationshipSectionProps).not.toBeNull(); const onChangeProperty = capturedRelationshipSectionProps!.onChangeProperty as (groupIdx: number, newIri: string, newLabel: string) => void; onChangeProperty(0, "http://www.w3.org/2000/01/rdf-schema#isDefinedBy", "Defined By"); @@ -1463,7 +1541,9 @@ describe("PropertyDetailPanel", () => { onUpdateProperty={onUpdateProperty} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); expect(capturedRelationshipSectionProps).not.toBeNull(); const groupsBefore = (capturedRelationshipSectionProps!.groups as unknown[]).length; const onAddGroup = capturedRelationshipSectionProps!.onAddGroup as () => void; @@ -1497,7 +1577,9 @@ describe("PropertyDetailPanel", () => { onUpdateProperty={onUpdateProperty} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); // Find annotation row for the custom annotation const annRow = capturedAnnotationRowProps.find( (p) => p.propertyIri === "http://www.w3.org/2004/02/skos/core#prefLabel" @@ -1547,7 +1629,9 @@ describe("PropertyDetailPanel", () => { onUpdateProperty={onUpdateProperty} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); const annRows = capturedAnnotationRowProps.filter( (p) => p.propertyIri === "http://www.w3.org/2004/02/skos/core#prefLabel" ); @@ -1579,7 +1663,9 @@ describe("PropertyDetailPanel", () => { onUpdateProperty={onUpdateProperty} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); expect(capturedRelationshipSectionProps).not.toBeNull(); expect(capturedRelationshipSectionProps!.isEditing).toBe(true); }); @@ -1604,7 +1690,9 @@ describe("PropertyDetailPanel", () => { onUpdateProperty={onUpdateProperty} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); const removeButtons = container.querySelectorAll('button[title="Remove"]'); expect(removeButtons.length).toBeGreaterThanOrEqual(1); await user.click(removeButtons[0]); @@ -1643,7 +1731,9 @@ describe("PropertyDetailPanel", () => { onUpdateProperty={onUpdateProperty} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); expect(capturedAutoSaveBarProps).not.toBeNull(); const onManualSave = capturedAutoSaveBarProps!.onManualSave as () => Promise; await onManualSave(); @@ -1676,7 +1766,9 @@ describe("PropertyDetailPanel", () => { onUpdateProperty={onUpdateProperty} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); await waitFor(() => { expect(capturedInlineAnnotationAdderProps).not.toBeNull(); @@ -1710,7 +1802,9 @@ describe("PropertyDetailPanel", () => { onUpdateProperty={onUpdateProperty} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); await waitFor(() => { expect(capturedInlineAnnotationAdderProps).not.toBeNull(); @@ -1739,7 +1833,9 @@ describe("PropertyDetailPanel", () => { onUpdateProperty={onUpdateProperty} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); await waitFor(() => { expect(capturedInlineAnnotationAdderProps).not.toBeNull(); @@ -1762,7 +1858,9 @@ describe("PropertyDetailPanel", () => { onUpdateProperty={onUpdateProperty} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); await waitFor(() => { expect(capturedRelationshipSectionProps).not.toBeNull(); @@ -1780,10 +1878,9 @@ describe("PropertyDetailPanel", () => { expect(screen.getByText("Label(s)")).not.toBeNull(); }); - // ── Cancel after continuous editing prevents re-entry ── + // ── Cancel in editor context prevents re-entry ── - it("does not re-enter edit mode after cancel even with continuousEditing", async () => { - editorModeOverrides = { continuousEditing: true }; + it("does not re-enter edit mode after cancel in editor context", async () => { const onUpdateProperty = vi.fn(); render( { onUpdateProperty={onUpdateProperty} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); const annRow = capturedAnnotationRowProps.find( (p) => p.propertyIri === "http://www.w3.org/2004/02/skos/core#prefLabel" ); @@ -1858,7 +1957,9 @@ describe("PropertyDetailPanel", () => { onUpdateProperty={onUpdateProperty} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); const labelInputs = screen.getAllByPlaceholderText("Label text"); expect(labelInputs.length).toBeGreaterThanOrEqual(1); expect((labelInputs[0] as HTMLInputElement).value).toBe(""); @@ -1882,7 +1983,9 @@ describe("PropertyDetailPanel", () => { onUpdateProperty={onUpdateProperty} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); expect(capturedRelationshipSectionProps).not.toBeNull(); const groups = capturedRelationshipSectionProps!.groups as Array<{ property_label: string }>; const definedByGroup = groups.find((g) => g.property_label === "Defined By"); diff --git a/__tests__/lib/stores/editorModeStore.test.ts b/__tests__/lib/stores/editorModeStore.test.ts index 13a12cda..70f782e1 100644 --- a/__tests__/lib/stores/editorModeStore.test.ts +++ b/__tests__/lib/stores/editorModeStore.test.ts @@ -82,7 +82,6 @@ describe("useEditorModeStore", () => { useEditorModeStore.setState({ editorMode: "standard", theme: "system", - continuousEditing: false, hideSaveButton: false, }); }); @@ -92,7 +91,6 @@ describe("useEditorModeStore", () => { const state = useEditorModeStore.getState(); expect(state.editorMode).toBe("standard"); expect(state.theme).toBe("system"); - expect(state.continuousEditing).toBe(false); expect(state.hideSaveButton).toBe(false); }); }); @@ -133,19 +131,6 @@ describe("useEditorModeStore", () => { }); }); - describe("setContinuousEditing", () => { - it("enables continuous editing", () => { - useEditorModeStore.getState().setContinuousEditing(true); - expect(useEditorModeStore.getState().continuousEditing).toBe(true); - }); - - it("disables continuous editing", () => { - useEditorModeStore.getState().setContinuousEditing(true); - useEditorModeStore.getState().setContinuousEditing(false); - expect(useEditorModeStore.getState().continuousEditing).toBe(false); - }); - }); - describe("setHideSaveButton", () => { it("hides the save button", () => { useEditorModeStore.getState().setHideSaveButton(true); diff --git a/app/projects/[id]/editor/page.tsx b/app/projects/[id]/editor/page.tsx index f098c558..dca334d0 100644 --- a/app/projects/[id]/editor/page.tsx +++ b/app/projects/[id]/editor/page.tsx @@ -12,7 +12,7 @@ import { CommitMessageDialog } from "@/components/editor/CommitMessageDialog"; import { AddEntityDialog, type NewEntityInfo } from "@/components/editor/AddEntityDialog"; import { useToast } from "@/lib/context/ToastContext"; import { ModeSwitcher } from "@/components/editor/ModeSwitcher"; -import { ContinuousEditingToggle } from "@/components/editor/ContinuousEditingToggle"; + import { DeveloperEditorLayout } from "@/components/editor/developer/DeveloperEditorLayout"; import { StandardEditorLayout } from "@/components/editor/standard/StandardEditorLayout"; import { BranchSelector, RevisionHistoryPanel, HistoryButton } from "@/components/revision"; @@ -823,19 +823,17 @@ export default function EditorPage() {
- - Back + + Close Editor

{project.name}

{totalClasses} classes {/* Mode Switcher */} - {canSuggest && } - {/* Suggestion mode indicator */} {isSuggestionMode && ( diff --git a/app/projects/[id]/page.tsx b/app/projects/[id]/page.tsx index 490ad2a0..cfd42d39 100644 --- a/app/projects/[id]/page.tsx +++ b/app/projects/[id]/page.tsx @@ -258,7 +258,7 @@ function ViewerContent({ {/* Open Editor / Sign In */} {canSuggest ? ( - +
- {/* Continuous Editing */} -
- - Continuous Editing - - -
- {/* Hide Save Button */}
(null); const editInitializedRef = useRef(false); - // Track if user explicitly cancelled for this classIri (prevents continuous-editing auto-re-entry) + // Track if user explicitly cancelled editing for this classIri (prevents auto-re-entry) const cancelledIriRef = useRef(null); - // Continuous editing from store - const continuousEditing = useEditorModeStore((s) => s.continuousEditing); - // Toast for error feedback const toast = useToast(); @@ -221,12 +217,13 @@ export function ClassDetailPanel({ return; } - // Continuous editing → auto-enter (unless user explicitly cancelled for this class) - if (continuousEditing && cancelledIriRef.current !== classIri) { + // In editor context (canEdit + onUpdateClass), auto-enter edit mode + // unless user explicitly cancelled for this class + if (!!onUpdateClass && cancelledIriRef.current !== classIri) { enterEditMode(); return; } - }, [classDetail, canEdit, restoredDraft, classIri, clearRestoredDraft, continuousEditing, isEditing, enterEditMode]); // eslint-disable-line react-hooks/exhaustive-deps + }, [classDetail, canEdit, restoredDraft, classIri, clearRestoredDraft, onUpdateClass, isEditing, enterEditMode]); // eslint-disable-line react-hooks/exhaustive-deps // Initialize edit state from OWLClassDetail const initEditState = useCallback((detail: OWLClassDetail) => { diff --git a/components/editor/ContinuousEditingToggle.tsx b/components/editor/ContinuousEditingToggle.tsx deleted file mode 100644 index d05c5fb6..00000000 --- a/components/editor/ContinuousEditingToggle.tsx +++ /dev/null @@ -1,33 +0,0 @@ -"use client"; - -import { Pencil } from "lucide-react"; -import { useEditorModeStore } from "@/lib/stores/editorModeStore"; -import { cn } from "@/lib/utils"; - -export function ContinuousEditingToggle() { - const continuousEditing = useEditorModeStore((s) => s.continuousEditing); - const setContinuousEditing = useEditorModeStore((s) => s.setContinuousEditing); - - return ( - - ); -} diff --git a/components/editor/IndividualDetailPanel.tsx b/components/editor/IndividualDetailPanel.tsx index 329dc228..b9d9491b 100644 --- a/components/editor/IndividualDetailPanel.tsx +++ b/components/editor/IndividualDetailPanel.tsx @@ -28,7 +28,6 @@ import { PropertyAssertionSection } from "@/components/editor/standard/PropertyA import { LABEL_IRI, COMMENT_IRI, DEFINITION_IRI, SEE_ALSO_IRI, getAnnotationPropertyInfo } from "@/lib/ontology/annotationProperties"; import { AutoSaveAffordanceBar } from "@/components/editor/AutoSaveAffordanceBar"; import { useEntityAutoSave } from "@/lib/hooks/useEntityAutoSave"; -import { useEditorModeStore } from "@/lib/stores/editorModeStore"; import { useToast } from "@/lib/context/ToastContext"; import { extractIndividualDetail, @@ -120,7 +119,6 @@ export function IndividualDetailPanel({ const editInitializedRef = useRef(false); const cancelledIriRef = useRef(null); - const continuousEditing = useEditorModeStore((s) => s.continuousEditing); const toast = useToast(); const buildDraftEntry = useCallback((): IndividualDraftEntry | null => { @@ -294,10 +292,10 @@ export function IndividualDetailPanel({ return; } - if (continuousEditing && cancelledIriRef.current !== individualIri) { + if (onUpdateIndividual && cancelledIriRef.current !== individualIri) { enterEditMode(); } - }, [detail, canEdit, restoredDraft, individualIri, clearRestoredDraft, continuousEditing, isEditing, enterEditMode]); + }, [detail, canEdit, restoredDraft, individualIri, clearRestoredDraft, onUpdateIndividual, isEditing, enterEditMode]); // ── Edit helpers ── const updateLabel = useCallback((index: number, field: "value" | "lang", val: string) => { diff --git a/components/editor/PropertyDetailPanel.tsx b/components/editor/PropertyDetailPanel.tsx index 7982e564..02b6b213 100644 --- a/components/editor/PropertyDetailPanel.tsx +++ b/components/editor/PropertyDetailPanel.tsx @@ -28,7 +28,6 @@ import { RelationshipSection, type RelationshipGroup, type RelationshipTarget } import { LABEL_IRI, COMMENT_IRI, DEFINITION_IRI, SEE_ALSO_IRI, getAnnotationPropertyInfo } from "@/lib/ontology/annotationProperties"; import { AutoSaveAffordanceBar } from "@/components/editor/AutoSaveAffordanceBar"; import { useEntityAutoSave } from "@/lib/hooks/useEntityAutoSave"; -import { useEditorModeStore } from "@/lib/stores/editorModeStore"; import { useToast } from "@/lib/context/ToastContext"; import { extractPropertyDetail, @@ -125,7 +124,6 @@ export function PropertyDetailPanel({ const prevIriRef = useRef(null); const editInitializedRef = useRef(false); const cancelledIriRef = useRef(null); - const continuousEditing = useEditorModeStore((s) => s.continuousEditing); const toast = useToast(); // Build draft entry for auto-save @@ -309,10 +307,10 @@ export function PropertyDetailPanel({ return; } - if (continuousEditing && cancelledIriRef.current !== propertyIri) { + if (onUpdateProperty && cancelledIriRef.current !== propertyIri) { enterEditMode(); } - }, [detail, canEdit, restoredDraft, propertyIri, clearRestoredDraft, continuousEditing, isEditing, enterEditMode]); + }, [detail, canEdit, restoredDraft, propertyIri, clearRestoredDraft, onUpdateProperty, isEditing, enterEditMode]); // ── Edit helpers ── const updateLabel = useCallback((index: number, field: "value" | "lang", val: string) => { diff --git a/lib/stores/editorModeStore.ts b/lib/stores/editorModeStore.ts index 88d41fa9..38876fe1 100644 --- a/lib/stores/editorModeStore.ts +++ b/lib/stores/editorModeStore.ts @@ -7,11 +7,9 @@ export type ThemePreference = "light" | "dark" | "system"; interface EditorModeState { editorMode: EditorMode; theme: ThemePreference; - continuousEditing: boolean; hideSaveButton: boolean; setEditorMode: (mode: EditorMode) => void; setTheme: (theme: ThemePreference) => void; - setContinuousEditing: (on: boolean) => void; setHideSaveButton: (on: boolean) => void; } @@ -39,7 +37,6 @@ export const useEditorModeStore = create()( (set) => ({ editorMode: "standard", theme: "system", - continuousEditing: false, hideSaveButton: false, setEditorMode: (mode) => set({ editorMode: mode }), @@ -49,7 +46,6 @@ export const useEditorModeStore = create()( set({ theme }); }, - setContinuousEditing: (on) => set({ continuousEditing: on }), setHideSaveButton: (on) => set({ hideSaveButton: on }), }), { From 76fd6d6f6f5cac8a8904ac5c251635087d8493e6 Mon Sep 17 00:00:00 2001 From: "John R. D'Orazio" Date: Tue, 28 Apr 2026 10:16:18 +0200 Subject: [PATCH 02/21] feat: gate auto-enter edit mode on preferEditMode preference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restore the persisted preference that was previously named "continuousEditing" under the new name "preferEditMode" with the semantics issue #98 calls for: "open ontology in edit mode by default" rather than the redundant per-entity toggle the old name implied. - Re-add preferEditMode + setPreferEditMode to editorModeStore (default false). - Replace the unconditional !!onUpdate* auto-enter trigger in ClassDetailPanel, PropertyDetailPanel, and IndividualDetailPanel with a check against preferEditMode, so users opt in to skipping the "Edit Item" click. - Surface the preference as a toggle in app/settings (User Settings → Editor Preferences) using the Pencil icon. - Drop a stray blank line left where the ContinuousEditingToggle import was removed from app/projects/[id]/editor/page.tsx. - Update detail panel test mocks to default preferEditMode: true (matching the editor-context behavior they exercise) and add explicit gating tests that assert no auto-enter when preferEditMode is off. - Cover the new field/setter in editorModeStore tests. Closes the items 4 and 5 gap from issue #98 that the original PR #104 had deferred. PR #152 is being closed as a duplicate of this one. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../editor/ClassDetailPanel.test.tsx | 23 +++++++-- .../editor/IndividualDetailPanel.test.tsx | 22 ++++++-- .../editor/PropertyDetailPanel.test.tsx | 22 ++++++-- __tests__/lib/stores/editorModeStore.test.ts | 15 ++++++ app/projects/[id]/editor/page.tsx | 1 - app/settings/page.tsx | 50 ++++++++++++++++++- components/editor/ClassDetailPanel.tsx | 11 ++-- components/editor/IndividualDetailPanel.tsx | 6 ++- components/editor/PropertyDetailPanel.tsx | 6 ++- lib/stores/editorModeStore.ts | 6 +++ 10 files changed, 143 insertions(+), 19 deletions(-) diff --git a/__tests__/components/editor/ClassDetailPanel.test.tsx b/__tests__/components/editor/ClassDetailPanel.test.tsx index bd0043ab..cf62b67a 100644 --- a/__tests__/components/editor/ClassDetailPanel.test.tsx +++ b/__tests__/components/editor/ClassDetailPanel.test.tsx @@ -50,7 +50,7 @@ vi.mock("@/lib/hooks/useAutoSave", () => ({ vi.mock("@/lib/stores/editorModeStore", () => ({ useEditorModeStore: (selector: (s: Record) => unknown) => - selector({ editorMode: "standard", ...editorModeOverrides }), + selector({ editorMode: "standard", preferEditMode: true, ...editorModeOverrides }), })); // Stub child components @@ -1710,9 +1710,9 @@ describe("ClassDetailPanel", () => { expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); }); - // ── Auto-enter edit mode in editor context ── + // ── Auto-enter edit mode gated by preferEditMode ── - it("auto-enters edit mode when onUpdateClass is provided (editor context)", async () => { + it("auto-enters edit mode when preferEditMode is on (editor context)", async () => { const onUpdateClass = vi.fn(); render( { }); }); + it("does not auto-enter edit mode when preferEditMode is off", async () => { + editorModeOverrides = { preferEditMode: false }; + const onUpdateClass = vi.fn(); + render( + + ); + + await waitFor(() => { + expect(screen.getByText("Edit Item")).not.toBeNull(); + }); + expect(screen.queryByTestId("auto-save-bar")).toBeNull(); + }); + // ── Does not auto-enter after cancel ── it("does not re-enter edit mode after cancel in editor context", async () => { diff --git a/__tests__/components/editor/IndividualDetailPanel.test.tsx b/__tests__/components/editor/IndividualDetailPanel.test.tsx index bc037d44..7f0bf9df 100644 --- a/__tests__/components/editor/IndividualDetailPanel.test.tsx +++ b/__tests__/components/editor/IndividualDetailPanel.test.tsx @@ -39,7 +39,7 @@ vi.mock("@/lib/hooks/useEntityAutoSave", () => ({ vi.mock("@/lib/stores/editorModeStore", () => ({ useEditorModeStore: (selector: (s: Record) => unknown) => - selector({ editorMode: "standard", ...editorModeOverrides }), + selector({ editorMode: "standard", preferEditMode: true, ...editorModeOverrides }), })); vi.mock("@/lib/hooks/useIriLabels", () => ({ @@ -637,9 +637,9 @@ describe("IndividualDetailPanel", () => { expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); }); - // ── Auto-enter edit mode in editor context ── + // ── Auto-enter edit mode gated by preferEditMode ── - it("auto-enters edit mode when onUpdateIndividual is provided (editor context)", async () => { + it("auto-enters edit mode when preferEditMode is on (editor context)", async () => { const onUpdateIndividual = vi.fn(); render( { }); }); + it("does not auto-enter edit mode when preferEditMode is off", async () => { + editorModeOverrides = { preferEditMode: false }; + const onUpdateIndividual = vi.fn(); + render( + + ); + await waitFor(() => { + expect(screen.getByText("Edit Item")).not.toBeNull(); + }); + expect(screen.queryByTestId("auto-save-bar")).toBeNull(); + }); + // ── Draft restoration ── it("auto-enters edit mode when restoredDraft is available", async () => { diff --git a/__tests__/components/editor/PropertyDetailPanel.test.tsx b/__tests__/components/editor/PropertyDetailPanel.test.tsx index 5d22d851..e577182a 100644 --- a/__tests__/components/editor/PropertyDetailPanel.test.tsx +++ b/__tests__/components/editor/PropertyDetailPanel.test.tsx @@ -48,7 +48,7 @@ vi.mock("@/lib/hooks/useEntityAutoSave", () => ({ vi.mock("@/lib/stores/editorModeStore", () => ({ useEditorModeStore: (selector: (s: Record) => unknown) => - selector({ editorMode: "standard", ...editorModeOverrides }), + selector({ editorMode: "standard", preferEditMode: true, ...editorModeOverrides }), })); vi.mock("@/lib/hooks/useIriLabels", () => ({ @@ -684,9 +684,9 @@ describe("PropertyDetailPanel", () => { }); }); - // ── Auto-enter edit mode in editor context ── + // ── Auto-enter edit mode gated by preferEditMode ── - it("auto-enters edit mode when onUpdateProperty is provided (editor context)", () => { + it("auto-enters edit mode when preferEditMode is on (editor context)", () => { const onUpdateProperty = vi.fn(); render( { expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); }); + it("does not auto-enter edit mode when preferEditMode is off", async () => { + editorModeOverrides = { preferEditMode: false }; + const onUpdateProperty = vi.fn(); + render( + + ); + await waitFor(() => { + expect(screen.getByText("Edit Item")).not.toBeNull(); + }); + expect(screen.queryByTestId("auto-save-bar")).toBeNull(); + }); + // ── Draft restoration ── it("auto-enters edit mode when restoredDraft is available", () => { diff --git a/__tests__/lib/stores/editorModeStore.test.ts b/__tests__/lib/stores/editorModeStore.test.ts index 70f782e1..b4abe34e 100644 --- a/__tests__/lib/stores/editorModeStore.test.ts +++ b/__tests__/lib/stores/editorModeStore.test.ts @@ -83,6 +83,7 @@ describe("useEditorModeStore", () => { editorMode: "standard", theme: "system", hideSaveButton: false, + preferEditMode: false, }); }); @@ -92,6 +93,7 @@ describe("useEditorModeStore", () => { expect(state.editorMode).toBe("standard"); expect(state.theme).toBe("system"); expect(state.hideSaveButton).toBe(false); + expect(state.preferEditMode).toBe(false); }); }); @@ -143,4 +145,17 @@ describe("useEditorModeStore", () => { expect(useEditorModeStore.getState().hideSaveButton).toBe(false); }); }); + + describe("setPreferEditMode", () => { + it("turns on the prefer-edit-mode preference", () => { + useEditorModeStore.getState().setPreferEditMode(true); + expect(useEditorModeStore.getState().preferEditMode).toBe(true); + }); + + it("turns the preference back off", () => { + useEditorModeStore.getState().setPreferEditMode(true); + useEditorModeStore.getState().setPreferEditMode(false); + expect(useEditorModeStore.getState().preferEditMode).toBe(false); + }); + }); }); diff --git a/app/projects/[id]/editor/page.tsx b/app/projects/[id]/editor/page.tsx index dca334d0..e0e13820 100644 --- a/app/projects/[id]/editor/page.tsx +++ b/app/projects/[id]/editor/page.tsx @@ -12,7 +12,6 @@ import { CommitMessageDialog } from "@/components/editor/CommitMessageDialog"; import { AddEntityDialog, type NewEntityInfo } from "@/components/editor/AddEntityDialog"; import { useToast } from "@/lib/context/ToastContext"; import { ModeSwitcher } from "@/components/editor/ModeSwitcher"; - import { DeveloperEditorLayout } from "@/components/editor/developer/DeveloperEditorLayout"; import { StandardEditorLayout } from "@/components/editor/standard/StandardEditorLayout"; import { BranchSelector, RevisionHistoryPanel, HistoryButton } from "@/components/revision"; diff --git a/app/settings/page.tsx b/app/settings/page.tsx index 7b26684a..d5978f9d 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -3,7 +3,7 @@ import { useState, useEffect } from "react"; import { useSession } from "next-auth/react"; import Link from "next/link"; -import { ArrowLeft, Trash2, Check, AlertCircle, LayoutGrid, Code, Sun, Moon, Monitor, Save } from "lucide-react"; +import { ArrowLeft, Trash2, Check, AlertCircle, LayoutGrid, Code, Sun, Moon, Monitor, Pencil, Save } from "lucide-react"; import { GithubIcon as Github } from "@/components/icons/github"; import { Header } from "@/components/layout/header"; import { Button } from "@/components/ui/button"; @@ -328,6 +328,8 @@ function EditorPreferencesSection() { const setEditorMode = useEditorModeStore((s) => s.setEditorMode); const theme = useEditorModeStore((s) => s.theme); const setTheme = useEditorModeStore((s) => s.setTheme); + const preferEditMode = useEditorModeStore((s) => s.preferEditMode); + const setPreferEditMode = useEditorModeStore((s) => s.setPreferEditMode); const [highlightedSetting, setHighlightedSetting] = useState(null); // Highlight and scroll to the setting referenced by the URL hash @@ -424,6 +426,52 @@ function EditorPreferencesSection() {
+ {/* Prefer Edit Mode */} +
+ + Prefer Edit Mode + + +
+ {/* Hide Save Button */}
(null); + const preferEditMode = useEditorModeStore((s) => s.preferEditMode); + // Toast for error feedback const toast = useToast(); @@ -217,13 +220,13 @@ export function ClassDetailPanel({ return; } - // In editor context (canEdit + onUpdateClass), auto-enter edit mode - // unless user explicitly cancelled for this class - if (!!onUpdateClass && cancelledIriRef.current !== classIri) { + // In editor context with the "prefer edit mode" preference enabled, auto-enter edit mode + // unless the user explicitly cancelled for this class + if (preferEditMode && !!onUpdateClass && cancelledIriRef.current !== classIri) { enterEditMode(); return; } - }, [classDetail, canEdit, restoredDraft, classIri, clearRestoredDraft, onUpdateClass, isEditing, enterEditMode]); // eslint-disable-line react-hooks/exhaustive-deps + }, [classDetail, canEdit, restoredDraft, classIri, clearRestoredDraft, onUpdateClass, preferEditMode, isEditing, enterEditMode]); // Initialize edit state from OWLClassDetail const initEditState = useCallback((detail: OWLClassDetail) => { diff --git a/components/editor/IndividualDetailPanel.tsx b/components/editor/IndividualDetailPanel.tsx index b9d9491b..fd037eb5 100644 --- a/components/editor/IndividualDetailPanel.tsx +++ b/components/editor/IndividualDetailPanel.tsx @@ -28,6 +28,7 @@ import { PropertyAssertionSection } from "@/components/editor/standard/PropertyA import { LABEL_IRI, COMMENT_IRI, DEFINITION_IRI, SEE_ALSO_IRI, getAnnotationPropertyInfo } from "@/lib/ontology/annotationProperties"; import { AutoSaveAffordanceBar } from "@/components/editor/AutoSaveAffordanceBar"; import { useEntityAutoSave } from "@/lib/hooks/useEntityAutoSave"; +import { useEditorModeStore } from "@/lib/stores/editorModeStore"; import { useToast } from "@/lib/context/ToastContext"; import { extractIndividualDetail, @@ -119,6 +120,7 @@ export function IndividualDetailPanel({ const editInitializedRef = useRef(false); const cancelledIriRef = useRef(null); + const preferEditMode = useEditorModeStore((s) => s.preferEditMode); const toast = useToast(); const buildDraftEntry = useCallback((): IndividualDraftEntry | null => { @@ -292,10 +294,10 @@ export function IndividualDetailPanel({ return; } - if (onUpdateIndividual && cancelledIriRef.current !== individualIri) { + if (preferEditMode && onUpdateIndividual && cancelledIriRef.current !== individualIri) { enterEditMode(); } - }, [detail, canEdit, restoredDraft, individualIri, clearRestoredDraft, onUpdateIndividual, isEditing, enterEditMode]); + }, [detail, canEdit, restoredDraft, individualIri, clearRestoredDraft, onUpdateIndividual, preferEditMode, isEditing, enterEditMode]); // ── Edit helpers ── const updateLabel = useCallback((index: number, field: "value" | "lang", val: string) => { diff --git a/components/editor/PropertyDetailPanel.tsx b/components/editor/PropertyDetailPanel.tsx index 02b6b213..ff532b7f 100644 --- a/components/editor/PropertyDetailPanel.tsx +++ b/components/editor/PropertyDetailPanel.tsx @@ -28,6 +28,7 @@ import { RelationshipSection, type RelationshipGroup, type RelationshipTarget } import { LABEL_IRI, COMMENT_IRI, DEFINITION_IRI, SEE_ALSO_IRI, getAnnotationPropertyInfo } from "@/lib/ontology/annotationProperties"; import { AutoSaveAffordanceBar } from "@/components/editor/AutoSaveAffordanceBar"; import { useEntityAutoSave } from "@/lib/hooks/useEntityAutoSave"; +import { useEditorModeStore } from "@/lib/stores/editorModeStore"; import { useToast } from "@/lib/context/ToastContext"; import { extractPropertyDetail, @@ -124,6 +125,7 @@ export function PropertyDetailPanel({ const prevIriRef = useRef(null); const editInitializedRef = useRef(false); const cancelledIriRef = useRef(null); + const preferEditMode = useEditorModeStore((s) => s.preferEditMode); const toast = useToast(); // Build draft entry for auto-save @@ -307,10 +309,10 @@ export function PropertyDetailPanel({ return; } - if (onUpdateProperty && cancelledIriRef.current !== propertyIri) { + if (preferEditMode && onUpdateProperty && cancelledIriRef.current !== propertyIri) { enterEditMode(); } - }, [detail, canEdit, restoredDraft, propertyIri, clearRestoredDraft, onUpdateProperty, isEditing, enterEditMode]); + }, [detail, canEdit, restoredDraft, propertyIri, clearRestoredDraft, onUpdateProperty, preferEditMode, isEditing, enterEditMode]); // ── Edit helpers ── const updateLabel = useCallback((index: number, field: "value" | "lang", val: string) => { diff --git a/lib/stores/editorModeStore.ts b/lib/stores/editorModeStore.ts index 38876fe1..505de8fe 100644 --- a/lib/stores/editorModeStore.ts +++ b/lib/stores/editorModeStore.ts @@ -8,9 +8,12 @@ interface EditorModeState { editorMode: EditorMode; theme: ThemePreference; hideSaveButton: boolean; + /** When true, opening an entity in the editor auto-enters edit mode (no extra "Edit Item" click). */ + preferEditMode: boolean; setEditorMode: (mode: EditorMode) => void; setTheme: (theme: ThemePreference) => void; setHideSaveButton: (on: boolean) => void; + setPreferEditMode: (on: boolean) => void; } /** @@ -38,6 +41,7 @@ export const useEditorModeStore = create()( editorMode: "standard", theme: "system", hideSaveButton: false, + preferEditMode: false, setEditorMode: (mode) => set({ editorMode: mode }), @@ -47,6 +51,8 @@ export const useEditorModeStore = create()( }, setHideSaveButton: (on) => set({ hideSaveButton: on }), + + setPreferEditMode: (on) => set({ preferEditMode: on }), }), { name: "ontokit-editor-preferences", From d2b5e1f497c3d87c1034db47ddd54324df98d9eb Mon Sep 17 00:00:00 2001 From: "John R. D'Orazio" Date: Tue, 28 Apr 2026 14:27:31 +0200 Subject: [PATCH 03/21] test(editor): exercise Edit Item click path in detail panel tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three tests named "enters edit mode when Edit Item is clicked" never actually clicked anything — they relied on the auto-enter behavior the mock default (preferEditMode: true) provides, so the manual click path was uncovered. Set preferEditMode: false in each, assert the panel mounts read-only ("Edit Item" present, no auto-save bar), then click Edit Item and assert the auto-save bar appears. This covers the click-to-edit flow that the preferEditMode gating now makes possible. The remaining CodeRabbit findings in this batch (proposing to remove the preferEditMode gating entirely so editor context always auto-enters) are intentionally not applied — they would reverse the issue #98 item 4 design we just shipped, where the preference controls whether entities open in edit mode by default. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/editor/ClassDetailPanel.test.tsx | 16 +++++++++++----- .../editor/IndividualDetailPanel.test.tsx | 12 +++++++++++- .../editor/PropertyDetailPanel.test.tsx | 13 +++++++++++-- 3 files changed, 33 insertions(+), 8 deletions(-) diff --git a/__tests__/components/editor/ClassDetailPanel.test.tsx b/__tests__/components/editor/ClassDetailPanel.test.tsx index ae9858d3..a29da8cf 100644 --- a/__tests__/components/editor/ClassDetailPanel.test.tsx +++ b/__tests__/components/editor/ClassDetailPanel.test.tsx @@ -582,6 +582,8 @@ describe("ClassDetailPanel", () => { // ── Edit mode entry ── it("enters edit mode when Edit Item button is clicked", async () => { + editorModeOverrides = { preferEditMode: false }; + const user = userEvent.setup(); const onUpdateClass = vi.fn(); render( { /> ); + // With preferEditMode off the panel mounts read-only; user must click Edit Item. await waitFor(() => { - expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + expect(screen.getByText("Edit Item")).not.toBeNull(); }); + expect(screen.queryByTestId("auto-save-bar")).toBeNull(); + + await user.click(screen.getByText("Edit Item")); - // In edit mode, label should be rendered in an input await waitFor(() => { - const labelInput = screen.getByDisplayValue("Person"); - expect(labelInput).not.toBeNull(); - expect(labelInput.tagName).toBe("INPUT"); + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); }); + const labelInput = screen.getByDisplayValue("Person"); + expect(labelInput).not.toBeNull(); + expect(labelInput.tagName).toBe("INPUT"); }); // ── Label editing ── diff --git a/__tests__/components/editor/IndividualDetailPanel.test.tsx b/__tests__/components/editor/IndividualDetailPanel.test.tsx index 357f8d39..c6997485 100644 --- a/__tests__/components/editor/IndividualDetailPanel.test.tsx +++ b/__tests__/components/editor/IndividualDetailPanel.test.tsx @@ -381,6 +381,8 @@ describe("IndividualDetailPanel", () => { }); it("enters edit mode when Edit Item is clicked", async () => { + editorModeOverrides = { preferEditMode: false }; + const user = userEvent.setup(); const onUpdateIndividual = vi.fn(); render( { onUpdateIndividual={onUpdateIndividual} /> ); + + // With preferEditMode off the panel mounts read-only; user must click Edit Item. + await waitFor(() => { + expect(screen.getByText("Edit Item")).not.toBeNull(); + }); + expect(screen.queryByTestId("auto-save-bar")).toBeNull(); + + await user.click(screen.getByText("Edit Item")); + await waitFor(() => { expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); }); - expect(screen.getByTestId("auto-save-bar")).toBeDefined(); }); // ── API call verification ── diff --git a/__tests__/components/editor/PropertyDetailPanel.test.tsx b/__tests__/components/editor/PropertyDetailPanel.test.tsx index 73480ab5..45169b52 100644 --- a/__tests__/components/editor/PropertyDetailPanel.test.tsx +++ b/__tests__/components/editor/PropertyDetailPanel.test.tsx @@ -396,6 +396,8 @@ describe("PropertyDetailPanel", () => { }); it("enters edit mode when Edit Item is clicked", async () => { + editorModeOverrides = { preferEditMode: false }; + const user = userEvent.setup(); const onUpdateProperty = vi.fn(); render( { onUpdateProperty={onUpdateProperty} /> ); + + // With preferEditMode off the panel mounts read-only; user must click Edit Item. + await waitFor(() => { + expect(screen.getByText("Edit Item")).not.toBeNull(); + }); + expect(screen.queryByTestId("auto-save-bar")).toBeNull(); + + await user.click(screen.getByText("Edit Item")); + await waitFor(() => { expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); }); - // Auto-save bar should appear - expect(screen.getByTestId("auto-save-bar")).toBeDefined(); }); // ── API call verification ── From c5ac8dcc1e9d2f607cf7d685ee0dbd749717daee Mon Sep 17 00:00:00 2001 From: "John R. D'Orazio" Date: Tue, 28 Apr 2026 15:01:58 +0200 Subject: [PATCH 04/21] refactor(editor): remove Edit Item button; editor is always in edit mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the design clarification: when the user is in the editor (vs the read-only viewer), the panel is unconditionally in edit mode. There is no longer a separate "Edit Item" affordance to enter edit mode — that button was a holdover from the three-state world (viewer / editor-but- read-only / editor-editing) that we collapsed to two states (viewer / editor-always-editing). Detail panels (Class, Property, Individual): - Drop the preferEditMode read inside the panels. preferEditMode is now purely a routing-level preference (which page to default to when opening a project), not a per-entity gate. - Drop cancelledIriRef. With unconditional auto-entry there is nothing to suppress — Cancel just discards the in-progress draft and re-inits from server state, then stays in edit mode (auto-save bar visible). - Manual save no longer calls setIsEditing(false); it just flushes. - Remove the Edit Item button block, the canEnterEdit local, and the now-unused Pencil/isSuggestionMode references in ClassDetailPanel. Settings copy: update the Prefer Edit Mode description to reflect the routing semantics ("opens a project straight to the editor / viewer first") rather than the old per-entity meaning. Tests updated: - Remove "does not auto-enter edit mode when preferEditMode is off" (gating no longer exists). - Remove "enters edit mode when Edit Item is clicked" (no button). - Rename "hides Edit Item button while in edit mode" to "never renders an Edit Item button in editor context". - Rewrite cancel tests: assert the auto-save bar stays visible after cancel instead of expecting a return to read-only "Edit Item". - Drop the redundant per-panel `preferEditMode: true` mock default. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../editor/ClassDetailPanel.test.tsx | 72 ++++--------------- .../editor/IndividualDetailPanel.test.tsx | 41 +++-------- .../editor/PropertyDetailPanel.test.tsx | 70 +++--------------- app/settings/page.tsx | 2 +- components/editor/ClassDetailPanel.tsx | 41 +++-------- components/editor/IndividualDetailPanel.tsx | 25 ++----- components/editor/PropertyDetailPanel.tsx | 27 ++----- 7 files changed, 50 insertions(+), 228 deletions(-) diff --git a/__tests__/components/editor/ClassDetailPanel.test.tsx b/__tests__/components/editor/ClassDetailPanel.test.tsx index a29da8cf..48215cca 100644 --- a/__tests__/components/editor/ClassDetailPanel.test.tsx +++ b/__tests__/components/editor/ClassDetailPanel.test.tsx @@ -50,7 +50,7 @@ vi.mock("@/lib/hooks/useAutoSave", () => ({ vi.mock("@/lib/stores/editorModeStore", () => ({ useEditorModeStore: (selector: (s: Record) => unknown) => - selector({ editorMode: "standard", preferEditMode: true, ...editorModeOverrides }), + selector({ editorMode: "standard", ...editorModeOverrides }), })); // Stub child components @@ -447,22 +447,6 @@ describe("ClassDetailPanel", () => { }); }); - it("shows 'Suggest Changes' when isSuggestionMode is true", async () => { - const onUpdateClass = vi.fn(); - render( - - ); - - await waitFor(() => { - expect(screen.getByText("Suggest Changes")).toBeDefined(); - }); - }); - // ── Tree-node fallback for unsaved entities ── it("renders unsaved entity fallback with parent link", async () => { @@ -581,9 +565,7 @@ describe("ClassDetailPanel", () => { // ── Edit mode entry ── - it("enters edit mode when Edit Item button is clicked", async () => { - editorModeOverrides = { preferEditMode: false }; - const user = userEvent.setup(); + it("auto-enters edit mode and renders inputs when onUpdateClass is provided", async () => { const onUpdateClass = vi.fn(); render( { /> ); - // With preferEditMode off the panel mounts read-only; user must click Edit Item. - await waitFor(() => { - expect(screen.getByText("Edit Item")).not.toBeNull(); - }); - expect(screen.queryByTestId("auto-save-bar")).toBeNull(); - - await user.click(screen.getByText("Edit Item")); - await waitFor(() => { expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); }); @@ -848,7 +822,7 @@ describe("ClassDetailPanel", () => { // ── Cancel flow ── - it("exits edit mode and discards draft when cancel is invoked", async () => { + it("discards draft when cancel is invoked but stays in edit mode", async () => { const user = userEvent.setup(); const onUpdateClass = vi.fn(); @@ -874,9 +848,9 @@ describe("ClassDetailPanel", () => { await waitFor(() => { expect(mockDiscardDraft).toHaveBeenCalled(); - // Verify edit mode exited — "Edit Item" button should be visible again - expect(screen.getByText("Edit Item")).not.toBeNull(); }); + // Editor is always in edit mode — auto-save bar stays. + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); }); it("calls flushToGit when navigating to a different class", async () => { @@ -969,9 +943,9 @@ describe("ClassDetailPanel", () => { }); }); - // ── Edit mode hides Edit Item button ── + // ── No "Edit Item" affordance in editor context ── - it("hides Edit Item button while in edit mode", async () => { + it("never renders an Edit Item button in editor context", async () => { const onUpdateClass = vi.fn(); render( { await waitFor(() => { expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); }); - - await waitFor(() => { - expect(screen.queryByText("Edit Item")).toBeNull(); - }); + expect(screen.queryByText("Edit Item")).toBeNull(); }); // ── Definition in edit mode ── @@ -1717,9 +1688,9 @@ describe("ClassDetailPanel", () => { expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); }); - // ── Auto-enter edit mode gated by preferEditMode ── + // ── Auto-enter edit mode in editor context ── - it("auto-enters edit mode when preferEditMode is on (editor context)", async () => { + it("auto-enters edit mode when onUpdateClass is provided", async () => { const onUpdateClass = vi.fn(); render( { }); }); - it("does not auto-enter edit mode when preferEditMode is off", async () => { - editorModeOverrides = { preferEditMode: false }; - const onUpdateClass = vi.fn(); - render( - - ); + // ── Cancel keeps the panel in edit mode ── - await waitFor(() => { - expect(screen.getByText("Edit Item")).not.toBeNull(); - }); - expect(screen.queryByTestId("auto-save-bar")).toBeNull(); - }); - - // ── Does not auto-enter after cancel ── - - it("does not re-enter edit mode after cancel in editor context", async () => { + it("stays in edit mode after cancel and discards the draft", async () => { const user = userEvent.setup(); const onUpdateClass = vi.fn(); render( @@ -1773,8 +1727,8 @@ describe("ClassDetailPanel", () => { await waitFor(() => { expect(mockDiscardDraft).toHaveBeenCalled(); - expect(screen.getByText("Edit Item")).not.toBeNull(); }); + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); }); // ── Draft restoration auto-enter ── diff --git a/__tests__/components/editor/IndividualDetailPanel.test.tsx b/__tests__/components/editor/IndividualDetailPanel.test.tsx index c6997485..e4eed728 100644 --- a/__tests__/components/editor/IndividualDetailPanel.test.tsx +++ b/__tests__/components/editor/IndividualDetailPanel.test.tsx @@ -39,7 +39,7 @@ vi.mock("@/lib/hooks/useEntityAutoSave", () => ({ vi.mock("@/lib/stores/editorModeStore", () => ({ useEditorModeStore: (selector: (s: Record) => unknown) => - selector({ editorMode: "standard", preferEditMode: true, ...editorModeOverrides }), + selector({ editorMode: "standard", ...editorModeOverrides }), })); vi.mock("@/lib/hooks/useIriLabels", () => ({ @@ -380,9 +380,7 @@ describe("IndividualDetailPanel", () => { }); }); - it("enters edit mode when Edit Item is clicked", async () => { - editorModeOverrides = { preferEditMode: false }; - const user = userEvent.setup(); + it("never renders an Edit Item button in editor context", async () => { const onUpdateIndividual = vi.fn(); render( { /> ); - // With preferEditMode off the panel mounts read-only; user must click Edit Item. - await waitFor(() => { - expect(screen.getByText("Edit Item")).not.toBeNull(); - }); - expect(screen.queryByTestId("auto-save-bar")).toBeNull(); - - await user.click(screen.getByText("Edit Item")); - await waitFor(() => { expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); }); + expect(screen.queryByText("Edit Item")).toBeNull(); }); // ── API call verification ── @@ -649,9 +640,9 @@ describe("IndividualDetailPanel", () => { expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); }); - // ── Auto-enter edit mode gated by preferEditMode ── + // ── Auto-enter edit mode in editor context ── - it("auto-enters edit mode when preferEditMode is on (editor context)", async () => { + it("auto-enters edit mode when onUpdateIndividual is provided", async () => { const onUpdateIndividual = vi.fn(); render( { }); }); - it("does not auto-enter edit mode when preferEditMode is off", async () => { - editorModeOverrides = { preferEditMode: false }; - const onUpdateIndividual = vi.fn(); - render( - - ); - await waitFor(() => { - expect(screen.getByText("Edit Item")).not.toBeNull(); - }); - expect(screen.queryByTestId("auto-save-bar")).toBeNull(); - }); - // ── Draft restoration ── it("auto-enters edit mode when restoredDraft is available", async () => { @@ -1954,9 +1929,9 @@ describe("IndividualDetailPanel", () => { expect(screen.getByText("Label(s)")).not.toBeNull(); }); - // ── Cancel in editor context prevents re-entry ── + // ── Cancel keeps the panel in edit mode ── - it("does not re-enter edit mode after cancel in editor context", async () => { + it("stays in edit mode after cancel and discards the draft", async () => { const onUpdateIndividual = vi.fn(); render( { await waitFor(() => { expect(mockDiscardDraft).toHaveBeenCalled(); - expect(screen.getByText("Edit Item")).not.toBeNull(); }); + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); }); // ── Edit mode with isDefinedBy relationships ── diff --git a/__tests__/components/editor/PropertyDetailPanel.test.tsx b/__tests__/components/editor/PropertyDetailPanel.test.tsx index 45169b52..5c794920 100644 --- a/__tests__/components/editor/PropertyDetailPanel.test.tsx +++ b/__tests__/components/editor/PropertyDetailPanel.test.tsx @@ -48,7 +48,7 @@ vi.mock("@/lib/hooks/useEntityAutoSave", () => ({ vi.mock("@/lib/stores/editorModeStore", () => ({ useEditorModeStore: (selector: (s: Record) => unknown) => - selector({ editorMode: "standard", preferEditMode: true, ...editorModeOverrides }), + selector({ editorMode: "standard", ...editorModeOverrides }), })); vi.mock("@/lib/hooks/useIriLabels", () => ({ @@ -395,9 +395,7 @@ describe("PropertyDetailPanel", () => { }); }); - it("enters edit mode when Edit Item is clicked", async () => { - editorModeOverrides = { preferEditMode: false }; - const user = userEvent.setup(); + it("never renders an Edit Item button in editor context", async () => { const onUpdateProperty = vi.fn(); render( { /> ); - // With preferEditMode off the panel mounts read-only; user must click Edit Item. - await waitFor(() => { - expect(screen.getByText("Edit Item")).not.toBeNull(); - }); - expect(screen.queryByTestId("auto-save-bar")).toBeNull(); - - await user.click(screen.getByText("Edit Item")); - await waitFor(() => { expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); }); + expect(screen.queryByText("Edit Item")).toBeNull(); }); // ── API call verification ── @@ -667,7 +658,7 @@ describe("PropertyDetailPanel", () => { // ── Cancel edit mode ── - it("exits edit mode and calls discardDraft when cancel flow is triggered", async () => { + it("discards draft when cancel is invoked but stays in edit mode", async () => { const onUpdateProperty = vi.fn(); render( { await waitFor(() => { expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); }); - // Verify we are in edit mode - expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); // Trigger cancel via the captured AutoSaveAffordanceBar callback expect(capturedAutoSaveBarProps).not.toBeNull(); @@ -690,14 +679,14 @@ describe("PropertyDetailPanel", () => { await waitFor(() => { expect(mockDiscardDraft).toHaveBeenCalled(); - // Verify edit mode exited — "Edit Item" button should be visible again - expect(screen.getByText("Edit Item")).not.toBeNull(); }); + // Editor is always in edit mode — auto-save bar stays. + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); }); - // ── Auto-enter edit mode gated by preferEditMode ── + // ── Auto-enter edit mode in editor context ── - it("auto-enters edit mode when preferEditMode is on (editor context)", async () => { + it("auto-enters edit mode when onUpdateProperty is provided", async () => { const onUpdateProperty = vi.fn(); render( { onUpdateProperty={onUpdateProperty} /> ); - // Should auto-enter edit mode and show auto-save bar await waitFor(() => { expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); }); }); - it("does not auto-enter edit mode when preferEditMode is off", async () => { - editorModeOverrides = { preferEditMode: false }; - const onUpdateProperty = vi.fn(); - render( - - ); - await waitFor(() => { - expect(screen.getByText("Edit Item")).not.toBeNull(); - }); - expect(screen.queryByTestId("auto-save-bar")).toBeNull(); - }); - // ── Draft restoration ── it("auto-enters edit mode when restoredDraft is available", () => { @@ -1878,32 +1850,6 @@ describe("PropertyDetailPanel", () => { expect(screen.getByText("Label(s)")).not.toBeNull(); }); - // ── Cancel in editor context prevents re-entry ── - - it("does not re-enter edit mode after cancel in editor context", async () => { - const onUpdateProperty = vi.fn(); - render( - - ); - await waitFor(() => { - expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); - }); - - // Trigger cancel - expect(capturedAutoSaveBarProps).not.toBeNull(); - const onCancel = capturedAutoSaveBarProps!.onCancel as () => void; - onCancel(); - - await waitFor(() => { - expect(mockDiscardDraft).toHaveBeenCalled(); - expect(screen.getByText("Edit Item")).not.toBeNull(); - }); - }); - // ── Edit mode with existing annotations shows custom annotation editing ── it("renders annotation language change callback in edit mode", async () => { diff --git a/app/settings/page.tsx b/app/settings/page.tsx index d5978f9d..e43cba5f 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -466,7 +466,7 @@ function EditorPreferencesSection() { {preferEditMode ? "On" : "Off"}

- When on, opening an entity in the editor enters edit mode immediately. When off, entities open read-only and you click “Edit Item” to make changes. + When on, opening a project takes you straight to the editor. When off, projects open in the read-only viewer first.

diff --git a/components/editor/ClassDetailPanel.tsx b/components/editor/ClassDetailPanel.tsx index 5922edf9..eee3eee5 100644 --- a/components/editor/ClassDetailPanel.tsx +++ b/components/editor/ClassDetailPanel.tsx @@ -26,7 +26,6 @@ import { StickyNote, Hash, Link2, - Pencil, } from "lucide-react"; import { projectOntologyApi, type OWLClassDetail, type ClassUpdatePayload, type AnnotationUpdate } from "@/lib/api/client"; import type { LocalizedString } from "@/lib/api/client"; @@ -44,7 +43,6 @@ import { CrossReferencesPanel } from "@/components/editor/CrossReferencesPanel"; import { SimilarConceptsPanel } from "@/components/editor/SimilarConceptsPanel"; import { EntityHistoryTab } from "@/components/editor/EntityHistoryTab"; import { useAutoSave } from "@/lib/hooks/useAutoSave"; -import { useEditorModeStore } from "@/lib/stores/editorModeStore"; import { useToast } from "@/lib/context/ToastContext"; /** Ensure an array of localized strings always ends with an empty placeholder row */ @@ -90,7 +88,6 @@ export function ClassDetailPanel({ onCopyIri, selectedNodeFallback, canEdit, - isSuggestionMode = false, onUpdateClass, refreshKey, headerActions, @@ -116,10 +113,6 @@ export function ClassDetailPanel({ // Track the previous classIri so we can flush on navigate const prevClassIriRef = useRef(null); const editInitializedRef = useRef(false); - // Track if user explicitly cancelled editing for this classIri (prevents auto-re-entry) - const cancelledIriRef = useRef(null); - - const preferEditMode = useEditorModeStore((s) => s.preferEditMode); // Toast for error feedback const toast = useToast(); @@ -174,7 +167,6 @@ export function ClassDetailPanel({ setResolvedTargetLabels({}); editInitializedRef.current = false; setIsEditing(false); - cancelledIriRef.current = null; }, [classIri]); // eslint-disable-line react-hooks/exhaustive-deps // Enter edit mode: initialize edit state from classDetail @@ -185,21 +177,19 @@ export function ClassDetailPanel({ setIsEditing(true); }, [classDetail]); // eslint-disable-line react-hooks/exhaustive-deps - // Cancel edit mode: discard draft, revert to server state + // Cancel: discard the in-progress draft and re-init from server state. + // The panel stays in edit mode — the editor is always editable. const cancelEditMode = useCallback(() => { discardDraft(); if (classDetail) { initEditState(classDetail); } - setIsEditing(false); - cancelledIriRef.current = classIri; - }, [classIri, classDetail, discardDraft]); // eslint-disable-line react-hooks/exhaustive-deps + }, [classDetail, discardDraft]); // eslint-disable-line react-hooks/exhaustive-deps - // Manual save: trigger draft save, flush to git, exit edit mode on success + // Manual save: flush the current draft to git. Stays in edit mode. const saveAndExitEditMode = useCallback(async () => { triggerSave(); - const ok = await flushToGit(); - if (ok) setIsEditing(false); + await flushToGit(); }, [triggerSave, flushToGit]); // Auto-enter edit mode based on continuous editing or restored draft @@ -221,13 +211,9 @@ export function ClassDetailPanel({ return; } - // In editor context with the "prefer edit mode" preference enabled, auto-enter edit mode - // unless the user explicitly cancelled for this class - if (preferEditMode && !!onUpdateClass && cancelledIriRef.current !== classIri) { - enterEditMode(); - return; - } - }, [classDetail, canEdit, restoredDraft, classIri, clearRestoredDraft, onUpdateClass, preferEditMode, isEditing, enterEditMode]); + // In editor context (canEdit + onUpdateClass), always auto-enter edit mode. + enterEditMode(); + }, [classDetail, canEdit, restoredDraft, classIri, clearRestoredDraft, onUpdateClass, isEditing, enterEditMode]); // Initialize edit state from OWLClassDetail const initEditState = useCallback((detail: OWLClassDetail) => { @@ -570,7 +556,6 @@ export function ClassDetailPanel({ } const displayLabel = getPreferredLabel(classDetail.labels) || getLocalName(classDetail.iri); - const canEnterEdit = !!canEdit && !!onUpdateClass; return (
@@ -593,16 +578,6 @@ export function ClassDetailPanel({
{headerActions} - {canEnterEdit && !isEditing && ( - - )}
diff --git a/components/editor/IndividualDetailPanel.tsx b/components/editor/IndividualDetailPanel.tsx index 4f2f3235..ec37b7f0 100644 --- a/components/editor/IndividualDetailPanel.tsx +++ b/components/editor/IndividualDetailPanel.tsx @@ -8,7 +8,6 @@ import { Copy, Trash2, AlertTriangle, - Pencil, Lightbulb, StickyNote, Link2, @@ -29,7 +28,6 @@ import { PropertyAssertionSection } from "@/components/editor/standard/PropertyA import { LABEL_IRI, COMMENT_IRI, DEFINITION_IRI, SEE_ALSO_IRI, getAnnotationPropertyInfo } from "@/lib/ontology/annotationProperties"; import { AutoSaveAffordanceBar } from "@/components/editor/AutoSaveAffordanceBar"; import { useEntityAutoSave } from "@/lib/hooks/useEntityAutoSave"; -import { useEditorModeStore } from "@/lib/stores/editorModeStore"; import { useToast } from "@/lib/context/ToastContext"; import { extractIndividualDetail, @@ -120,8 +118,6 @@ export function IndividualDetailPanel({ const editInitializedRef = useRef(false); - const cancelledIriRef = useRef(null); - const preferEditMode = useEditorModeStore((s) => s.preferEditMode); const toast = useToast(); const buildDraftEntry = useCallback((): IndividualDraftEntry | null => { @@ -249,7 +245,6 @@ export function IndividualDetailPanel({ useEffect(() => { editInitializedRef.current = false; setIsEditing(false); - cancelledIriRef.current = null; }, [individualIri]); const enterEditMode = useCallback(() => { @@ -259,18 +254,16 @@ export function IndividualDetailPanel({ setIsEditing(true); }, [detail, initEditState]); + // Cancel: discard draft, re-init from server. Stay in edit mode. const cancelEditMode = useCallback(() => { discardDraft(); if (detail) initEditState(detail); - setIsEditing(false); - cancelledIriRef.current = individualIri; - }, [individualIri, detail, discardDraft, initEditState]); + }, [detail, discardDraft, initEditState]); - // Manual save: trigger draft save, flush to git, exit edit mode on success + // Manual save: flush the current draft to git. Stays in edit mode. const saveAndExitEditMode = useCallback(async () => { triggerSave(); - const ok = await flushToGit(); - if (ok) setIsEditing(false); + await flushToGit(); }, [triggerSave, flushToGit]); useEffect(() => { @@ -295,10 +288,10 @@ export function IndividualDetailPanel({ return; } - if (preferEditMode && onUpdateIndividual && cancelledIriRef.current !== individualIri) { + if (onUpdateIndividual) { enterEditMode(); } - }, [detail, canEdit, restoredDraft, individualIri, clearRestoredDraft, onUpdateIndividual, preferEditMode, isEditing, enterEditMode]); + }, [detail, canEdit, restoredDraft, individualIri, clearRestoredDraft, onUpdateIndividual, isEditing, enterEditMode]); // ── Edit helpers ── const updateLabel = useCallback((index: number, field: "value" | "lang", val: string) => { @@ -404,7 +397,6 @@ export function IndividualDetailPanel({ } const displayLabel = detail.labels.length > 0 ? detail.labels[0].value : getLocalName(individualIri); - const canEnterEdit = canEdit && !!onUpdateIndividual; return (
@@ -424,11 +416,6 @@ export function IndividualDetailPanel({ )} - {canEnterEdit && !isEditing && ( -
- -
- )}
Individual diff --git a/components/editor/PropertyDetailPanel.tsx b/components/editor/PropertyDetailPanel.tsx index d67c02ae..2875006c 100644 --- a/components/editor/PropertyDetailPanel.tsx +++ b/components/editor/PropertyDetailPanel.tsx @@ -9,7 +9,6 @@ import { Copy, Trash2, AlertTriangle, - Pencil, ArrowRight, ArrowLeftRight, CheckSquare, @@ -29,7 +28,6 @@ import { RelationshipSection, type RelationshipGroup, type RelationshipTarget } import { LABEL_IRI, COMMENT_IRI, DEFINITION_IRI, SEE_ALSO_IRI, getAnnotationPropertyInfo } from "@/lib/ontology/annotationProperties"; import { AutoSaveAffordanceBar } from "@/components/editor/AutoSaveAffordanceBar"; import { useEntityAutoSave } from "@/lib/hooks/useEntityAutoSave"; -import { useEditorModeStore } from "@/lib/stores/editorModeStore"; import { useToast } from "@/lib/context/ToastContext"; import { extractPropertyDetail, @@ -125,8 +123,6 @@ export function PropertyDetailPanel({ const prevIriRef = useRef(null); const editInitializedRef = useRef(false); - const cancelledIriRef = useRef(null); - const preferEditMode = useEditorModeStore((s) => s.preferEditMode); const toast = useToast(); // Build draft entry for auto-save @@ -262,7 +258,6 @@ export function PropertyDetailPanel({ prevIriRef.current = propertyIri; editInitializedRef.current = false; setIsEditing(false); - cancelledIriRef.current = null; }, [propertyIri]); // eslint-disable-line react-hooks/exhaustive-deps const enterEditMode = useCallback(() => { @@ -272,18 +267,16 @@ export function PropertyDetailPanel({ setIsEditing(true); }, [detail, initEditState]); + // Cancel: discard draft, re-init from server. Stay in edit mode. const cancelEditMode = useCallback(() => { discardDraft(); if (detail) initEditState(detail); - setIsEditing(false); - cancelledIriRef.current = propertyIri; - }, [propertyIri, detail, discardDraft, initEditState]); + }, [detail, discardDraft, initEditState]); - // Manual save: trigger draft save, flush to git, exit edit mode on success + // Manual save: flush the current draft to git. Stays in edit mode. const saveAndExitEditMode = useCallback(async () => { triggerSave(); - const ok = await flushToGit(); - if (ok) setIsEditing(false); + await flushToGit(); }, [triggerSave, flushToGit]); // Auto-enter edit mode @@ -310,10 +303,10 @@ export function PropertyDetailPanel({ return; } - if (preferEditMode && onUpdateProperty && cancelledIriRef.current !== propertyIri) { + if (onUpdateProperty) { enterEditMode(); } - }, [detail, canEdit, restoredDraft, propertyIri, clearRestoredDraft, onUpdateProperty, preferEditMode, isEditing, enterEditMode]); + }, [detail, canEdit, restoredDraft, propertyIri, clearRestoredDraft, onUpdateProperty, isEditing, enterEditMode]); // ── Edit helpers ── const updateLabel = useCallback((index: number, field: "value" | "lang", val: string) => { @@ -447,7 +440,6 @@ export function PropertyDetailPanel({ const typeInfo = PROPERTY_TYPE_LABELS[detail.propertyType]; const displayLabel = detail.labels.length > 0 ? detail.labels[0].value : getLocalName(propertyIri); - const canEnterEdit = canEdit && !!onUpdateProperty; return (
@@ -468,13 +460,6 @@ export function PropertyDetailPanel({ )} - {canEnterEdit && !isEditing && ( -
- -
- )}
From 550b2a8089f2d37ceec01992a068a8f285204ca5 Mon Sep 17 00:00:00 2001 From: "John R. D'Orazio" Date: Tue, 28 Apr 2026 15:43:06 +0200 Subject: [PATCH 05/21] feat(editor): add Viewer/Editor switcher with type-aware selection params MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the standalone "Open Editor" / "Close Editor" buttons with a grouped Viewer|Editor toggle that lives next to the existing Standard|Developer ModeSwitcher in both pages. The toggle's active segment renders as a non-link span; the inactive segment is a Link. Selection round-trip is now type-aware. The single ?classIri= query param is replaced with one of three mutually-exclusive keys: - ?classIri= for class entities - ?propertyIri= for properties - ?individualIri= for individuals This addresses OWL punning, where the same IRI can refer to a class and an individual simultaneously — a bookmarked link to the Person class and one to the Person individual are now unambiguous. A new lib/utils/selectionUrl helper centralizes: - readSelectionFromSearchParams: reads any of the three keys with priority class > property > individual (matching tree order). - buildSelectionQuery: builds a leading-? query string from a typed selection. The viewer page no longer renders its own "Open Editor" button. The "Sign in to edit" affordance for unauthenticated users is preserved. The editor page no longer renders its "Close Editor" link or the preceding divider. Wiring of selectedIri → entity-type at the link-builder side is the next phase (selection store needs to surface the active entity's type); for now the switcher carries through whichever ?Iri= param the URL already has, which preserves today's behavior end-to-end. Tests: 16 new (URL helper round-trip and priority order; switcher active-segment rendering, link hrefs, and IRI carry-through for all three types). Full suite: 2705 passing. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../editor/ViewerEditorSwitcher.test.tsx | 93 +++++++++++++++++++ __tests__/lib/utils/selectionUrl.test.ts | 90 ++++++++++++++++++ app/projects/[id]/editor/page.tsx | 20 ++-- app/projects/[id]/page.tsx | 25 ++--- components/editor/ViewerEditorSwitcher.tsx | 79 ++++++++++++++++ lib/utils/selectionUrl.ts | 42 +++++++++ 6 files changed, 321 insertions(+), 28 deletions(-) create mode 100644 __tests__/components/editor/ViewerEditorSwitcher.test.tsx create mode 100644 __tests__/lib/utils/selectionUrl.test.ts create mode 100644 components/editor/ViewerEditorSwitcher.tsx create mode 100644 lib/utils/selectionUrl.ts diff --git a/__tests__/components/editor/ViewerEditorSwitcher.test.tsx b/__tests__/components/editor/ViewerEditorSwitcher.test.tsx new file mode 100644 index 00000000..fc17cd53 --- /dev/null +++ b/__tests__/components/editor/ViewerEditorSwitcher.test.tsx @@ -0,0 +1,93 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/react"; + +import { ViewerEditorSwitcher } from "@/components/editor/ViewerEditorSwitcher"; + +vi.mock("next/link", () => ({ + __esModule: true, + default: ({ children, href, ...props }: Record) => ( + + {children as React.ReactNode} + + ), +})); + +let mockPathname = "/projects/proj-1"; +let mockSearch = ""; +vi.mock("next/navigation", () => ({ + usePathname: () => mockPathname, + useSearchParams: () => new URLSearchParams(mockSearch), +})); + +describe("ViewerEditorSwitcher", () => { + beforeEach(() => { + mockPathname = "/projects/proj-1"; + mockSearch = ""; + }); + + it("renders both segments with their labels", () => { + render(); + + expect(screen.getByText("Viewer")).toBeDefined(); + expect(screen.getByText("Editor")).toBeDefined(); + }); + + it("marks Viewer as active when on the viewer route", () => { + mockPathname = "/projects/proj-1"; + render(); + + // The active segment renders as a non-link + const viewerSegment = screen.getByText("Viewer").parentElement; + expect(viewerSegment?.getAttribute("aria-current")).toBe("page"); + + // Editor segment is a clickable link + const editorLink = screen.getByText("Editor").closest("a"); + expect(editorLink).not.toBeNull(); + expect(editorLink?.getAttribute("href")).toBe("/projects/proj-1/editor"); + }); + + it("marks Editor as active when on the editor route", () => { + mockPathname = "/projects/proj-1/editor"; + render(); + + const editorSegment = screen.getByText("Editor").parentElement; + expect(editorSegment?.getAttribute("aria-current")).toBe("page"); + + const viewerLink = screen.getByText("Viewer").closest("a"); + expect(viewerLink).not.toBeNull(); + expect(viewerLink?.getAttribute("href")).toBe("/projects/proj-1"); + }); + + it("carries a class IRI selection through to the destination", () => { + mockPathname = "/projects/proj-1"; + mockSearch = "classIri=" + encodeURIComponent("http://example.org/Person"); + render(); + + const editorLink = screen.getByText("Editor").closest("a"); + expect(editorLink?.getAttribute("href")).toBe( + "/projects/proj-1/editor?classIri=" + encodeURIComponent("http://example.org/Person"), + ); + }); + + it("carries a property IRI selection through to the destination", () => { + mockPathname = "/projects/proj-1/editor"; + mockSearch = "propertyIri=" + encodeURIComponent("http://example.org/hasName"); + render(); + + const viewerLink = screen.getByText("Viewer").closest("a"); + expect(viewerLink?.getAttribute("href")).toBe( + "/projects/proj-1?propertyIri=" + encodeURIComponent("http://example.org/hasName"), + ); + }); + + it("carries an individual IRI selection through to the destination", () => { + mockPathname = "/projects/proj-1"; + mockSearch = "individualIri=" + encodeURIComponent("http://example.org/alice"); + render(); + + const editorLink = screen.getByText("Editor").closest("a"); + expect(editorLink?.getAttribute("href")).toBe( + "/projects/proj-1/editor?individualIri=" + encodeURIComponent("http://example.org/alice"), + ); + }); +}); diff --git a/__tests__/lib/utils/selectionUrl.test.ts b/__tests__/lib/utils/selectionUrl.test.ts new file mode 100644 index 00000000..4bfecfb9 --- /dev/null +++ b/__tests__/lib/utils/selectionUrl.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; + +import { + buildSelectionQuery, + readSelectionFromSearchParams, + SELECTION_PARAM_BY_TYPE, +} from "@/lib/utils/selectionUrl"; + +describe("readSelectionFromSearchParams", () => { + it("reads a class IRI", () => { + const params = new URLSearchParams("classIri=http%3A%2F%2Fexample.org%2FPerson"); + expect(readSelectionFromSearchParams(params)).toEqual({ + iri: "http://example.org/Person", + type: "class", + }); + }); + + it("reads a property IRI", () => { + const params = new URLSearchParams("propertyIri=http%3A%2F%2Fexample.org%2FhasName"); + expect(readSelectionFromSearchParams(params)).toEqual({ + iri: "http://example.org/hasName", + type: "property", + }); + }); + + it("reads an individual IRI", () => { + const params = new URLSearchParams("individualIri=http%3A%2F%2Fexample.org%2Falice"); + expect(readSelectionFromSearchParams(params)).toEqual({ + iri: "http://example.org/alice", + type: "individual", + }); + }); + + it("returns null when no selection key is present", () => { + expect(readSelectionFromSearchParams(new URLSearchParams(""))).toBeNull(); + expect(readSelectionFromSearchParams(new URLSearchParams("foo=bar"))).toBeNull(); + }); + + it("honors the class > property > individual priority when more than one is set", () => { + const params = new URLSearchParams( + "individualIri=ex%3Aalice&propertyIri=ex%3AhasName&classIri=ex%3APerson", + ); + expect(readSelectionFromSearchParams(params)).toEqual({ + iri: "ex:Person", + type: "class", + }); + + const params2 = new URLSearchParams("individualIri=ex%3Aalice&propertyIri=ex%3AhasName"); + expect(readSelectionFromSearchParams(params2)).toEqual({ + iri: "ex:hasName", + type: "property", + }); + }); +}); + +describe("buildSelectionQuery", () => { + it("returns an empty string when selection is null", () => { + expect(buildSelectionQuery(null)).toBe(""); + }); + + it("returns an empty string when the IRI is empty", () => { + expect(buildSelectionQuery({ iri: "", type: "class" })).toBe(""); + }); + + it("encodes the IRI under the matching key", () => { + expect(buildSelectionQuery({ iri: "http://example.org/Person", type: "class" })).toBe( + "?classIri=http%3A%2F%2Fexample.org%2FPerson", + ); + expect(buildSelectionQuery({ iri: "http://example.org/hasName", type: "property" })).toBe( + "?propertyIri=http%3A%2F%2Fexample.org%2FhasName", + ); + expect(buildSelectionQuery({ iri: "http://example.org/alice", type: "individual" })).toBe( + "?individualIri=http%3A%2F%2Fexample.org%2Falice", + ); + }); + + it("round-trips through readSelectionFromSearchParams", () => { + const selection = { iri: "http://example.org/Person", type: "class" } as const; + const params = new URLSearchParams(buildSelectionQuery(selection).slice(1)); + expect(readSelectionFromSearchParams(params)).toEqual(selection); + }); +}); + +describe("SELECTION_PARAM_BY_TYPE", () => { + it("maps each entity type to its expected key", () => { + expect(SELECTION_PARAM_BY_TYPE.class).toBe("classIri"); + expect(SELECTION_PARAM_BY_TYPE.property).toBe("propertyIri"); + expect(SELECTION_PARAM_BY_TYPE.individual).toBe("individualIri"); + }); +}); diff --git a/app/projects/[id]/editor/page.tsx b/app/projects/[id]/editor/page.tsx index 5deec35b..45767e93 100644 --- a/app/projects/[id]/editor/page.tsx +++ b/app/projects/[id]/editor/page.tsx @@ -12,6 +12,8 @@ import { CommitMessageDialog } from "@/components/editor/CommitMessageDialog"; import { AddEntityDialog, type NewEntityInfo } from "@/components/editor/AddEntityDialog"; import { useToast } from "@/lib/context/ToastContext"; import { ModeSwitcher } from "@/components/editor/ModeSwitcher"; +import { ViewerEditorSwitcher } from "@/components/editor/ViewerEditorSwitcher"; +import { readSelectionFromSearchParams } from "@/lib/utils/selectionUrl"; import { DeveloperEditorLayout } from "@/components/editor/developer/DeveloperEditorLayout"; import { StandardEditorLayout } from "@/components/editor/standard/StandardEditorLayout"; import { BranchSelector, RevisionHistoryPanel, HistoryButton } from "@/components/revision"; @@ -51,7 +53,8 @@ export default function EditorPage() { const projectId = params.id as string; const resumeSessionParam = searchParams.get("resumeSession") || undefined; const resumeBranchParam = searchParams.get("branch") || undefined; - const classIriParam = searchParams.get("classIri"); + const initialSelection = readSelectionFromSearchParams(searchParams); + const classIriParam = initialSelection?.iri ?? null; const initialBranch = resumeBranchParam || (() => { try { return sessionStorage.getItem(`ontokit:branch:${projectId}`); } catch { return null; } })() || undefined; @@ -824,20 +827,11 @@ export default function EditorPage() {
- { - const iriToCarry = selectedIri || classIriParam; - return `/projects/${projectId}${iriToCarry ? `?classIri=${encodeURIComponent(iriToCarry)}` : ''}`; - })()} - className="flex items-center gap-2 text-sm text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-200" - > - - Close Editor - -

{project.name}

{totalClasses} classes - {/* Mode Switcher */} + {/* Viewer / Editor switcher */} + + {/* Standard / Developer mode switcher */} {/* Suggestion mode indicator */} {isSuggestionMode && ( diff --git a/app/projects/[id]/page.tsx b/app/projects/[id]/page.tsx index e96fadad..2609a886 100644 --- a/app/projects/[id]/page.tsx +++ b/app/projects/[id]/page.tsx @@ -4,11 +4,13 @@ import { useState, useEffect, useCallback, useRef } from "react"; import { useSession, signIn } from "next-auth/react"; import { useParams, useSearchParams } from "next/navigation"; import Link from "next/link"; -import { ArrowLeft, Settings, FileCode, Pencil, LogIn, LayoutDashboard } from "lucide-react"; +import { ArrowLeft, Settings, FileCode, LogIn, LayoutDashboard } from "lucide-react"; import { ShareButton } from "@/components/editor/ShareButton"; import { Header } from "@/components/layout/header"; import { Button } from "@/components/ui/button"; import { ModeSwitcher } from "@/components/editor/ModeSwitcher"; +import { ViewerEditorSwitcher } from "@/components/editor/ViewerEditorSwitcher"; +import { readSelectionFromSearchParams } from "@/lib/utils/selectionUrl"; import { DeveloperEditorLayout } from "@/components/editor/developer/DeveloperEditorLayout"; import { StandardEditorLayout } from "@/components/editor/standard/StandardEditorLayout"; import { BranchProvider, useBranch } from "@/lib/context/BranchContext"; @@ -159,7 +161,8 @@ function ViewerContent({ sessionStatus: "loading" | "authenticated" | "unauthenticated"; }) { const searchParams = useSearchParams(); - const classIriParam = searchParams.get("classIri"); + const initialSelection = readSelectionFromSearchParams(searchParams); + const classIriParam = initialSelection?.iri ?? null; const editorMode = useEditorModeStore((s) => s.editorMode); // Use the default branch from the BranchProvider context @@ -223,6 +226,7 @@ function ViewerContent({

{project?.name}

{totalClasses} classes + {canSuggest && }
@@ -240,18 +244,9 @@ function ViewerContent({ - {/* Open Editor / Sign In */} - {canSuggest ? ( - { - const iriToCarry = selectedIri || classIriParam; - return `/projects/${projectId}/editor${iriToCarry ? `?classIri=${encodeURIComponent(iriToCarry)}` : ''}`; - })()}> - - - ) : !hasValidAccess ? ( + {/* Sign In affordance for unauthenticated users — the Viewer/Editor switcher above + carries authenticated users into the editor. */} + {!canSuggest && !hasValidAccess && ( - ) : null} + )} {canManage && ( diff --git a/components/editor/ViewerEditorSwitcher.tsx b/components/editor/ViewerEditorSwitcher.tsx new file mode 100644 index 00000000..bca3a586 --- /dev/null +++ b/components/editor/ViewerEditorSwitcher.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { Eye, Pencil } from "lucide-react"; +import Link from "next/link"; +import { usePathname, useSearchParams } from "next/navigation"; +import { cn } from "@/lib/utils"; +import { + buildSelectionQuery, + readSelectionFromSearchParams, +} from "@/lib/utils/selectionUrl"; + +interface ViewerEditorSwitcherProps { + projectId: string; + className?: string; +} + +type Mode = "viewer" | "editor"; + +const modes: { value: Mode; label: string; icon: typeof Eye }[] = [ + { value: "viewer", label: "Viewer", icon: Eye }, + { value: "editor", label: "Editor", icon: Pencil }, +]; + +export function ViewerEditorSwitcher({ projectId, className }: ViewerEditorSwitcherProps) { + const pathname = usePathname(); + const searchParams = useSearchParams(); + + const isEditor = pathname?.endsWith("/editor") ?? false; + const activeMode: Mode = isEditor ? "editor" : "viewer"; + + const selection = readSelectionFromSearchParams(searchParams); + const query = buildSelectionQuery(selection); + + const hrefFor = (mode: Mode) => + mode === "editor" ? `/projects/${projectId}/editor${query}` : `/projects/${projectId}${query}`; + + return ( +
+ {modes.map(({ value, label, icon: Icon }) => { + const isActive = activeMode === value; + const classes = cn( + "flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors", + isActive + ? "bg-white text-slate-900 shadow-xs dark:bg-slate-700 dark:text-white" + : "text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white", + ); + + if (isActive) { + return ( + + + {label} + + ); + } + + return ( + + + {label} + + ); + })} +
+ ); +} diff --git a/lib/utils/selectionUrl.ts b/lib/utils/selectionUrl.ts new file mode 100644 index 00000000..9a48ca86 --- /dev/null +++ b/lib/utils/selectionUrl.ts @@ -0,0 +1,42 @@ +export type SelectableEntityType = "class" | "property" | "individual"; + +interface Selection { + iri: string; + type: SelectableEntityType; +} + +/** + * Param key per entity type. Keys are mutually exclusive in a URL — at most one should be set. + * Order matters: when more than one is present (e.g. a stale link), readers honor the first match. + * The priority is class > property > individual, matching the order entities appear in the tree. + */ +export const SELECTION_PARAM_BY_TYPE: Readonly> = Object.freeze({ + class: "classIri", + property: "propertyIri", + individual: "individualIri", +}); + +const SELECTION_TYPES_IN_PRIORITY: readonly SelectableEntityType[] = ["class", "property", "individual"]; + +/** + * Read the selection (IRI + entity type) from a URL's search params. + * Returns null when no selection key is present. + */ +export function readSelectionFromSearchParams( + searchParams: Pick, +): Selection | null { + for (const type of SELECTION_TYPES_IN_PRIORITY) { + const iri = searchParams.get(SELECTION_PARAM_BY_TYPE[type]); + if (iri) return { iri, type }; + } + return null; +} + +/** + * Build a leading-`?` query string carrying the given selection, or an empty string when no selection. + */ +export function buildSelectionQuery(selection: Selection | null): string { + if (!selection || !selection.iri) return ""; + const key = SELECTION_PARAM_BY_TYPE[selection.type]; + return `?${key}=${encodeURIComponent(selection.iri)}`; +} From 4f42883c2479f4ba302e8c0abc3097a515b7fff4 Mon Sep 17 00:00:00 2001 From: "John R. D'Orazio" Date: Tue, 28 Apr 2026 15:58:20 +0200 Subject: [PATCH 06/21] feat(editor): make ViewerEditorSwitcher type-aware via selection store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a small in-session Zustand store (lib/stores/selectionStore) that tracks the currently active selection as { iri, type }, with type being one of class | property | individual. The store is not persisted — on a full page reload the URL search params still drive initial state. Both StandardEditorLayout and DeveloperEditorLayout now mirror their active-tab selection into this store via a single useEffect that maps (activeTab, selectedIri/selectedPropertyIri/selectedIndividualIri) to the corresponding (iri, type) tuple. Layouts also clear the store on unmount to avoid leaking selection state between projects. ViewerEditorSwitcher now prefers the store over the URL when synthesizing its destination link. The URL fallback remains for the initial render (before any layout effect has run) and for users who arrive on a route via a deep link. This closes the gap from the previous commit: previously the switcher could only round-trip whatever ?Iri= key the URL already carried, so a user who selected a property in the viewer and clicked "Editor" would get ?classIri= synthesized from the URL fallback (or no param at all). Now the switcher emits ?propertyIri= correctly, disambiguating OWL-punned IRIs across the viewer/editor boundary. Tests: - 4 new for the store (initial state, set, clear, null/null). - 2 new for the switcher (store preferred over URL; URL fallback when store is empty). - Existing 16 selection-URL/switcher tests still pass. - Full suite: 2711 passing (was 2705). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../editor/ViewerEditorSwitcher.test.tsx | 29 +++++++++++ __tests__/lib/stores/selectionStore.test.ts | 48 +++++++++++++++++++ components/editor/ViewerEditorSwitcher.tsx | 11 ++++- .../developer/DeveloperEditorLayout.tsx | 17 +++++++ .../editor/standard/StandardEditorLayout.tsx | 17 +++++++ lib/stores/selectionStore.ts | 24 ++++++++++ 6 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 __tests__/lib/stores/selectionStore.test.ts create mode 100644 lib/stores/selectionStore.ts diff --git a/__tests__/components/editor/ViewerEditorSwitcher.test.tsx b/__tests__/components/editor/ViewerEditorSwitcher.test.tsx index fc17cd53..65f9b2ba 100644 --- a/__tests__/components/editor/ViewerEditorSwitcher.test.tsx +++ b/__tests__/components/editor/ViewerEditorSwitcher.test.tsx @@ -2,6 +2,7 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; import { render, screen } from "@testing-library/react"; import { ViewerEditorSwitcher } from "@/components/editor/ViewerEditorSwitcher"; +import { useSelectionStore } from "@/lib/stores/selectionStore"; vi.mock("next/link", () => ({ __esModule: true, @@ -23,6 +24,7 @@ describe("ViewerEditorSwitcher", () => { beforeEach(() => { mockPathname = "/projects/proj-1"; mockSearch = ""; + useSelectionStore.getState().clear(); }); it("renders both segments with their labels", () => { @@ -90,4 +92,31 @@ describe("ViewerEditorSwitcher", () => { "/projects/proj-1/editor?individualIri=" + encodeURIComponent("http://example.org/alice"), ); }); + + it("prefers the selection store over URL params when both are set", () => { + mockPathname = "/projects/proj-1"; + // URL says one thing — typically a stale initial-load value + mockSearch = "classIri=" + encodeURIComponent("http://example.org/Stale"); + // …but the user has since selected a property in this session + useSelectionStore.getState().setSelection("http://example.org/hasName", "property"); + + render(); + + const editorLink = screen.getByText("Editor").closest("a"); + expect(editorLink?.getAttribute("href")).toBe( + "/projects/proj-1/editor?propertyIri=" + encodeURIComponent("http://example.org/hasName"), + ); + }); + + it("falls back to URL params when the selection store is empty", () => { + mockPathname = "/projects/proj-1"; + mockSearch = "individualIri=" + encodeURIComponent("http://example.org/alice"); + // store is cleared by beforeEach + render(); + + const editorLink = screen.getByText("Editor").closest("a"); + expect(editorLink?.getAttribute("href")).toBe( + "/projects/proj-1/editor?individualIri=" + encodeURIComponent("http://example.org/alice"), + ); + }); }); diff --git a/__tests__/lib/stores/selectionStore.test.ts b/__tests__/lib/stores/selectionStore.test.ts new file mode 100644 index 00000000..41022211 --- /dev/null +++ b/__tests__/lib/stores/selectionStore.test.ts @@ -0,0 +1,48 @@ +import { beforeEach, describe, expect, it } from "vitest"; + +import { useSelectionStore } from "@/lib/stores/selectionStore"; + +describe("useSelectionStore", () => { + beforeEach(() => { + useSelectionStore.getState().clear(); + }); + + it("starts with no selection", () => { + const state = useSelectionStore.getState(); + expect(state.iri).toBeNull(); + expect(state.type).toBeNull(); + }); + + it("setSelection records both iri and type", () => { + useSelectionStore.getState().setSelection("ex:Person", "class"); + expect(useSelectionStore.getState()).toMatchObject({ + iri: "ex:Person", + type: "class", + }); + + useSelectionStore.getState().setSelection("ex:hasName", "property"); + expect(useSelectionStore.getState()).toMatchObject({ + iri: "ex:hasName", + type: "property", + }); + + useSelectionStore.getState().setSelection("ex:alice", "individual"); + expect(useSelectionStore.getState()).toMatchObject({ + iri: "ex:alice", + type: "individual", + }); + }); + + it("clear resets back to null/null", () => { + useSelectionStore.getState().setSelection("ex:Person", "class"); + useSelectionStore.getState().clear(); + expect(useSelectionStore.getState().iri).toBeNull(); + expect(useSelectionStore.getState().type).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(); + }); +}); diff --git a/components/editor/ViewerEditorSwitcher.tsx b/components/editor/ViewerEditorSwitcher.tsx index bca3a586..b33e0d4d 100644 --- a/components/editor/ViewerEditorSwitcher.tsx +++ b/components/editor/ViewerEditorSwitcher.tsx @@ -4,6 +4,7 @@ import { Eye, Pencil } from "lucide-react"; import Link from "next/link"; import { usePathname, useSearchParams } from "next/navigation"; import { cn } from "@/lib/utils"; +import { useSelectionStore } from "@/lib/stores/selectionStore"; import { buildSelectionQuery, readSelectionFromSearchParams, @@ -28,7 +29,15 @@ export function ViewerEditorSwitcher({ projectId, className }: ViewerEditorSwitc const isEditor = pathname?.endsWith("/editor") ?? false; const activeMode: Mode = isEditor ? "editor" : "viewer"; - const selection = readSelectionFromSearchParams(searchParams); + // Prefer the in-memory selection store (reflects the active-tab selection + // chosen by the user in this session). Fall back to URL params on initial + // load, before any layout effect has populated the store. + const storeIri = useSelectionStore((s) => s.iri); + const storeType = useSelectionStore((s) => s.type); + const selection = + storeIri && storeType + ? { iri: storeIri, type: storeType } + : readSelectionFromSearchParams(searchParams); const query = buildSelectionQuery(selection); const hrefFor = (mode: Mode) => diff --git a/components/editor/developer/DeveloperEditorLayout.tsx b/components/editor/developer/DeveloperEditorLayout.tsx index 3676d45d..f5882a74 100644 --- a/components/editor/developer/DeveloperEditorLayout.tsx +++ b/components/editor/developer/DeveloperEditorLayout.tsx @@ -26,6 +26,7 @@ import type { ClassTreeNode } from "@/lib/ontology/types"; import type { OntologySourceEditorRef } from "@/components/editor/OntologySourceEditor"; import type { IriPosition } from "@/lib/editor/indexWorker"; import { useDraftStore } from "@/lib/stores/draftStore"; +import { useSelectionStore } from "@/lib/stores/selectionStore"; import { getLocalName } from "@/lib/utils"; import { extractTreeLabelMap } from "@/lib/graph/buildGraphData"; import { useAnnounce } from "@/components/ui/ScreenReaderAnnouncer"; @@ -285,6 +286,22 @@ 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. + const setSelection = useSelectionStore((s) => s.setSelection); + const clearSelection = useSelectionStore((s) => s.clear); + useEffect(() => { + if (activeTab === "classes") { + setSelection(selectedIri ?? null, selectedIri ? "class" : null); + } else if (activeTab === "properties") { + setSelection(selectedPropertyIri ?? null, selectedPropertyIri ? "property" : null); + } else { + setSelection(selectedIndividualIri ?? null, selectedIndividualIri ? "individual" : null); + } + }, [activeTab, selectedIri, selectedPropertyIri, selectedIndividualIri, setSelection]); + useEffect(() => () => clearSelection(), [clearSelection]); + // Shared search state const { showSearch, diff --git a/components/editor/standard/StandardEditorLayout.tsx b/components/editor/standard/StandardEditorLayout.tsx index 38175df4..41e2f643 100644 --- a/components/editor/standard/StandardEditorLayout.tsx +++ b/components/editor/standard/StandardEditorLayout.tsx @@ -17,6 +17,7 @@ import { Share2, ArrowLeft } from "lucide-react"; import { DraggableTreeWrapper } from "@/components/editor/shared/DraggableTreeWrapper"; import { useTreeDragDrop, type DragMode } from "@/lib/hooks/useTreeDragDrop"; import { useToast } from "@/lib/context/ToastContext"; +import { useSelectionStore } from "@/lib/stores/selectionStore"; const OntologyGraph = dynamic( () => import("@/components/graph/OntologyGraph").then((mod) => mod.OntologyGraph), @@ -250,6 +251,22 @@ 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. + const setSelection = useSelectionStore((s) => s.setSelection); + const clearSelection = useSelectionStore((s) => s.clear); + useEffect(() => { + if (activeTab === "classes") { + setSelection(selectedIri ?? null, selectedIri ? "class" : null); + } else if (activeTab === "properties") { + setSelection(selectedPropertyIri ?? null, selectedPropertyIri ? "property" : null); + } else { + setSelection(selectedIndividualIri ?? null, selectedIndividualIri ? "individual" : null); + } + }, [activeTab, selectedIri, selectedPropertyIri, selectedIndividualIri, setSelection]); + useEffect(() => () => clearSelection(), [clearSelection]); + // Shared search state const { showSearch, diff --git a/lib/stores/selectionStore.ts b/lib/stores/selectionStore.ts new file mode 100644 index 00000000..cd6fec3f --- /dev/null +++ b/lib/stores/selectionStore.ts @@ -0,0 +1,24 @@ +import { create } from "zustand"; + +import type { SelectableEntityType } from "@/lib/utils/selectionUrl"; + +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; + setSelection: (iri: string | null, type: SelectableEntityType | null) => 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. + */ +export const useSelectionStore = create()((set) => ({ + iri: null, + type: null, + setSelection: (iri, type) => set({ iri, type }), + clear: () => set({ iri: null, type: null }), +})); From 828ca9b07bc560b8486e48a1fbafc61385745bff Mon Sep 17 00:00:00 2001 From: "John R. D'Orazio" Date: Tue, 28 Apr 2026 16:08:33 +0200 Subject: [PATCH 07/21] fix: address CodeRabbit findings on selection plumbing and a11y MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ClassDetailPanel: reset showParentPicker on cancel and on classIri change. The transient picker overlay used to stay open when the user discarded their draft or navigated to a different class — both flows now clear it. ViewerEditorSwitcher: drop aria-pressed from both the active span and the inactive Link. aria-pressed is for toggle-button widgets; this control is navigation. aria-current="page" on the active item is the correct contract on its own. PropertyDetailPanel + IndividualDetailPanel: tighten the auto-enter useEffect's early return to also gate on onUpdateProperty / onUpdateIndividual. Previously the restored-draft branch could enter edit mode in viewer context (no onUpdate handler), where useEntityAutoSave.onFlush is undefined — saves would silently no-op. ClassDetailPanel already had this guard; this brings the other two panels in line. The redundant inner if (onUpdate*) check before the final enterEditMode() is removed since the early return guarantees it. Tests: new "closes the parent picker when the user cancels" spec for ClassDetailPanel. Full suite: 2712 passing. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../editor/ClassDetailPanel.test.tsx | 30 +++++++++++++++++++ components/editor/ClassDetailPanel.tsx | 2 ++ components/editor/IndividualDetailPanel.tsx | 6 ++-- components/editor/PropertyDetailPanel.tsx | 6 ++-- components/editor/ViewerEditorSwitcher.tsx | 3 +- 5 files changed, 37 insertions(+), 10 deletions(-) diff --git a/__tests__/components/editor/ClassDetailPanel.test.tsx b/__tests__/components/editor/ClassDetailPanel.test.tsx index 48215cca..50076c18 100644 --- a/__tests__/components/editor/ClassDetailPanel.test.tsx +++ b/__tests__/components/editor/ClassDetailPanel.test.tsx @@ -792,6 +792,36 @@ describe("ClassDetailPanel", () => { }); }); + it("closes the parent picker when the user cancels", async () => { + const user = userEvent.setup(); + const onUpdateClass = vi.fn(); + render( + + ); + + await waitFor(() => { + expect(screen.getByText("Add parent")).not.toBeNull(); + }); + + // Open the parent picker + await user.click(screen.getByText("Add parent")); + await waitFor(() => { + expect(screen.queryByText("Add parent")).toBeNull(); + }); + + // Cancel via the AutoSaveAffordanceBar stub + await user.click(screen.getByTestId("cancel-edit")); + + // The picker should close — Add parent button reappears + await waitFor(() => { + expect(screen.getByText("Add parent")).not.toBeNull(); + }); + }); + // ── Save flow ── it("triggers save on label input blur", async () => { diff --git a/components/editor/ClassDetailPanel.tsx b/components/editor/ClassDetailPanel.tsx index eee3eee5..3570b65f 100644 --- a/components/editor/ClassDetailPanel.tsx +++ b/components/editor/ClassDetailPanel.tsx @@ -167,6 +167,7 @@ export function ClassDetailPanel({ setResolvedTargetLabels({}); editInitializedRef.current = false; setIsEditing(false); + setShowParentPicker(false); }, [classIri]); // eslint-disable-line react-hooks/exhaustive-deps // Enter edit mode: initialize edit state from classDetail @@ -184,6 +185,7 @@ export function ClassDetailPanel({ if (classDetail) { initEditState(classDetail); } + setShowParentPicker(false); }, [classDetail, discardDraft]); // eslint-disable-line react-hooks/exhaustive-deps // Manual save: flush the current draft to git. Stays in edit mode. diff --git a/components/editor/IndividualDetailPanel.tsx b/components/editor/IndividualDetailPanel.tsx index ec37b7f0..1fa69724 100644 --- a/components/editor/IndividualDetailPanel.tsx +++ b/components/editor/IndividualDetailPanel.tsx @@ -268,7 +268,7 @@ export function IndividualDetailPanel({ useEffect(() => { if (isEditing || editInitializedRef.current) return; - if (!canEdit || !detail) return; + if (!canEdit || !onUpdateIndividual || !detail) return; if (restoredDraft && restoredDraft.entityType === "individual" && individualIri) { const d = restoredDraft as IndividualDraftEntry; @@ -288,9 +288,7 @@ export function IndividualDetailPanel({ return; } - if (onUpdateIndividual) { - enterEditMode(); - } + enterEditMode(); }, [detail, canEdit, restoredDraft, individualIri, clearRestoredDraft, onUpdateIndividual, isEditing, enterEditMode]); // ── Edit helpers ── diff --git a/components/editor/PropertyDetailPanel.tsx b/components/editor/PropertyDetailPanel.tsx index 2875006c..624fbd94 100644 --- a/components/editor/PropertyDetailPanel.tsx +++ b/components/editor/PropertyDetailPanel.tsx @@ -282,7 +282,7 @@ export function PropertyDetailPanel({ // Auto-enter edit mode useEffect(() => { if (isEditing || editInitializedRef.current) return; - if (!canEdit || !detail) return; + if (!canEdit || !onUpdateProperty || !detail) return; if (restoredDraft && restoredDraft.entityType === "property" && propertyIri) { const d = restoredDraft as PropertyDraftEntry; @@ -303,9 +303,7 @@ export function PropertyDetailPanel({ return; } - if (onUpdateProperty) { - enterEditMode(); - } + enterEditMode(); }, [detail, canEdit, restoredDraft, propertyIri, clearRestoredDraft, onUpdateProperty, isEditing, enterEditMode]); // ── Edit helpers ── diff --git a/components/editor/ViewerEditorSwitcher.tsx b/components/editor/ViewerEditorSwitcher.tsx index b33e0d4d..85d803da 100644 --- a/components/editor/ViewerEditorSwitcher.tsx +++ b/components/editor/ViewerEditorSwitcher.tsx @@ -63,7 +63,7 @@ export function ViewerEditorSwitcher({ projectId, className }: ViewerEditorSwitc if (isActive) { return ( - + {label} @@ -75,7 +75,6 @@ export function ViewerEditorSwitcher({ projectId, className }: ViewerEditorSwitc key={value} href={hrefFor(value)} aria-label={label} - aria-pressed="false" className={classes} > From ebba6faa172c58dcf6c2919dc6b32eff3ca35c8e Mon Sep 17 00:00:00 2001 From: "John R. D'Orazio" Date: Tue, 28 Apr 2026 16:21:50 +0200 Subject: [PATCH 08/21] chore(lint): clean up tractable lint warnings (39 -> 16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit eslint.config.mjs: ignore .worktrees/, coverage/, .claude/, .serena/. ESLint was scanning the embedded chore-add-serena-memories worktree and the generated coverage/lcov-report output, which inflated the warning count by ~half through duplicates and false positives in generated code. EntityTree: hoist scrollToIri above handleKeyDown and wrap it in useCallback. The previous declaration order caused ESLint to flag it as "Cannot access variable before it is declared" — a temporal-dead-zone warning at the point where handleKeyDown closed over the still-undefined const. Hoisting + useCallback also lets handleKeyDown declare scrollToIri as a dependency cleanly. TurtleEditor.test.tsx: silence the missing-deps warnings on the mock's beforeMount/onMount effects with localized eslint-disable-line comments on the deps array. Including props.beforeMount/onMount as deps would re-fire those effects on every render and double-invoke the mock, breaking the existing "calls onReady ... times" assertions; the mock deliberately uses an empty deps array with a manual ref-equality guard to mirror monaco-react's own once-per-instance behavior. Added an explanatory comment so future readers don't try to "fix" it again. The remaining 16 warnings are all react-hooks/set-state-in-effect. That rule is explicitly TODO'd in eslint.config.mjs:9-11 by the maintainer ("address these new react-hooks rules from eslint-config-next 16") — each instance requires careful effect refactoring (derived state, refs, or event-handler relocation) and is out of scope for this branch focused on issue #98 entity selection. Full suite: 2712 tests passing. Co-Authored-By: Claude Opus 4.7 (1M context) --- __tests__/components/editor/TurtleEditor.test.tsx | 10 ++++++++-- components/editor/shared/EntityTree.tsx | 14 +++++++------- eslint.config.mjs | 12 +++++++++++- 3 files changed, 26 insertions(+), 10 deletions(-) diff --git a/__tests__/components/editor/TurtleEditor.test.tsx b/__tests__/components/editor/TurtleEditor.test.tsx index dfb6c419..3b920f39 100644 --- a/__tests__/components/editor/TurtleEditor.test.tsx +++ b/__tests__/components/editor/TurtleEditor.test.tsx @@ -36,6 +36,12 @@ vi.mock("@monaco-editor/react", async () => { // eslint-disable-next-line react-hooks/globals capturedProps = props; + // The mock fires beforeMount/onMount exactly once per editor instance, + // mirroring monaco-react's own behavior. We deliberately use an empty + // deps array (with a manual ref-equality guard) instead of including + // props.beforeMount/onMount, because re-running these effects on every + // re-render would produce duplicate beforeMount/onMount invocations and + // break tests that assert call count. React.useEffect(() => { if (typeof props.beforeMount === "function" && capturedBeforeMount !== props.beforeMount) { capturedBeforeMount = props.beforeMount as (monaco: unknown) => void; @@ -55,7 +61,7 @@ vi.mock("@monaco-editor/react", async () => { }, }); } - }, []); + }, []); // eslint-disable-line react-hooks/exhaustive-deps React.useEffect(() => { if (typeof props.onMount === "function" && capturedOnMount !== props.onMount) { @@ -80,7 +86,7 @@ vi.mock("@monaco-editor/react", async () => { }; (props.onMount as (e: unknown, m: unknown) => void)(mockEditor, mockMonaco); } - }, []); + }, []); // eslint-disable-line react-hooks/exhaustive-deps return React.createElement("div", { "data-testid": "monaco-editor", diff --git a/components/editor/shared/EntityTree.tsx b/components/editor/shared/EntityTree.tsx index e4ab1def..20ec2391 100644 --- a/components/editor/shared/EntityTree.tsx +++ b/components/editor/shared/EntityTree.tsx @@ -97,6 +97,12 @@ export function EntityTree({ const visibleIris = useMemo(() => flattenVisible(nodes), [nodes]); + const scrollToIri = useCallback((iri: string) => { + if (!containerRef.current) return; + const el = containerRef.current.querySelector(`[data-iri="${CSS.escape(iri)}"]`); + el?.scrollIntoView({ block: "nearest" }); + }, []); + const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (!enableKeyboardNav) return; @@ -166,15 +172,9 @@ export function EntityTree({ } } }, - [enableKeyboardNav, focusedIri, selectedIri, visibleIris, nodes, onExpand, onCollapse, onSelect], + [enableKeyboardNav, focusedIri, selectedIri, visibleIris, nodes, onExpand, onCollapse, onSelect, scrollToIri], ); - const scrollToIri = (iri: string) => { - if (!containerRef.current) return; - const el = containerRef.current.querySelector(`[data-iri="${CSS.escape(iri)}"]`); - el?.scrollIntoView({ block: "nearest" }); - }; - const activeDescendantId = focusedIri ? `tree-item-${focusedIri.replace(/[^a-zA-Z0-9-_]/g, "_")}` : undefined; diff --git a/eslint.config.mjs b/eslint.config.mjs index 52d214c8..591291d4 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -21,7 +21,17 @@ const eslintConfig = [ }, }, { - ignores: ["node_modules/**", ".next/**", "out/**", "build/**", "next-env.d.ts"], + ignores: [ + "node_modules/**", + ".next/**", + "out/**", + "build/**", + "coverage/**", + ".worktrees/**", + ".claude/**", + ".serena/**", + "next-env.d.ts", + ], }, ]; From 8218313f904ac11f0cb9b1e821f8d3bfcd29991f Mon Sep 17 00:00:00 2001 From: "John R. D'Orazio" Date: Tue, 28 Apr 2026 16:54:22 +0200 Subject: [PATCH 09/21] fix(viewer/editor): dispatch property/individual URL params via entityNavigationRef MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The viewer and editor pages restored selection from the URL by always calling navigateToNode(iri), which is class-tree-only. Selecting a property in the viewer's Properties tree and switching to the editor landed in the Classes tab with a "Could not load X as an OWL Class, or a Property, or an Individual" error — the property IRI was being looked up as a class. The layouts already expose entityNavigationRef, an imperative handler that knows how to switch the active tab and select the right per-tab IRI. The editor page already creates this ref but didn't use it for URL restoration; the viewer page didn't have it at all. - Both pages now branch the URL-restoration effect on initialSelection.type: - "class": existing path (waits for class tree, calls navigateToNode). - "property" / "individual": dispatches via entityNavigationRef.current(iri, type), which sets activeTab and the per-tab selection in the layout. - Viewer page gains its own entityNavigationRef and passes it to both StandardEditorLayout and DeveloperEditorLayout. - Editor page drops the unused classIriParam local in favor of reading initialSelection directly. Effect timing: layouts mount before the parent page's effect runs in each commit cycle (children-first effect order), and on the first render where ViewerContent / the post-loading editor is shown, the layout's entityNavigationRef-population effect runs before the page's URL-restoration effect. So the ref is reliably populated by the time we dispatch through it. Full suite: 2712 tests passing, type-check clean, lint 0 errors. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/projects/[id]/editor/page.tsx | 24 ++++++++++++++++-------- app/projects/[id]/page.tsx | 22 ++++++++++++++++------ 2 files changed, 32 insertions(+), 14 deletions(-) diff --git a/app/projects/[id]/editor/page.tsx b/app/projects/[id]/editor/page.tsx index 45767e93..1bcd385e 100644 --- a/app/projects/[id]/editor/page.tsx +++ b/app/projects/[id]/editor/page.tsx @@ -54,7 +54,6 @@ export default function EditorPage() { const resumeSessionParam = searchParams.get("resumeSession") || undefined; const resumeBranchParam = searchParams.get("branch") || undefined; const initialSelection = readSelectionFromSearchParams(searchParams); - const classIriParam = initialSelection?.iri ?? null; const initialBranch = resumeBranchParam || (() => { try { return sessionStorage.getItem(`ontokit:branch:${projectId}`); } catch { return null; } })() || undefined; @@ -92,19 +91,28 @@ export default function EditorPage() { resetSourceState, } = viewer; - // Restore selected class from URL query param once tree is ready - useEffect(() => { - if (!classIriParam || isTreeLoading || !nodes.length) return; - if (selectedIri === classIriParam) return; - navigateToNode(classIriParam); - }, [classIriParam, isTreeLoading, nodes.length, selectedIri, navigateToNode]); - // UI state (editor-only) const [showHistory, setShowHistory] = useState(false); const [showHealthCheck, setShowHealthCheck] = useState(false); const sourceEditorRef = useRef(null); const entityNavigationRef = useRef<((iri: string, type?: string) => void) | null>(null); + // Restore selected entity from URL query param. For classes we wait for the + // class tree to load; for properties and individuals we dispatch through the + // layout's nav handler (entityNavigationRef), which switches the active tab + // and selects the entity directly. + useEffect(() => { + if (!initialSelection) return; + if (initialSelection.type === "class") { + if (isTreeLoading || !nodes.length) return; + if (selectedIri === initialSelection.iri) return; + navigateToNode(initialSelection.iri); + return; + } + if (isLoading) return; // layout not yet mounted + entityNavigationRef.current?.(initialSelection.iri, initialSelection.type); + }, [initialSelection, isTreeLoading, nodes.length, selectedIri, navigateToNode, isLoading]); + // Commit dialog const [commitDialogOpen, setCommitDialogOpen] = useState(false); const [pendingSaveContent, setPendingSaveContent] = useState(null); diff --git a/app/projects/[id]/page.tsx b/app/projects/[id]/page.tsx index 2609a886..df71ab91 100644 --- a/app/projects/[id]/page.tsx +++ b/app/projects/[id]/page.tsx @@ -162,7 +162,6 @@ function ViewerContent({ }) { const searchParams = useSearchParams(); const initialSelection = readSelectionFromSearchParams(searchParams); - const classIriParam = initialSelection?.iri ?? null; const editorMode = useEditorModeStore((s) => s.editorMode); // Use the default branch from the BranchProvider context @@ -189,13 +188,22 @@ function ViewerContent({ const sourceEditorRef = useRef(null); const [pendingScrollIri, setPendingScrollIri] = useState(null); + const entityNavigationRef = useRef<((iri: string, type?: string) => void) | null>(null); - // Navigate to classIri from URL query param once tree is ready + // Restore selected entity from URL query param. Class navigation goes + // through the class tree once it's loaded; properties and individuals are + // dispatched through the layout's nav handler so the active tab and + // per-tab selection are set correctly. useEffect(() => { - if (!classIriParam || isTreeLoading || !nodes.length) return; - if (selectedIri === classIriParam) return; - navigateToNode(classIriParam); - }, [classIriParam, isTreeLoading, nodes.length, selectedIri, navigateToNode]); + if (!initialSelection) return; + if (initialSelection.type === "class") { + if (isTreeLoading || !nodes.length) return; + if (selectedIri === initialSelection.iri) return; + navigateToNode(initialSelection.iri); + return; + } + entityNavigationRef.current?.(initialSelection.iri, initialSelection.type); + }, [initialSelection, isTreeLoading, nodes.length, selectedIri, navigateToNode]); const toast = useToast(); const handleCopyIri = useCallback(async (iri: string) => { @@ -296,6 +304,7 @@ function ViewerContent({ hasExpandedNodes={hasExpandedNodes} isExpandingAll={isExpandingAll} navigateToNode={navigateToNode} + entityNavigationRef={entityNavigationRef} sourceContent={sourceContent} setSourceContent={setSourceContent as (content: string | ((prev: string) => string)) => void} isLoadingSource={isLoadingSource} @@ -336,6 +345,7 @@ function ViewerContent({ hasExpandedNodes={hasExpandedNodes} isExpandingAll={isExpandingAll} navigateToNode={navigateToNode} + entityNavigationRef={entityNavigationRef} onAddEntity={noop} onCopyIri={handleCopyIri} selectedNodeFallback={selectedNodeFallback} From 26bd9079d0a0ffa381881521a73cb652e160ea93 Mon Sep 17 00:00:00 2001 From: "John R. D'Orazio" Date: Tue, 28 Apr 2026 17:10:13 +0200 Subject: [PATCH 10/21] perf(useIriLabels): skip class probe for external-vocabulary IRIs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hook resolves IRI labels via a probe-then-fallback strategy: getClassDetail first (fastest for classes), searchEntities on 404. For IRIs from well-known external vocabularies (skos, rdfs, owl, dcterms, xsd, foaf, prov, dc) neither endpoint can resolve them — those IRIs live outside the project ontology — so the probe is a guaranteed waste of a network round-trip and pollutes the dev console with 404s that the code already silently catches. This was particularly visible after the Viewer→Editor switcher landed: opening a property panel in the editor would log six or more 404s for common annotation properties (skos:prefLabel, skos:altLabel) and property roots (owl:topObjectProperty) before the hook fell through to search and the panel rendered correctly. Add a small skip-list for those well-known external prefixes and short-circuit the probe before the network call. The caller still falls back to getLocalName for a sensible default when no label is resolved, which is the same behavior as the old "search returned no match" branch. Tests: 6 new (parametrized) — confirms each external IRI bypasses both endpoints; one regression test confirms project-internal IRIs still hit the class probe. Full suite: 2718 passing. Note: this commit is staged but NOT pushed pending CodeRabbit's review of the rest of the branch. Co-Authored-By: Claude Opus 4.7 (1M context) --- __tests__/lib/hooks/useIriLabels.test.ts | 39 ++++++++++++++++++++++++ lib/hooks/useIriLabels.ts | 32 +++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/__tests__/lib/hooks/useIriLabels.test.ts b/__tests__/lib/hooks/useIriLabels.test.ts index ee7525ab..5cd1fb28 100644 --- a/__tests__/lib/hooks/useIriLabels.test.ts +++ b/__tests__/lib/hooks/useIriLabels.test.ts @@ -174,4 +174,43 @@ describe("useIriLabels", () => { // No label resolved, but no crash expect(result.current["http://example.org/Unknown"]).toBeUndefined(); }); + + it.each([ + "http://www.w3.org/2004/02/skos/core#prefLabel", + "http://www.w3.org/2000/01/rdf-schema#label", + "http://www.w3.org/2002/07/owl#topObjectProperty", + "http://purl.org/dc/terms/hasVersion", + "http://www.w3.org/2001/XMLSchema#string", + ])("skips both class probe and search for external-vocabulary IRI %s", async (iri) => { + mockedGetClassDetail.mockResolvedValue({ labels: [{ value: "should-not", language: "en" }] }); + mockedSearchEntities.mockResolvedValue({ results: [] }); + + const { result } = renderHook(() => + useIriLabels([iri], { projectId: "proj-1", accessToken: "token" }), + ); + + // Give the effect a chance to run + await new Promise((r) => setTimeout(r, 50)); + + expect(mockedGetClassDetail).not.toHaveBeenCalled(); + expect(mockedSearchEntities).not.toHaveBeenCalled(); + expect(result.current[iri]).toBeUndefined(); + }); + + it("still resolves project-internal IRIs that aren't in the external skip-list", async () => { + mockedGetClassDetail.mockResolvedValue({ labels: [{ value: "Person", language: "en" }] }); + mockedSearchEntities.mockResolvedValue({ results: [] }); + + const { result } = renderHook(() => + useIriLabels(["https://ontology.example.org/Person"], { + projectId: "proj-1", + accessToken: "token", + }), + ); + + await waitFor(() => { + expect(result.current["https://ontology.example.org/Person"]).toBe("Person"); + }); + expect(mockedGetClassDetail).toHaveBeenCalled(); + }); }); diff --git a/lib/hooks/useIriLabels.ts b/lib/hooks/useIriLabels.ts index 0e1111f8..5b650c04 100644 --- a/lib/hooks/useIriLabels.ts +++ b/lib/hooks/useIriLabels.ts @@ -2,6 +2,32 @@ import { useState, useEffect, useRef } from "react"; import { projectOntologyApi } from "@/lib/api/client"; import { getPreferredLabel, getLocalName } from "@/lib/utils"; +/** + * IRIs whose namespace belongs to a well-known external vocabulary will never + * resolve via the project's class/property/individual endpoints — and the + * project's own searchEntities endpoint won't find them either. Skipping the + * probe for these saves a round-trip and eliminates noisy 404s in the console + * while still letting getLocalName provide a sensible default label. + */ +const EXTERNAL_VOCABULARY_PREFIXES: readonly string[] = [ + "http://www.w3.org/1999/02/22-rdf-syntax-ns#", + "http://www.w3.org/2000/01/rdf-schema#", + "http://www.w3.org/2001/XMLSchema#", + "http://www.w3.org/2002/07/owl#", + "http://www.w3.org/2004/02/skos/core#", + "http://purl.org/dc/elements/1.1/", + "http://purl.org/dc/terms/", + "http://xmlns.com/foaf/0.1/", + "http://www.w3.org/ns/prov#", +]; + +function isExternalVocabularyIri(iri: string): boolean { + for (const prefix of EXTERNAL_VOCABULARY_PREFIXES) { + if (iri.startsWith(prefix)) return true; + } + return false; +} + /** * Async-resolve rdfs:labels for a set of IRIs. * @@ -54,6 +80,12 @@ export function useIriLabels( const batch = unresolved.slice(i, i + 10); await Promise.all( batch.map(async (iri) => { + // External-vocabulary IRIs (skos, rdfs, owl, dcterms, …) live + // outside the project ontology, so neither the class endpoint nor + // the project search can resolve them. Skip the probe entirely + // and let the caller fall back on getLocalName. + if (isExternalVocabularyIri(iri)) return; + // 1. Try class endpoint (fastest for classes) try { const detail = await projectOntologyApi.getClassDetail( From b3a964dccad3c1227cd040bfe2044de155024563 Mon Sep 17 00:00:00 2001 From: "John R. D'Orazio" Date: Tue, 28 Apr 2026 17:32:07 +0200 Subject: [PATCH 11/21] fix: address CodeRabbit findings on URL restore + a11y + naming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Memoize the URL-derived initialSelection in both viewer and editor pages. The previous code called readSelectionFromSearchParams on every render, returning a fresh object identity and re-firing the URL-restore effect. Combined with the lack of a one-shot guard, an in-page selection change (which doesn't update the URL) could be clobbered on the next render that touched any of the effect's deps. Now: useMemo keyed on searchParams.toString() gives a stable selection reference, and a consumedSelectionRef tracks the `${type}:${iri}` we've already applied so each unique URL selection is consumed at most once per page-instance. If the URL changes mid-session, the new key is applied; otherwise the user's in-page selection is left alone. - ViewerEditorSwitcher: add aria-label={label} to the active-mode . The visible label is hidden via `display:none` on small screens (className="hidden sm:inline"), which screen readers also honor — leaving icon-only active elements without an accessible name. The inactive Link already carries aria-label; this brings the active branch in line. - Rename saveAndExitEditMode → flushDraftToGit across all three detail panels (Class, Property, Individual) and their tests. The function no longer exits edit mode (since editor is now always in edit mode), so the old name was misleading. - Dedupe two redundant ClassDetailPanel auto-enter tests: "auto-enters edit mode when canEdit is true and onUpdateClass provided" and "auto-enters edit mode when onUpdateClass is provided" both asserted the same thing as "auto-enters edit mode and renders inputs when onUpdateClass is provided" (which has stronger assertions on the rendered input). Kept the strongest one. Rejected: the suggestion-mode test addition for ClassDetailPanel. ClassDetailPanel no longer reads isSuggestionMode internally — that prop was destructured for the old "Suggest Changes"/"Edit Item" button label, which I removed when removing the Edit Item affordance. A test toggling the prop on this component would assert nothing observable. The intended layout-level coverage of canEdit||isSuggestionMode lives in StandardEditorLayout/DeveloperEditorLayout; if that needs deeper tests, the right place is those layouts' test files. Full suite: 2716 tests passing (was 2718 — minus the two duplicates). Type-check clean. Lint 0 errors / 16 pre-existing warnings (issue #200). Note: not pushing pending CodeRabbit's review of the rest of the branch. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../editor/ClassDetailPanel.test.tsx | 36 +------------------ .../editor/IndividualDetailPanel.test.tsx | 4 +-- .../editor/PropertyDetailPanel.test.tsx | 4 +-- app/projects/[id]/editor/page.tsx | 30 +++++++++++++--- app/projects/[id]/page.tsx | 27 +++++++++++--- components/editor/ClassDetailPanel.tsx | 4 +-- components/editor/IndividualDetailPanel.tsx | 4 +-- components/editor/PropertyDetailPanel.tsx | 4 +-- components/editor/ViewerEditorSwitcher.tsx | 2 +- 9 files changed, 59 insertions(+), 56 deletions(-) diff --git a/__tests__/components/editor/ClassDetailPanel.test.tsx b/__tests__/components/editor/ClassDetailPanel.test.tsx index 50076c18..233a2d12 100644 --- a/__tests__/components/editor/ClassDetailPanel.test.tsx +++ b/__tests__/components/editor/ClassDetailPanel.test.tsx @@ -430,23 +430,6 @@ describe("ClassDetailPanel", () => { expect(screen.queryByText("Edit Item")).toBeNull(); }); - // ── Edit mode (canEdit=true) ── - - it("auto-enters edit mode when canEdit is true and onUpdateClass provided", async () => { - const onUpdateClass = vi.fn(); - render( - - ); - - await waitFor(() => { - expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); - }); - }); - // ── Tree-node fallback for unsaved entities ── it("renders unsaved entity fallback with parent link", async () => { @@ -1687,7 +1670,7 @@ describe("ClassDetailPanel", () => { // ── Manual save stays in edit mode when flushToGit fails ── - it("stays in edit mode when saveAndExitEditMode flush fails", async () => { + it("stays in edit mode when flushDraftToGit flush fails", async () => { const user = userEvent.setup(); const onUpdateClass = vi.fn(); mockFlushToGit.mockResolvedValue(false); @@ -1718,23 +1701,6 @@ describe("ClassDetailPanel", () => { expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); }); - // ── Auto-enter edit mode in editor context ── - - it("auto-enters edit mode when onUpdateClass is provided", async () => { - const onUpdateClass = vi.fn(); - render( - - ); - - await waitFor(() => { - expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); - }); - }); - // ── Cancel keeps the panel in edit mode ── it("stays in edit mode after cancel and discards the draft", async () => { diff --git a/__tests__/components/editor/IndividualDetailPanel.test.tsx b/__tests__/components/editor/IndividualDetailPanel.test.tsx index e4eed728..9428c3bb 100644 --- a/__tests__/components/editor/IndividualDetailPanel.test.tsx +++ b/__tests__/components/editor/IndividualDetailPanel.test.tsx @@ -1185,7 +1185,7 @@ describe("IndividualDetailPanel", () => { // ── Manual save via AutoSaveAffordanceBar ── - it("invokes saveAndExitEditMode via AutoSaveAffordanceBar onManualSave", async () => { + it("invokes flushDraftToGit via AutoSaveAffordanceBar onManualSave", async () => { const onUpdateIndividual = vi.fn(); render( { // ── Manual save stays in edit mode when flushToGit fails ── - it("stays in edit mode when saveAndExitEditMode flush fails", async () => { + it("stays in edit mode when flushDraftToGit flush fails", async () => { const onUpdateIndividual = vi.fn(); mockFlushToGit.mockResolvedValue(false); render( diff --git a/__tests__/components/editor/PropertyDetailPanel.test.tsx b/__tests__/components/editor/PropertyDetailPanel.test.tsx index 5c794920..5128dec9 100644 --- a/__tests__/components/editor/PropertyDetailPanel.test.tsx +++ b/__tests__/components/editor/PropertyDetailPanel.test.tsx @@ -1233,7 +1233,7 @@ describe("PropertyDetailPanel", () => { // ── Manual save via AutoSaveAffordanceBar ── - it("invokes saveAndExitEditMode via AutoSaveAffordanceBar onManualSave", async () => { + it("invokes flushDraftToGit via AutoSaveAffordanceBar onManualSave", async () => { const onUpdateProperty = vi.fn(); render( { // ── Manual save stays in edit mode when flushToGit fails ── - it("stays in edit mode when saveAndExitEditMode flush fails", async () => { + it("stays in edit mode when flushDraftToGit flush fails", async () => { const onUpdateProperty = vi.fn(); mockFlushToGit.mockResolvedValue(false); render( diff --git a/app/projects/[id]/editor/page.tsx b/app/projects/[id]/editor/page.tsx index 1bcd385e..3df9b6d4 100644 --- a/app/projects/[id]/editor/page.tsx +++ b/app/projects/[id]/editor/page.tsx @@ -53,7 +53,18 @@ export default function EditorPage() { const projectId = params.id as string; const resumeSessionParam = searchParams.get("resumeSession") || undefined; const resumeBranchParam = searchParams.get("branch") || undefined; - const initialSelection = readSelectionFromSearchParams(searchParams); + // Memoize the parsed URL selection so its identity is stable across renders + // when the URL hasn't changed — otherwise the URL-restore effect would re-fire + // every render and risk clobbering the user's in-page selection. + const searchParamsString = searchParams.toString(); + const initialSelection = useMemo( + () => readSelectionFromSearchParams(new URLSearchParams(searchParamsString)), + [searchParamsString], + ); + // Tracks the URL selection we've already applied. Each unique + // `${type}:${iri}` is consumed at most once per page-instance — if the URL + // changes mid-session, we apply the new key, but we never re-apply a key. + const consumedSelectionRef = useRef(null); const initialBranch = resumeBranchParam || (() => { try { return sessionStorage.getItem(`ontokit:branch:${projectId}`); } catch { return null; } })() || undefined; @@ -100,17 +111,26 @@ export default function EditorPage() { // Restore selected entity from URL query param. For classes we wait for the // class tree to load; for properties and individuals we dispatch through the // layout's nav handler (entityNavigationRef), which switches the active tab - // and selects the entity directly. + // and selects the entity directly. Each URL-derived selection is applied at + // most once via consumedSelectionRef — that way an in-page selection change + // (which doesn't update the URL) isn't undone on the next render. useEffect(() => { if (!initialSelection) return; + const key = `${initialSelection.type}:${initialSelection.iri}`; + if (consumedSelectionRef.current === key) return; + if (initialSelection.type === "class") { if (isTreeLoading || !nodes.length) return; - if (selectedIri === initialSelection.iri) return; - navigateToNode(initialSelection.iri); + if (selectedIri !== initialSelection.iri) { + navigateToNode(initialSelection.iri); + } + consumedSelectionRef.current = key; return; } if (isLoading) return; // layout not yet mounted - entityNavigationRef.current?.(initialSelection.iri, initialSelection.type); + if (!entityNavigationRef.current) return; // layout's ref not yet populated + entityNavigationRef.current(initialSelection.iri, initialSelection.type); + consumedSelectionRef.current = key; }, [initialSelection, isTreeLoading, nodes.length, selectedIri, navigateToNode, isLoading]); // Commit dialog diff --git a/app/projects/[id]/page.tsx b/app/projects/[id]/page.tsx index df71ab91..ec67248c 100644 --- a/app/projects/[id]/page.tsx +++ b/app/projects/[id]/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, useCallback, useRef } from "react"; +import { useState, useEffect, useCallback, useMemo, useRef } from "react"; import { useSession, signIn } from "next-auth/react"; import { useParams, useSearchParams } from "next/navigation"; import Link from "next/link"; @@ -161,7 +161,13 @@ function ViewerContent({ sessionStatus: "loading" | "authenticated" | "unauthenticated"; }) { const searchParams = useSearchParams(); - const initialSelection = readSelectionFromSearchParams(searchParams); + // Memoize the parsed URL selection so the URL-restore effect doesn't re-fire + // (and risk clobbering the user's in-page selection) on every render. + const searchParamsString = searchParams.toString(); + const initialSelection = useMemo( + () => readSelectionFromSearchParams(new URLSearchParams(searchParamsString)), + [searchParamsString], + ); const editorMode = useEditorModeStore((s) => s.editorMode); // Use the default branch from the BranchProvider context @@ -189,6 +195,10 @@ function ViewerContent({ const sourceEditorRef = useRef(null); const [pendingScrollIri, setPendingScrollIri] = useState(null); const entityNavigationRef = useRef<((iri: string, type?: string) => void) | null>(null); + // Tracks the URL selection we've already applied — see editor page for the + // full rationale. Prevents an in-page selection change from being clobbered + // by a re-fire of this effect. + const consumedSelectionRef = useRef(null); // Restore selected entity from URL query param. Class navigation goes // through the class tree once it's loaded; properties and individuals are @@ -196,13 +206,20 @@ function ViewerContent({ // per-tab selection are set correctly. useEffect(() => { if (!initialSelection) return; + const key = `${initialSelection.type}:${initialSelection.iri}`; + if (consumedSelectionRef.current === key) return; + if (initialSelection.type === "class") { if (isTreeLoading || !nodes.length) return; - if (selectedIri === initialSelection.iri) return; - navigateToNode(initialSelection.iri); + if (selectedIri !== initialSelection.iri) { + navigateToNode(initialSelection.iri); + } + consumedSelectionRef.current = key; return; } - entityNavigationRef.current?.(initialSelection.iri, initialSelection.type); + if (!entityNavigationRef.current) return; + entityNavigationRef.current(initialSelection.iri, initialSelection.type); + consumedSelectionRef.current = key; }, [initialSelection, isTreeLoading, nodes.length, selectedIri, navigateToNode]); const toast = useToast(); diff --git a/components/editor/ClassDetailPanel.tsx b/components/editor/ClassDetailPanel.tsx index 3570b65f..f02a86e6 100644 --- a/components/editor/ClassDetailPanel.tsx +++ b/components/editor/ClassDetailPanel.tsx @@ -189,7 +189,7 @@ export function ClassDetailPanel({ }, [classDetail, discardDraft]); // eslint-disable-line react-hooks/exhaustive-deps // Manual save: flush the current draft to git. Stays in edit mode. - const saveAndExitEditMode = useCallback(async () => { + const flushDraftToGit = useCallback(async () => { triggerSave(); await flushToGit(); }, [triggerSave, flushToGit]); @@ -617,7 +617,7 @@ export function ClassDetailPanel({ error={saveError} validationError={validationError} onRetry={() => flushToGit()} - onManualSave={saveAndExitEditMode} + onManualSave={flushDraftToGit} onCancel={cancelEditMode} /> )} diff --git a/components/editor/IndividualDetailPanel.tsx b/components/editor/IndividualDetailPanel.tsx index 1fa69724..e29284a4 100644 --- a/components/editor/IndividualDetailPanel.tsx +++ b/components/editor/IndividualDetailPanel.tsx @@ -261,7 +261,7 @@ export function IndividualDetailPanel({ }, [detail, discardDraft, initEditState]); // Manual save: flush the current draft to git. Stays in edit mode. - const saveAndExitEditMode = useCallback(async () => { + const flushDraftToGit = useCallback(async () => { triggerSave(); await flushToGit(); }, [triggerSave, flushToGit]); @@ -435,7 +435,7 @@ export function IndividualDetailPanel({ error={saveError} validationError={validationError} onRetry={() => flushToGit()} - onManualSave={saveAndExitEditMode} + onManualSave={flushDraftToGit} onCancel={cancelEditMode} /> )} diff --git a/components/editor/PropertyDetailPanel.tsx b/components/editor/PropertyDetailPanel.tsx index 624fbd94..8a344037 100644 --- a/components/editor/PropertyDetailPanel.tsx +++ b/components/editor/PropertyDetailPanel.tsx @@ -274,7 +274,7 @@ export function PropertyDetailPanel({ }, [detail, discardDraft, initEditState]); // Manual save: flush the current draft to git. Stays in edit mode. - const saveAndExitEditMode = useCallback(async () => { + const flushDraftToGit = useCallback(async () => { triggerSave(); await flushToGit(); }, [triggerSave, flushToGit]); @@ -481,7 +481,7 @@ export function PropertyDetailPanel({ error={saveError} validationError={validationError} onRetry={() => flushToGit()} - onManualSave={saveAndExitEditMode} + onManualSave={flushDraftToGit} onCancel={cancelEditMode} /> )} diff --git a/components/editor/ViewerEditorSwitcher.tsx b/components/editor/ViewerEditorSwitcher.tsx index 85d803da..eb75ec7e 100644 --- a/components/editor/ViewerEditorSwitcher.tsx +++ b/components/editor/ViewerEditorSwitcher.tsx @@ -63,7 +63,7 @@ export function ViewerEditorSwitcher({ projectId, className }: ViewerEditorSwitc if (isActive) { return ( - + {label} From c22733a0d9034d87c0b99c036a14a4df787692a1 Mon Sep 17 00:00:00 2001 From: "John R. D'Orazio" Date: Tue, 28 Apr 2026 17:54:06 +0200 Subject: [PATCH 12/21] fix(editor): remount detail panels on selection change to prevent stale form state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Selecting a class in the editor, then selecting a different class, was showing the new class's title (read directly from the freshly-fetched classDetail) but keeping the previous class's edit-field values. Same shape of bug applied to property and individual selection. Root cause: the detail panel components were reused across selections, which preserved their internal useState (editLabels, editComments, …) and refs (editInitializedRef). The auto-enter useEffect already saw editInitializedRef.current === true from the previous class, so it early-returned when the new classDetail arrived and never re-ran initEditState with the new data. Apply the React-canonical reset-on-prop-change pattern: pass key={iri ?? "no-…"} to ClassDetailPanel, PropertyDetailPanel, and IndividualDetailPanel in both StandardEditorLayout and DeveloperEditorLayout. Each selection change remounts the panel with fresh state and refs, so initEditState runs cleanly when the new detail loads. Drop the now-redundant "navigate-away" effects in ClassDetailPanel and PropertyDetailPanel: - The setState resets they performed (setIsEditing, setShowParentPicker, editInitializedRef = false, …) are handled implicitly by the remount. - The flushToGit call they performed when prevIriRef !== currentIri was dead code under key-remount (the instance is destroyed before the effect can detect a same-instance change). Replace with the cleanup pattern IndividualDetailPanel already uses: a useEffect whose cleanup function calls flushToGit, so the unmounting instance flushes its draft right before the new instance mounts. The eslint- disable comment is the same ("flush reads from refs; cleanup needs the latest closure"). Side benefit: removing those two effects also clears two of the react-hooks/set-state-in-effect warnings tracked in #200 — lint warnings drop from 16 to 14. Full suite: 2716 tests passing. Co-Authored-By: Claude Opus 4.7 (1M context) --- components/editor/ClassDetailPanel.tsx | 19 ++++++++----------- components/editor/PropertyDetailPanel.tsx | 15 +++++++-------- .../developer/DeveloperEditorLayout.tsx | 5 +++++ .../editor/standard/StandardEditorLayout.tsx | 6 ++++++ 4 files changed, 26 insertions(+), 19 deletions(-) diff --git a/components/editor/ClassDetailPanel.tsx b/components/editor/ClassDetailPanel.tsx index f02a86e6..e876e992 100644 --- a/components/editor/ClassDetailPanel.tsx +++ b/components/editor/ClassDetailPanel.tsx @@ -110,8 +110,6 @@ export function ClassDetailPanel({ const [editRelationships, setEditRelationships] = useState([]); const [showParentPicker, setShowParentPicker] = useState(false); - // Track the previous classIri so we can flush on navigate - const prevClassIriRef = useRef(null); const editInitializedRef = useRef(false); // Toast for error feedback @@ -158,17 +156,16 @@ export function ClassDetailPanel({ } }, [isEditing, editStateRef]); - // Flush to git when class selection changes, then reset to read-only + // Flush any pending draft to git when this panel unmounts (the parent + // remounts the panel on classIri change via a key prop, so "navigate + // away" maps to "instance unmount" — flushing in the cleanup function is + // what carries the user's last edit through to the backend). useEffect(() => { - if (prevClassIriRef.current && prevClassIriRef.current !== classIri) { + return () => { flushToGit(); - } - prevClassIriRef.current = classIri; - setResolvedTargetLabels({}); - editInitializedRef.current = false; - setIsEditing(false); - setShowParentPicker(false); - }, [classIri]); // eslint-disable-line react-hooks/exhaustive-deps + }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- flush reads from refs; cleanup needs the latest closure + }, [classIri]); // Enter edit mode: initialize edit state from classDetail const enterEditMode = useCallback(() => { diff --git a/components/editor/PropertyDetailPanel.tsx b/components/editor/PropertyDetailPanel.tsx index 8a344037..f9897919 100644 --- a/components/editor/PropertyDetailPanel.tsx +++ b/components/editor/PropertyDetailPanel.tsx @@ -121,7 +121,6 @@ export function PropertyDetailPanel({ const [editRelationships, setEditRelationships] = useState([]); const [editPropertyType, setEditPropertyType] = useState("object"); - const prevIriRef = useRef(null); const editInitializedRef = useRef(false); const toast = useToast(); @@ -250,15 +249,15 @@ export function PropertyDetailPanel({ setEditAnnotations(regularAnnotations); }, []); - // Flush to git on navigate away + // Flush any pending draft to git when this panel unmounts (the parent + // remounts the panel on propertyIri change via a key prop, so "navigate + // away" maps to "instance unmount"). useEffect(() => { - if (prevIriRef.current && prevIriRef.current !== propertyIri) { + return () => { flushToGit(); - } - prevIriRef.current = propertyIri; - editInitializedRef.current = false; - setIsEditing(false); - }, [propertyIri]); // eslint-disable-line react-hooks/exhaustive-deps + }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- flush reads from refs; cleanup needs the latest closure + }, [propertyIri]); const enterEditMode = useCallback(() => { if (!detail) return; diff --git a/components/editor/developer/DeveloperEditorLayout.tsx b/components/editor/developer/DeveloperEditorLayout.tsx index f5882a74..cb4c3b0d 100644 --- a/components/editor/developer/DeveloperEditorLayout.tsx +++ b/components/editor/developer/DeveloperEditorLayout.tsx @@ -544,6 +544,9 @@ export function DeveloperEditorLayout(props: DeveloperEditorLayoutProps) {
{activeTab === "classes" ? ( ) : activeTab === "properties" ? ( ) : ( ) : activeTab === "classes" ? ( ) : activeTab === "properties" ? ( ) : ( Date: Tue, 28 Apr 2026 18:04:48 +0200 Subject: [PATCH 13/21] perf(useIriLabels): probe searchEntities first, fall back to class endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous probe order tried getClassDetail first and fell through to searchEntities on 404. That worked for class-heavy panels but produced noisy 404s when resolving labels for project-internal properties or individuals — the IRI exists in the project but isn't a class, so the class endpoint reliably 404s before the search fallback runs. Swap the order so searchEntities is the primary path and getClassDetail is the fallback. searchEntities returns labels for any entity type the project knows about, so a single call resolves most internal IRIs without hitting the class endpoint at all. Class probe still runs as a fallback for IRIs whose local name doesn't surface in the search index (opaque IDs, etc.). The earlier external-vocabulary skip-list (skos, rdfs, owl, dcterms, …) remains in place — those IRIs aren't in the project search index either and would 404 the class probe too, so they short-circuit before either call. Net effect for the user: opening a property panel that references project-internal entities (domain, range, etc.) no longer logs a 404 per referenced IRI. The class probe now only fires for the rare case of a class IRI whose local name is unsearchable. Tests updated: - "resolves labels via searchEntities (primary path)" — confirms search alone resolves the label and getClassDetail is NOT called. - "falls back to getClassDetail when search returns no match" — search returns an empty result set, class probe runs. - "falls back to getClassDetail when search throws" — search errors, class probe still runs. - Existing external-vocabulary skip and labelHints tests unchanged. Full suite: 2717 tests passing, type-check clean, lint 0 errors / 14 warnings. Co-Authored-By: Claude Opus 4.7 (1M context) --- __tests__/lib/hooks/useIriLabels.test.ts | 42 +++++++++++++++++++----- lib/hooks/useIriLabels.ts | 32 ++++++++++-------- 2 files changed, 52 insertions(+), 22 deletions(-) diff --git a/__tests__/lib/hooks/useIriLabels.test.ts b/__tests__/lib/hooks/useIriLabels.test.ts index 5cd1fb28..2920602d 100644 --- a/__tests__/lib/hooks/useIriLabels.test.ts +++ b/__tests__/lib/hooks/useIriLabels.test.ts @@ -27,7 +27,31 @@ describe("useIriLabels", () => { expect(mockedGetClassDetail).not.toHaveBeenCalled(); }); - it("resolves labels via getClassDetail", async () => { + it("resolves labels via searchEntities (primary path)", async () => { + mockedSearchEntities.mockResolvedValue({ + results: [ + { iri: "http://example.org/hasPart", label: "has Part" }, + ], + }); + + const { result } = renderHook(() => + useIriLabels(["http://example.org/hasPart"], { + projectId: "proj-1", + accessToken: "token", + }), + ); + + await waitFor(() => { + expect(result.current["http://example.org/hasPart"]).toBe("has Part"); + }); + // Search alone resolved it — no class probe needed. + expect(mockedSearchEntities).toHaveBeenCalled(); + expect(mockedGetClassDetail).not.toHaveBeenCalled(); + }); + + it("falls back to getClassDetail when search returns no match", async () => { + // Search returns an empty result set — IRI isn't in the search index. + mockedSearchEntities.mockResolvedValue({ results: [] }); mockedGetClassDetail.mockResolvedValue({ labels: [{ value: "Person", language: "en" }], }); @@ -43,6 +67,7 @@ describe("useIriLabels", () => { await waitFor(() => { expect(result.current["http://example.org/Person"]).toBe("Person"); }); + expect(mockedSearchEntities).toHaveBeenCalled(); expect(mockedGetClassDetail).toHaveBeenCalledWith( "proj-1", "http://example.org/Person", @@ -51,24 +76,23 @@ describe("useIriLabels", () => { ); }); - it("falls back to searchEntities when getClassDetail fails", async () => { - mockedGetClassDetail.mockRejectedValue(new Error("Not a class")); - mockedSearchEntities.mockResolvedValue({ - results: [ - { iri: "http://example.org/hasPart", label: "has Part" }, - ], + it("falls back to getClassDetail when search throws", async () => { + mockedSearchEntities.mockRejectedValue(new Error("search down")); + mockedGetClassDetail.mockResolvedValue({ + labels: [{ value: "Person", language: "en" }], }); const { result } = renderHook(() => - useIriLabels(["http://example.org/hasPart"], { + useIriLabels(["http://example.org/Person"], { projectId: "proj-1", accessToken: "token", }), ); await waitFor(() => { - expect(result.current["http://example.org/hasPart"]).toBe("has Part"); + expect(result.current["http://example.org/Person"]).toBe("Person"); }); + expect(mockedGetClassDetail).toHaveBeenCalled(); }); it("returns labelHints for known IRIs without fetching", () => { diff --git a/lib/hooks/useIriLabels.ts b/lib/hooks/useIriLabels.ts index 5b650c04..e9828e5f 100644 --- a/lib/hooks/useIriLabels.ts +++ b/lib/hooks/useIriLabels.ts @@ -86,18 +86,11 @@ export function useIriLabels( // and let the caller fall back on getLocalName. if (isExternalVocabularyIri(iri)) return; - // 1. Try class endpoint (fastest for classes) - try { - const detail = await projectOntologyApi.getClassDetail( - projectId, iri, accessToken, branch, - ); - const label = getPreferredLabel(detail.labels); - if (label) { newLabels[iri] = label; return; } - } catch { - // Not a class — fall through - } - - // 2. Fallback: entity search by local name + // 1. Search by local name. searchEntities returns labels for any + // entity type (class / property / individual) the project knows + // about, so a single call resolves most internal IRIs without + // the 404 noise that the class endpoint produces for properties + // and individuals. try { const localName = getLocalName(iri); const result = await projectOntologyApi.searchEntities( @@ -108,7 +101,20 @@ export function useIriLabels( ); if (match?.label) { newLabels[iri] = match.label; return; } } catch { - // Search failed — leave unresolved + // Search failed — fall through to class probe + } + + // 2. Fallback: try the class endpoint directly. This catches IRIs + // that exist as classes but whose local name doesn't surface in + // a search (e.g. opaque IDs that getLocalName trims to noise). + try { + const detail = await projectOntologyApi.getClassDetail( + projectId, iri, accessToken, branch, + ); + const label = getPreferredLabel(detail.labels); + if (label) { newLabels[iri] = label; return; } + } catch { + // Not a class either — leave unresolved; caller falls back to getLocalName. } }), ); From 9b72af24044f9883a04c97f09b9ac4d9ecfcb99f Mon Sep 17 00:00:00 2001 From: "John R. D'Orazio" Date: Tue, 28 Apr 2026 20:33:03 +0200 Subject: [PATCH 14/21] fix: address CodeRabbit findings on URL restore, ref staleness, and a11y MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useIriLabels.test.ts: mocked label objects used `language: "en"` but getPreferredLabel reads `lang`, so class-fallback assertions were not actually testing the path. Switch all 7 occurrences to `lang: "en"`. useIriLabels.ts: external-vocabulary skip-list normalizes http:// and https:// schemes so both variants of the same vocabulary are skipped. editor/page.tsx + viewer/page.tsx: URL-restore effect awaits navigateToNode and only marks consumedSelectionRef.current = key on success. Failures stay unconsumed so a later render can retry. ClassDetailPanel.tsx + PropertyDetailPanel.tsx: unmount-flush goes through a ref kept up to date in an effect, so the cleanup always calls the latest flushToGit closure (not the one captured on first render). The ref write happens in an effect to satisfy react-hooks/immutability. ClassDetailPanel.tsx: hoist initEditState above enterEditMode and cancelEditMode so both can list it as a dependency cleanly. cancelEditMode now captures the latest initEditState, so a cancel after relationship labels resolve no longer reverts to a stale init that loses those labels. ViewerEditorSwitcher.tsx: hrefFor merges the selection key into the existing searchParams instead of replacing the whole query string — preserves ?branch= and ?resumeSession=. IndividualDetailPanel.test.tsx: removed a duplicate auto-enter test. ClassDetailPanel.test.tsx + PropertyDetailPanel.test.tsx: rewrote the flushToGit-on-iri-change tests as flushToGit-on-unmount, matching the actual contract under key-prop remount. Rejected: the waitForAutoSaveBar helper-extraction nitpick (net negative for readability vs. the existing grep-able pattern). Full suite: 2716 tests passing. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../editor/ClassDetailPanel.test.tsx | 23 ++-- .../editor/IndividualDetailPanel.test.tsx | 14 --- .../editor/PropertyDetailPanel.test.tsx | 20 ++-- __tests__/lib/hooks/useIriLabels.test.ts | 14 +-- app/projects/[id]/editor/page.tsx | 21 +++- app/projects/[id]/page.tsx | 18 +++- components/editor/ClassDetailPanel.tsx | 101 ++++++++++-------- components/editor/PropertyDetailPanel.tsx | 17 +-- components/editor/ViewerEditorSwitcher.tsx | 22 +++- lib/hooks/useIriLabels.ts | 32 +++--- 10 files changed, 159 insertions(+), 123 deletions(-) diff --git a/__tests__/components/editor/ClassDetailPanel.test.tsx b/__tests__/components/editor/ClassDetailPanel.test.tsx index 233a2d12..548b0bb7 100644 --- a/__tests__/components/editor/ClassDetailPanel.test.tsx +++ b/__tests__/components/editor/ClassDetailPanel.test.tsx @@ -866,29 +866,26 @@ describe("ClassDetailPanel", () => { expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); }); - it("calls flushToGit when navigating to a different class", async () => { + it("flushes pending draft to git on unmount", async () => { + // The parent layout remounts the panel on selection change via a key + // prop, so the panel's contract for "navigate away" is "unmount and let + // the cleanup flush". This test exercises that contract directly. const onUpdateClass = vi.fn(); - const { rerender } = render( + const { unmount } = render( ); - - rerender( - - ); - await waitFor(() => { - expect(mockFlushToGit).toHaveBeenCalled(); + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); }); + + unmount(); + + expect(mockFlushToGit).toHaveBeenCalled(); }); // ── Multiple labels with remove ── diff --git a/__tests__/components/editor/IndividualDetailPanel.test.tsx b/__tests__/components/editor/IndividualDetailPanel.test.tsx index 9428c3bb..e7323b1c 100644 --- a/__tests__/components/editor/IndividualDetailPanel.test.tsx +++ b/__tests__/components/editor/IndividualDetailPanel.test.tsx @@ -366,20 +366,6 @@ describe("IndividualDetailPanel", () => { // ── Edit mode (canEdit=true) ── - it("auto-enters edit mode when canEdit is true and onUpdateIndividual provided", async () => { - const onUpdateIndividual = vi.fn(); - render( - - ); - await waitFor(() => { - expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); - }); - }); - it("never renders an Edit Item button in editor context", async () => { const onUpdateIndividual = vi.fn(); render( diff --git a/__tests__/components/editor/PropertyDetailPanel.test.tsx b/__tests__/components/editor/PropertyDetailPanel.test.tsx index 5128dec9..f62312f3 100644 --- a/__tests__/components/editor/PropertyDetailPanel.test.tsx +++ b/__tests__/components/editor/PropertyDetailPanel.test.tsx @@ -735,22 +735,16 @@ describe("PropertyDetailPanel", () => { expect(mockClearRestoredDraft).toHaveBeenCalled(); }); - // ── flushToGit on IRI change ── + // ── flushToGit on unmount ── - it("calls flushToGit when propertyIri changes", async () => { - const { rerender } = render( + it("flushes pending draft to git on unmount", async () => { + // The parent layout remounts the panel on selection change via a key + // prop, so unmount is what carries pending edits to the backend. + const { unmount } = render( ); - rerender( - - ); - await waitFor(() => { - expect(mockFlushToGit).toHaveBeenCalled(); - }); + unmount(); + expect(mockFlushToGit).toHaveBeenCalled(); }); // ── Does not show characteristics for data properties ── diff --git a/__tests__/lib/hooks/useIriLabels.test.ts b/__tests__/lib/hooks/useIriLabels.test.ts index 2920602d..3ee72394 100644 --- a/__tests__/lib/hooks/useIriLabels.test.ts +++ b/__tests__/lib/hooks/useIriLabels.test.ts @@ -53,7 +53,7 @@ describe("useIriLabels", () => { // Search returns an empty result set — IRI isn't in the search index. mockedSearchEntities.mockResolvedValue({ results: [] }); mockedGetClassDetail.mockResolvedValue({ - labels: [{ value: "Person", language: "en" }], + labels: [{ value: "Person", lang: "en" }], }); const { result } = renderHook(() => @@ -79,7 +79,7 @@ describe("useIriLabels", () => { it("falls back to getClassDetail when search throws", async () => { mockedSearchEntities.mockRejectedValue(new Error("search down")); mockedGetClassDetail.mockResolvedValue({ - labels: [{ value: "Person", language: "en" }], + labels: [{ value: "Person", lang: "en" }], }); const { result } = renderHook(() => @@ -114,7 +114,7 @@ describe("useIriLabels", () => { it("merges labelHints with fetched labels", async () => { const hints = { "http://example.org/Known": "Known Label" }; mockedGetClassDetail.mockResolvedValue({ - labels: [{ value: "Fetched", language: "en" }], + labels: [{ value: "Fetched", lang: "en" }], }); const { result } = renderHook(() => @@ -146,7 +146,7 @@ describe("useIriLabels", () => { it("resets cache when projectId changes", async () => { mockedGetClassDetail.mockResolvedValue({ - labels: [{ value: "Label1", language: "en" }], + labels: [{ value: "Label1", lang: "en" }], }); const { result, rerender } = renderHook( @@ -163,7 +163,7 @@ describe("useIriLabels", () => { }); mockedGetClassDetail.mockResolvedValue({ - labels: [{ value: "Label2", language: "en" }], + labels: [{ value: "Label2", lang: "en" }], }); // Change projectId AND iris to trigger both the context reset and the IRI effect @@ -206,7 +206,7 @@ describe("useIriLabels", () => { "http://purl.org/dc/terms/hasVersion", "http://www.w3.org/2001/XMLSchema#string", ])("skips both class probe and search for external-vocabulary IRI %s", async (iri) => { - mockedGetClassDetail.mockResolvedValue({ labels: [{ value: "should-not", language: "en" }] }); + mockedGetClassDetail.mockResolvedValue({ labels: [{ value: "should-not", lang: "en" }] }); mockedSearchEntities.mockResolvedValue({ results: [] }); const { result } = renderHook(() => @@ -222,7 +222,7 @@ describe("useIriLabels", () => { }); it("still resolves project-internal IRIs that aren't in the external skip-list", async () => { - mockedGetClassDetail.mockResolvedValue({ labels: [{ value: "Person", language: "en" }] }); + mockedGetClassDetail.mockResolvedValue({ labels: [{ value: "Person", lang: "en" }] }); mockedSearchEntities.mockResolvedValue({ results: [] }); const { result } = renderHook(() => diff --git a/app/projects/[id]/editor/page.tsx b/app/projects/[id]/editor/page.tsx index 3df9b6d4..b8ae215a 100644 --- a/app/projects/[id]/editor/page.tsx +++ b/app/projects/[id]/editor/page.tsx @@ -121,11 +121,24 @@ export default function EditorPage() { if (initialSelection.type === "class") { if (isTreeLoading || !nodes.length) return; - if (selectedIri !== initialSelection.iri) { - navigateToNode(initialSelection.iri); + if (selectedIri === initialSelection.iri) { + consumedSelectionRef.current = key; + return; } - consumedSelectionRef.current = key; - return; + // Await the navigation so we only mark this URL key consumed when the + // tree-side restore actually succeeds. If it throws, leave the key + // unconsumed so a later render (e.g. once data is healthy) can retry. + let cancelled = false; + navigateToNode(initialSelection.iri) + .then(() => { + if (!cancelled) consumedSelectionRef.current = key; + }) + .catch((err) => { + if (!cancelled) console.error("Failed to restore selection from URL:", err); + }); + return () => { + cancelled = true; + }; } if (isLoading) return; // layout not yet mounted if (!entityNavigationRef.current) return; // layout's ref not yet populated diff --git a/app/projects/[id]/page.tsx b/app/projects/[id]/page.tsx index ec67248c..b364e2b6 100644 --- a/app/projects/[id]/page.tsx +++ b/app/projects/[id]/page.tsx @@ -211,11 +211,21 @@ function ViewerContent({ if (initialSelection.type === "class") { if (isTreeLoading || !nodes.length) return; - if (selectedIri !== initialSelection.iri) { - navigateToNode(initialSelection.iri); + if (selectedIri === initialSelection.iri) { + consumedSelectionRef.current = key; + return; } - consumedSelectionRef.current = key; - return; + let cancelled = false; + navigateToNode(initialSelection.iri) + .then(() => { + if (!cancelled) consumedSelectionRef.current = key; + }) + .catch((err) => { + if (!cancelled) console.error("Failed to restore selection from URL:", err); + }); + return () => { + cancelled = true; + }; } if (!entityNavigationRef.current) return; entityNavigationRef.current(initialSelection.iri, initialSelection.type); diff --git a/components/editor/ClassDetailPanel.tsx b/components/editor/ClassDetailPanel.tsx index e876e992..cb15184c 100644 --- a/components/editor/ClassDetailPanel.tsx +++ b/components/editor/ClassDetailPanel.tsx @@ -160,12 +160,62 @@ export function ClassDetailPanel({ // remounts the panel on classIri change via a key prop, so "navigate // away" maps to "instance unmount" — flushing in the cleanup function is // what carries the user's last edit through to the backend). + // The flush function captures closure over canEdit/onUpdateClass/etc. and + // can be re-created mid-render; route the unmount call through a ref that + // we keep up to date in an effect, so cleanup always sees the latest + // closure rather than the one captured on first render. + const flushToGitRef = useRef(flushToGit); + useEffect(() => { + flushToGitRef.current = flushToGit; + }, [flushToGit]); useEffect(() => { return () => { - flushToGit(); + flushToGitRef.current(); }; - // eslint-disable-next-line react-hooks/exhaustive-deps -- flush reads from refs; cleanup needs the latest closure - }, [classIri]); + }, []); + + // Initialize edit state from OWLClassDetail. + // Declared before enterEditMode / cancelEditMode so those callbacks can list + // it in their dependency arrays without tripping a use-before-declared error. + const initEditState = useCallback((detail: OWLClassDetail) => { + setEditLabels(detail.labels.length > 0 ? detail.labels.map((l) => ({ ...l })) : [{ value: "", lang: "en" }]); + setEditComments(ensureTrailingEmpty(detail.comments.map((c) => ({ ...c })))); + setEditParentIris([...detail.parent_iris]); + setEditParentLabels({ ...detail.parent_labels }); + + const allAnnotations = detail.annotations || []; + const regularAnnotations: AnnotationUpdate[] = []; + const relationships: RelationshipGroup[] = []; + + for (const a of allAnnotations) { + if (RELATIONSHIP_PROPERTY_IRIS.has(a.property_iri)) { + const propInfo = getAnnotationPropertyInfo(a.property_iri); + relationships.push({ + property_iri: a.property_iri, + property_label: propInfo.displayLabel, + targets: a.values + .filter((v) => v.value.trim()) + .map((v) => ({ iri: v.value, label: resolvedTargetLabels[v.value] || getLocalName(v.value) })), + }); + } else { + regularAnnotations.push({ + property_iri: a.property_iri, + values: ensureTrailingEmpty(a.values.map((v) => ({ ...v }))), + }); + } + } + + if (!regularAnnotations.find((a) => a.property_iri === DEFINITION_IRI)) { + regularAnnotations.unshift({ property_iri: DEFINITION_IRI, values: [{ value: "", lang: "en" }] }); + } + + if (relationships.length === 0) { + relationships.push({ property_iri: SEE_ALSO_IRI, property_label: "See Also", targets: [] }); + } + + setEditAnnotations(regularAnnotations); + setEditRelationships(relationships); + }, [resolvedTargetLabels]); // Enter edit mode: initialize edit state from classDetail const enterEditMode = useCallback(() => { @@ -173,7 +223,7 @@ export function ClassDetailPanel({ initEditState(classDetail); editInitializedRef.current = true; setIsEditing(true); - }, [classDetail]); // eslint-disable-line react-hooks/exhaustive-deps + }, [classDetail, initEditState]); // Cancel: discard the in-progress draft and re-init from server state. // The panel stays in edit mode — the editor is always editable. @@ -183,7 +233,7 @@ export function ClassDetailPanel({ initEditState(classDetail); } setShowParentPicker(false); - }, [classDetail, discardDraft]); // eslint-disable-line react-hooks/exhaustive-deps + }, [classDetail, discardDraft, initEditState]); // Manual save: flush the current draft to git. Stays in edit mode. const flushDraftToGit = useCallback(async () => { @@ -214,47 +264,6 @@ export function ClassDetailPanel({ enterEditMode(); }, [classDetail, canEdit, restoredDraft, classIri, clearRestoredDraft, onUpdateClass, isEditing, enterEditMode]); - // Initialize edit state from OWLClassDetail - const initEditState = useCallback((detail: OWLClassDetail) => { - setEditLabels(detail.labels.length > 0 ? detail.labels.map((l) => ({ ...l })) : [{ value: "", lang: "en" }]); - setEditComments(ensureTrailingEmpty(detail.comments.map((c) => ({ ...c })))); - setEditParentIris([...detail.parent_iris]); - setEditParentLabels({ ...detail.parent_labels }); - - const allAnnotations = detail.annotations || []; - const regularAnnotations: AnnotationUpdate[] = []; - const relationships: RelationshipGroup[] = []; - - for (const a of allAnnotations) { - if (RELATIONSHIP_PROPERTY_IRIS.has(a.property_iri)) { - const propInfo = getAnnotationPropertyInfo(a.property_iri); - relationships.push({ - property_iri: a.property_iri, - property_label: propInfo.displayLabel, - targets: a.values - .filter((v) => v.value.trim()) - .map((v) => ({ iri: v.value, label: resolvedTargetLabels[v.value] || getLocalName(v.value) })), - }); - } else { - regularAnnotations.push({ - property_iri: a.property_iri, - values: ensureTrailingEmpty(a.values.map((v) => ({ ...v }))), - }); - } - } - - if (!regularAnnotations.find((a) => a.property_iri === DEFINITION_IRI)) { - regularAnnotations.unshift({ property_iri: DEFINITION_IRI, values: [{ value: "", lang: "en" }] }); - } - - if (relationships.length === 0) { - relationships.push({ property_iri: SEE_ALSO_IRI, property_label: "See Also", targets: [] }); - } - - setEditAnnotations(regularAnnotations); - setEditRelationships(relationships); - }, [resolvedTargetLabels]); - // Fetch class data useEffect(() => { if (!classIri) { diff --git a/components/editor/PropertyDetailPanel.tsx b/components/editor/PropertyDetailPanel.tsx index f9897919..d67eabeb 100644 --- a/components/editor/PropertyDetailPanel.tsx +++ b/components/editor/PropertyDetailPanel.tsx @@ -249,15 +249,20 @@ export function PropertyDetailPanel({ setEditAnnotations(regularAnnotations); }, []); - // Flush any pending draft to git when this panel unmounts (the parent - // remounts the panel on propertyIri change via a key prop, so "navigate - // away" maps to "instance unmount"). + // Flush any pending draft to git when this panel unmounts. The flush + // closure captures props that can change between mount and unmount, so + // route the call through a ref that we keep up to date in an effect — the + // cleanup then sees the latest closure rather than the one captured on + // first render. + const flushToGitRef = useRef(flushToGit); + useEffect(() => { + flushToGitRef.current = flushToGit; + }, [flushToGit]); useEffect(() => { return () => { - flushToGit(); + flushToGitRef.current(); }; - // eslint-disable-next-line react-hooks/exhaustive-deps -- flush reads from refs; cleanup needs the latest closure - }, [propertyIri]); + }, []); const enterEditMode = useCallback(() => { if (!detail) return; diff --git a/components/editor/ViewerEditorSwitcher.tsx b/components/editor/ViewerEditorSwitcher.tsx index eb75ec7e..d3d5db6f 100644 --- a/components/editor/ViewerEditorSwitcher.tsx +++ b/components/editor/ViewerEditorSwitcher.tsx @@ -6,8 +6,8 @@ import { usePathname, useSearchParams } from "next/navigation"; import { cn } from "@/lib/utils"; import { useSelectionStore } from "@/lib/stores/selectionStore"; import { - buildSelectionQuery, readSelectionFromSearchParams, + SELECTION_PARAM_BY_TYPE, } from "@/lib/utils/selectionUrl"; interface ViewerEditorSwitcherProps { @@ -38,10 +38,24 @@ export function ViewerEditorSwitcher({ projectId, className }: ViewerEditorSwitc storeIri && storeType ? { iri: storeIri, type: storeType } : readSelectionFromSearchParams(searchParams); - const query = buildSelectionQuery(selection); - const hrefFor = (mode: Mode) => - mode === "editor" ? `/projects/${projectId}/editor${query}` : `/projects/${projectId}${query}`; + // Build the destination href by merging the selection key into the existing + // query string instead of replacing it — this preserves params like ?branch= + // and ?resumeSession= that the editor and viewer use. + const hrefFor = (mode: Mode) => { + const next = new URLSearchParams(searchParams.toString()); + // Drop all known selection keys before writing the new one so we never + // emit two of them at once. + for (const key of Object.values(SELECTION_PARAM_BY_TYPE)) { + next.delete(key); + } + if (selection?.iri && selection.type) { + next.set(SELECTION_PARAM_BY_TYPE[selection.type], selection.iri); + } + const qs = next.toString(); + const path = mode === "editor" ? `/projects/${projectId}/editor` : `/projects/${projectId}`; + return qs ? `${path}?${qs}` : path; + }; return (
Date: Tue, 28 Apr 2026 20:47:17 +0200 Subject: [PATCH 15/21] test: cover patch-coverage gaps from CodeRabbit/codecov report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useIriLabels.test.ts: - Extend the external-vocabulary skip-list test to also cover https:// variants of the same vocabularies (https://www.w3.org/2004/02/skos/core#, https://purl.org/dc/terms/, https://www.w3.org/2002/07/owl#). Previously only http:// variants were tested, so the http(s) normalization branch added in the last commit was uncovered. - Add a parametrized test for non-http(s) IRIs (urn:, tel:, bare strings) to exercise the early-return path in isExternalVocabularyIri (the `else return false` branch flagged at useIriLabels.ts:32). The hook should fall through to its normal probe path for these IRIs, not skip. ViewerEditorSwitcher.test.tsx: - Add "preserves unrelated query params" test confirming that ?branch= and ?resumeSession= survive the navigation (the merge-into-existing- searchParams branch added when fixing the param-clobbering CodeRabbit finding). - Add "strips the previous selection key before writing a new one" test confirming mutual exclusion between classIri/propertyIri/ individualIri keys when the store has a different selection than the URL. ClassDetailPanel.test.tsx: - Add "renders the class detail when lintApi.getIssues rejects" test covering the .catch branch on line 283 (`lintApi.getIssues(...) .catch(() => ({items: []}))`). The fetch effect tolerates lint failures; this test confirms the panel still renders the class detail and just suppresses the lint section in that case. Skipped: - ClassDetailPanel.tsx:136 (the one-line toast.error wrapper inside useAutoSave's onError prop). The current useAutoSave mock fully replaces the implementation and never invokes onError, so exercising that line would require a test-specific mock variant. Net cost outweighs value for a delegating one-liner. - EntityTree.tsx scrollToIri null-ref branch — defensive guard against a ref that's set during render; hard to trigger from RTL without a contrived setup, low practical risk. Full suite: 2725 tests passing (was 2716, +9 new), type-check clean, lint 0 errors / 15 warnings. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../editor/ClassDetailPanel.test.tsx | 16 +++++++++ .../editor/ViewerEditorSwitcher.test.tsx | 35 +++++++++++++++++++ __tests__/lib/hooks/useIriLabels.test.ts | 25 +++++++++++++ 3 files changed, 76 insertions(+) diff --git a/__tests__/components/editor/ClassDetailPanel.test.tsx b/__tests__/components/editor/ClassDetailPanel.test.tsx index 548b0bb7..1ae1819b 100644 --- a/__tests__/components/editor/ClassDetailPanel.test.tsx +++ b/__tests__/components/editor/ClassDetailPanel.test.tsx @@ -353,6 +353,22 @@ describe("ClassDetailPanel", () => { }); }); + it("renders the class detail when lintApi.getIssues rejects", async () => { + // The fetch effect tolerates a lint failure (it's wrapped in .catch + // that returns an empty issues list) so the rest of the panel still + // renders. Cover that branch. + mockGetIssues.mockRejectedValue(new Error("lint service down")); + render(); + + await waitFor(() => { + expect(mockGetClassDetail).toHaveBeenCalled(); + }); + // No lint summary should be shown despite a class being loaded. + await waitFor(() => { + expect(screen.queryByText(/Health Issues/i)).toBeNull(); + }); + }); + // ── Lint issues ── it("shows lint issues for the class", async () => { diff --git a/__tests__/components/editor/ViewerEditorSwitcher.test.tsx b/__tests__/components/editor/ViewerEditorSwitcher.test.tsx index 65f9b2ba..87078bab 100644 --- a/__tests__/components/editor/ViewerEditorSwitcher.test.tsx +++ b/__tests__/components/editor/ViewerEditorSwitcher.test.tsx @@ -119,4 +119,39 @@ describe("ViewerEditorSwitcher", () => { "/projects/proj-1/editor?individualIri=" + encodeURIComponent("http://example.org/alice"), ); }); + + it("preserves unrelated query params (branch, resumeSession) in the destination href", () => { + mockPathname = "/projects/proj-1"; + mockSearch = + "branch=feature%2Ffoo" + + "&resumeSession=abc123" + + "&classIri=" + encodeURIComponent("http://example.org/Person"); + render(); + + const editorLink = screen.getByText("Editor").closest("a"); + const href = editorLink?.getAttribute("href") ?? ""; + expect(href.startsWith("/projects/proj-1/editor?")).toBe(true); + const params = new URLSearchParams(href.split("?")[1] ?? ""); + // unrelated params survive + expect(params.get("branch")).toBe("feature/foo"); + expect(params.get("resumeSession")).toBe("abc123"); + // selection key is still carried correctly + expect(params.get("classIri")).toBe("http://example.org/Person"); + }); + + it("strips the previous selection key before writing a new one (mutual exclusion)", () => { + mockPathname = "/projects/proj-1"; + // URL has a stale classIri… + mockSearch = "classIri=" + encodeURIComponent("http://example.org/Stale"); + // …but the user has since selected a property in this session + useSelectionStore.getState().setSelection("http://example.org/hasName", "property"); + + render(); + + const editorLink = screen.getByText("Editor").closest("a"); + const href = editorLink?.getAttribute("href") ?? ""; + const params = new URLSearchParams(href.split("?")[1] ?? ""); + expect(params.get("classIri")).toBeNull(); + expect(params.get("propertyIri")).toBe("http://example.org/hasName"); + }); }); diff --git a/__tests__/lib/hooks/useIriLabels.test.ts b/__tests__/lib/hooks/useIriLabels.test.ts index 3ee72394..d18f0b90 100644 --- a/__tests__/lib/hooks/useIriLabels.test.ts +++ b/__tests__/lib/hooks/useIriLabels.test.ts @@ -205,6 +205,10 @@ describe("useIriLabels", () => { "http://www.w3.org/2002/07/owl#topObjectProperty", "http://purl.org/dc/terms/hasVersion", "http://www.w3.org/2001/XMLSchema#string", + // https:// variants of the same vocabularies must also be skipped + "https://www.w3.org/2004/02/skos/core#prefLabel", + "https://purl.org/dc/terms/title", + "https://www.w3.org/2002/07/owl#sameAs", ])("skips both class probe and search for external-vocabulary IRI %s", async (iri) => { mockedGetClassDetail.mockResolvedValue({ labels: [{ value: "should-not", lang: "en" }] }); mockedSearchEntities.mockResolvedValue({ results: [] }); @@ -221,6 +225,27 @@ describe("useIriLabels", () => { expect(result.current[iri]).toBeUndefined(); }); + it.each([ + "urn:uuid:550e8400-e29b-41d4-a716-446655440000", + "tel:+1-555-0100", + "bare-string-no-scheme", + ])("does not short-circuit non-http(s) IRIs: %s", async (iri) => { + // Non-http(s) IRIs aren't in the external-vocabulary skip-list, so the + // hook should fall through to its normal probe path. Configure both + // mocks to return empty results — we just want to assert that the hook + // ATTEMPTED to resolve (i.e., did not silently skip). + mockedSearchEntities.mockResolvedValue({ results: [] }); + mockedGetClassDetail.mockRejectedValue(new Error("not found")); + + renderHook(() => + useIriLabels([iri], { projectId: "proj-1", accessToken: "token" }), + ); + + await waitFor(() => { + expect(mockedSearchEntities).toHaveBeenCalled(); + }); + }); + it("still resolves project-internal IRIs that aren't in the external skip-list", async () => { mockedGetClassDetail.mockResolvedValue({ labels: [{ value: "Person", lang: "en" }] }); mockedSearchEntities.mockResolvedValue({ results: [] }); From 292734257d756bdac444070910ae257f7917de48 Mon Sep 17 00:00:00 2001 From: "John R. D'Orazio" Date: Tue, 28 Apr 2026 20:53:01 +0200 Subject: [PATCH 16/21] fix(ClassDetailPanel): clear editStateRef on cancel to prevent unmount race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cancelEditMode previously relied on the editStateRef-sync useEffect to refill the autosave ref after the post-cancel re-render. There was a narrow window where, if the panel unmounted in the same React batch as the cancel (e.g., user clicks Cancel and then immediately navigates), the unmount cleanup's flushToGit would read pre-cancel data via editStateRef and resurrect the just-discarded edit. Set editStateRef.current = null directly after discardDraft() so the window closes immediately. The editStateRef-sync effect still refills the ref on the next commit when the user resumes editing — clearing here just guarantees safety in the brief gap between cancel and that sync. Rejected: the "legacy ?classIri/?propertyIri/?individualIri params" finding. Those keys aren't legacy — they ARE the current contract, and readSelectionFromSearchParams (lib/utils/selectionUrl.ts:13-33) reads them today. ?classIri=ex:Person parses correctly into {iri: "ex:Person", type: "class"}. CodeRabbit appears to have been misled by the helper abstraction. Full suite: 2725 tests passing, type-check clean, lint 0 errors. Co-Authored-By: Claude Opus 4.7 (1M context) --- components/editor/ClassDetailPanel.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/components/editor/ClassDetailPanel.tsx b/components/editor/ClassDetailPanel.tsx index cb15184c..9902cc5b 100644 --- a/components/editor/ClassDetailPanel.tsx +++ b/components/editor/ClassDetailPanel.tsx @@ -229,11 +229,16 @@ export function ClassDetailPanel({ // The panel stays in edit mode — the editor is always editable. const cancelEditMode = useCallback(() => { discardDraft(); + // Explicitly clear the autosave ref so an unmount in the same React + // batch as this cancel can't resurrect the discarded edit via flushToGit. + // The editStateRef-sync effect would refill this ref after the next + // commit anyway, but that's too late if unmount happens first. + editStateRef.current = null; if (classDetail) { initEditState(classDetail); } setShowParentPicker(false); - }, [classDetail, discardDraft, initEditState]); + }, [classDetail, discardDraft, initEditState, editStateRef]); // Manual save: flush the current draft to git. Stays in edit mode. const flushDraftToGit = useCallback(async () => { From 2366fd409af02fec9348e10f8bfe6633d3e4cf19 Mon Sep 17 00:00:00 2001 From: "John R. D'Orazio" Date: Tue, 28 Apr 2026 22:38:08 +0200 Subject: [PATCH 17/21] feat: wire preferEditMode to ProjectCard so it actually opens the editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Prefer Edit Mode user setting in /settings was persisted in the store and surfaced in the UI, but no code consumed it. Clicking a project card from the projects list always took the user to the viewer (/projects/[id]), regardless of their preference. ProjectCard now reads: - the session via useSession() — for the auth token needed by derivePermissions - preferEditMode from useEditorModeStore - canSuggest derived from project.user_role + accessToken via the existing derivePermissions helper When preferEditMode is on AND the user has at least suggester rights, the card links straight to /projects/[id]/editor. Anyone without edit or suggest rights stays on the viewer regardless of the preference, so read-only public-project visitors can't accidentally land in the editor. This closes the last loop on the Prefer Edit Mode feature shipped in this PR — the preference is now functional end-to-end (set in /settings → projects list honors it → editor opens). Tests: 6 new project-card cases covering each role (owner, admin, editor, viewer), an authenticated suggester on a public project, and an unauthenticated visitor. The existing test mock setup is extended with controlled stubs for useSession and useEditorModeStore. Full suite: 2731 tests passing, type-check clean, lint 0 errors. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/projects/project-card.test.tsx | 65 ++++++++++++++++++- components/projects/project-card.tsx | 16 ++++- 2 files changed, 77 insertions(+), 4 deletions(-) diff --git a/__tests__/components/projects/project-card.test.tsx b/__tests__/components/projects/project-card.test.tsx index 3b87615d..f1bfe57b 100644 --- a/__tests__/components/projects/project-card.test.tsx +++ b/__tests__/components/projects/project-card.test.tsx @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi, beforeEach } from "vitest"; import { render, screen } from "@testing-library/react"; // Mock next/link to render a plain tag @@ -9,6 +9,19 @@ vi.mock("next/link", () => ({ ), })); +// Test-controlled session and preferEditMode mocks. +let mockSessionAccessToken: string | undefined; +let mockPreferEditMode = false; + +vi.mock("next-auth/react", () => ({ + useSession: () => ({ data: mockSessionAccessToken ? { accessToken: mockSessionAccessToken } : null }), +})); + +vi.mock("@/lib/stores/editorModeStore", () => ({ + useEditorModeStore: (selector: (s: { preferEditMode: boolean }) => T) => + selector({ preferEditMode: mockPreferEditMode }), +})); + import { ProjectCard } from "@/components/projects/project-card"; import type { Project } from "@/lib/api/projects"; @@ -27,6 +40,11 @@ function makeProject(overrides: Partial = {}): Project { } describe("ProjectCard", () => { + beforeEach(() => { + mockSessionAccessToken = undefined; + mockPreferEditMode = false; + }); + // ── Basic rendering ───────────────────────────────────────────── it("renders the project name", () => { render(); @@ -44,8 +62,49 @@ describe("ProjectCard", () => { }); // ── Link ──────────────────────────────────────────────────────── - it("links to the correct project URL", () => { - render(); + it("links to the viewer when preferEditMode is off", () => { + mockPreferEditMode = false; + render(); + const link = screen.getByRole("link", { name: /Open project Test Ontology/ }); + expect(link.getAttribute("href")).toBe("/projects/proj-123"); + }); + + it("links straight to the editor when preferEditMode is on AND user has edit rights", () => { + mockPreferEditMode = true; + render(); + const link = screen.getByRole("link", { name: /Open project Test Ontology/ }); + expect(link.getAttribute("href")).toBe("/projects/proj-123/editor"); + }); + + it.each(["admin", "editor"] as const)( + "links to the editor when preferEditMode is on for %s role", + (role) => { + mockPreferEditMode = true; + render(); + const link = screen.getByRole("link", { name: /Open project Test Ontology/ }); + expect(link.getAttribute("href")).toBe("/projects/proj-123/editor"); + }, + ); + + it("falls back to the viewer when preferEditMode is on but user has only viewer role", () => { + mockPreferEditMode = true; + render(); + const link = screen.getByRole("link", { name: /Open project Test Ontology/ }); + expect(link.getAttribute("href")).toBe("/projects/proj-123"); + }); + + it("links a public project to the editor for an authenticated visitor with no role (suggester) when preferEditMode is on", () => { + mockPreferEditMode = true; + mockSessionAccessToken = "token-abc"; + render(); + const link = screen.getByRole("link", { name: /Open project Test Ontology/ }); + expect(link.getAttribute("href")).toBe("/projects/proj-123/editor"); + }); + + it("links to the viewer when preferEditMode is on but visitor is unauthenticated", () => { + mockPreferEditMode = true; + mockSessionAccessToken = undefined; + render(); const link = screen.getByRole("link", { name: /Open project Test Ontology/ }); expect(link.getAttribute("href")).toBe("/projects/proj-123"); }); diff --git a/components/projects/project-card.tsx b/components/projects/project-card.tsx index 2673f2ff..69b82033 100644 --- a/components/projects/project-card.tsx +++ b/components/projects/project-card.tsx @@ -1,9 +1,12 @@ "use client"; import Link from "next/link"; +import { useSession } from "next-auth/react"; import { Globe, Lock, Users } from "lucide-react"; import { cn, formatDate } from "@/lib/utils"; import type { Project } from "@/lib/api/projects"; +import { derivePermissions } from "@/lib/hooks/useProject"; +import { useEditorModeStore } from "@/lib/stores/editorModeStore"; interface ProjectCardProps { project: Project; @@ -11,9 +14,20 @@ interface ProjectCardProps { } export function ProjectCard({ project, className }: ProjectCardProps) { + const { data: session } = useSession(); + const { canSuggest } = derivePermissions(project, session?.accessToken); + const preferEditMode = useEditorModeStore((s) => s.preferEditMode); + + // When the user has prefer-edit-mode on AND has at least suggester rights, + // open the project straight to the editor. Anyone without edit/suggest + // rights stays on the read-only viewer regardless of the preference. + const href = preferEditMode && canSuggest + ? `/projects/${project.id}/editor` + : `/projects/${project.id}`; + return ( Date: Tue, 28 Apr 2026 22:58:48 +0200 Subject: [PATCH 18/21] feat: respect preferEditMode on every "Back to project" link too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The user can set Prefer Edit Mode in /settings to default to the editor. The previous commit wired ProjectCard to honor that preference. The same preference should also apply to "Back to project" links from side pages (settings, pull requests, analytics, suggestions, suggestion review), all of which were hard-coded to /projects/[id] (the viewer). Add a small useProjectHomeHref(projectId) hook that returns either /projects/[id] or /projects/[id]/editor depending on: - the user's preferEditMode preference (read from useEditorModeStore), and - their canSuggest permission on this project (derived via the existing derivePermissions helper). Read-only viewer-role users and unauthenticated visitors stay on the viewer URL regardless of preference, so the affordance never lands someone where they have no rights. Wired into all six existing "Back to project" link sites: - app/projects/[id]/settings/page.tsx (two callsites) - app/projects/[id]/pull-requests/page.tsx - app/projects/[id]/analytics/page.tsx - app/projects/[id]/suggestions/page.tsx - app/projects/[id]/suggestions/review/page.tsx Companion fix: __tests__/components/projects/lint-config-section.test.tsx imports symbols from settings/page.tsx, which now transitively pulls in useEditorModeStore. The store's module-level code calls window.matchMedia(...).addEventListener(...). The test had a matchMedia polyfill but it ran AFTER ES-module imports were hoisted, so the store init hit first and threw. Move the polyfill into vi.hoisted so it runs before any import resolves. Note that issue #206 still proposes the broader fix — sessionStorage- backed last-known-URL so we also restore the selection IRI / mode the user was in. This commit is the focused half: at minimum, preferEditMode is now honored end-to-end. The full breadcrumb restore lands in #206. Full suite: 2731 tests passing, type-check clean, lint 0 errors. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../projects/lint-config-section.test.tsx | 27 ++++++++++------- app/projects/[id]/analytics/page.tsx | 4 ++- app/projects/[id]/pull-requests/page.tsx | 4 ++- app/projects/[id]/settings/page.tsx | 7 +++-- app/projects/[id]/suggestions/page.tsx | 4 ++- app/projects/[id]/suggestions/review/page.tsx | 4 ++- lib/hooks/useProjectHomeHref.ts | 30 +++++++++++++++++++ 7 files changed, 63 insertions(+), 17 deletions(-) create mode 100644 lib/hooks/useProjectHomeHref.ts diff --git a/__tests__/components/projects/lint-config-section.test.tsx b/__tests__/components/projects/lint-config-section.test.tsx index 411271a8..487837c8 100644 --- a/__tests__/components/projects/lint-config-section.test.tsx +++ b/__tests__/components/projects/lint-config-section.test.tsx @@ -4,22 +4,27 @@ import userEvent from "@testing-library/user-event"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { renderWithQueryClient } from "@/__tests__/helpers/renderWithProviders"; -// ---- matchMedia polyfill (must be before any import that touches the store) ---- -if (typeof window !== "undefined" && !window.matchMedia) { +// ---- matchMedia polyfill (must run BEFORE module imports load the editor +// mode store, whose top-level code calls window.matchMedia(...).addEventListener +// during module initialization). vi.hoisted runs before ES-module hoisting so +// the polyfill is in place before any subsequent import resolves. ---- +vi.hoisted(() => { + if (typeof globalThis.window === "undefined") return; + if (typeof window.matchMedia === "function") return; Object.defineProperty(window, "matchMedia", { writable: true, - value: vi.fn().mockImplementation((query: string) => ({ + value: (query: string) => ({ matches: false, media: query, onchange: null, - addListener: vi.fn(), - removeListener: vi.fn(), - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - dispatchEvent: vi.fn(), - })), - }); -} + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }), + }); +}); // Mock heavy dependencies that the settings page imports but LintConfigSection doesn't use vi.mock("@/components/layout/header", () => ({ diff --git a/app/projects/[id]/analytics/page.tsx b/app/projects/[id]/analytics/page.tsx index d5fe105d..e1fc6713 100644 --- a/app/projects/[id]/analytics/page.tsx +++ b/app/projects/[id]/analytics/page.tsx @@ -10,6 +10,7 @@ import { useHotEntities, useContributors, } from "@/lib/hooks/useProjectAnalytics"; +import { useProjectHomeHref } from "@/lib/hooks/useProjectHomeHref"; import { cn, getLocalName } from "@/lib/utils"; export default function ProjectAnalyticsPage() { @@ -18,6 +19,7 @@ export default function ProjectAnalyticsPage() { const router = useRouter(); const projectId = params.id as string; const token = session?.accessToken; + const projectHomeHref = useProjectHomeHref(projectId); const { data: activity, isLoading: loadingActivity, error: activityError } = useProjectActivity( projectId, @@ -45,7 +47,7 @@ export default function ProjectAnalyticsPage() {
diff --git a/app/projects/[id]/pull-requests/page.tsx b/app/projects/[id]/pull-requests/page.tsx index 572a8930..d23dd2c9 100644 --- a/app/projects/[id]/pull-requests/page.tsx +++ b/app/projects/[id]/pull-requests/page.tsx @@ -11,6 +11,7 @@ import { PRList } from "@/components/pr/PRList"; import { PRCreateModal } from "@/components/pr/PRCreateModal"; import { BranchProvider } from "@/lib/context/BranchContext"; import { useProject, derivePermissions } from "@/lib/hooks/useProject"; +import { useProjectHomeHref } from "@/lib/hooks/useProjectHomeHref"; export default function PullRequestsPage() { const { data: session, status } = useSession(); @@ -20,6 +21,7 @@ export default function PullRequestsPage() { const { project, isLoading, error } = useProject(projectId, session?.accessToken); const { canEdit: canCreatePR } = derivePermissions(project, session?.accessToken); + const projectHomeHref = useProjectHomeHref(projectId); const [showCreateModal, setShowCreateModal] = useState(false); if (isLoading || status === "loading") { @@ -72,7 +74,7 @@ export default function PullRequestsPage() {
diff --git a/app/projects/[id]/settings/page.tsx b/app/projects/[id]/settings/page.tsx index b062d5bc..e420d8d4 100644 --- a/app/projects/[id]/settings/page.tsx +++ b/app/projects/[id]/settings/page.tsx @@ -57,6 +57,7 @@ import { } from "@/lib/api/joinRequests"; import { useRemoteSync } from "@/lib/hooks/useRemoteSync"; import { useProject, projectQueryKeys } from "@/lib/hooks/useProject"; +import { useProjectHomeHref } from "@/lib/hooks/useProjectHomeHref"; import { useMembers, memberQueryKeys } from "@/lib/hooks/useMembers"; import { useNormalizationStatus, normalizationQueryKeys } from "@/lib/hooks/useNormalizationStatus"; import { useIndexStatus, indexQueryKeys } from "@/lib/hooks/useIndexStatus"; @@ -102,6 +103,8 @@ export default function ProjectSettingsPage() { const params = useParams(); const projectId = params.id as string; + const projectHomeHref = useProjectHomeHref(projectId); + const queryClient = useQueryClient(); // Keep a ref to the latest accessToken so polling closures never go stale @@ -907,7 +910,7 @@ export default function ProjectSettingsPage() {
@@ -947,7 +950,7 @@ export default function ProjectSettingsPage() {
{/* Back link */} diff --git a/app/projects/[id]/suggestions/page.tsx b/app/projects/[id]/suggestions/page.tsx index 17d58816..f141477e 100644 --- a/app/projects/[id]/suggestions/page.tsx +++ b/app/projects/[id]/suggestions/page.tsx @@ -20,6 +20,7 @@ import { import { Header } from "@/components/layout/header"; import { Button } from "@/components/ui/button"; import { useProject } from "@/lib/hooks/useProject"; +import { useProjectHomeHref } from "@/lib/hooks/useProjectHomeHref"; import { suggestionsApi, type SuggestionSessionSummary, @@ -74,6 +75,7 @@ export default function SuggestionsPage() { const projectId = params.id as string; const { project, isLoading: isProjectLoading, error: projectError } = useProject(projectId, session?.accessToken); + const projectHomeHref = useProjectHomeHref(projectId); const [sessions, setSessions] = useState([]); const [isLoadingSessions, setIsLoadingSessions] = useState(true); const [sessionsError, setSessionsError] = useState(null); @@ -139,7 +141,7 @@ export default function SuggestionsPage() { {/* Navigation */}
diff --git a/app/projects/[id]/suggestions/review/page.tsx b/app/projects/[id]/suggestions/review/page.tsx index 3c05cbb2..ff37440f 100644 --- a/app/projects/[id]/suggestions/review/page.tsx +++ b/app/projects/[id]/suggestions/review/page.tsx @@ -22,6 +22,7 @@ import { import { Header } from "@/components/layout/header"; import { Button } from "@/components/ui/button"; import { useProject, derivePermissions } from "@/lib/hooks/useProject"; +import { useProjectHomeHref } from "@/lib/hooks/useProjectHomeHref"; import { suggestionsApi, type SuggestionSessionSummary, @@ -159,6 +160,7 @@ export default function SuggestionReviewPage() { const { project, isLoading: isProjectLoading, error: projectError } = useProject(projectId, session?.accessToken); const { canEdit: canReview } = derivePermissions(project, session?.accessToken); + const projectHomeHref = useProjectHomeHref(projectId); const [sessions, setSessions] = useState([]); const [isLoadingSessions, setIsLoadingSessions] = useState(true); @@ -322,7 +324,7 @@ export default function SuggestionReviewPage() {
diff --git a/lib/hooks/useProjectHomeHref.ts b/lib/hooks/useProjectHomeHref.ts new file mode 100644 index 00000000..307fdcdc --- /dev/null +++ b/lib/hooks/useProjectHomeHref.ts @@ -0,0 +1,30 @@ +"use client"; + +import { useSession } from "next-auth/react"; +import { derivePermissions, useProject } from "@/lib/hooks/useProject"; +import { useEditorModeStore } from "@/lib/stores/editorModeStore"; + +/** + * 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. + * + * 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. + * + * 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. + */ +export function useProjectHomeHref(projectId: string): string { + const { data: session } = useSession(); + const { project } = useProject(projectId, session?.accessToken); + const { canSuggest } = derivePermissions(project, session?.accessToken); + const preferEditMode = useEditorModeStore((s) => s.preferEditMode); + + return preferEditMode && canSuggest + ? `/projects/${projectId}/editor` + : `/projects/${projectId}`; +} From 612501c88f27b45edc9b5d242f9389c75d2ac86a Mon Sep 17 00:00:00 2001 From: "John R. D'Orazio" Date: Tue, 28 Apr 2026 23:12:57 +0200 Subject: [PATCH 19/21] test(useIriLabels): replace fragile setTimeout sleep with waitFor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The external-vocabulary skip-list test asserted that neither getClassDetail nor searchEntities was called for IRIs in the skip-list. The wait was a fixed `await new Promise(r => setTimeout(r, 50))`, which is timing-based and flaky in principle: under load the 50ms might not be enough for a late call to fire, hiding regressions; under fast machines the sleep is just dead weight. Replace with `await waitFor(() => { expect(...).not.toHaveBeenCalled(); })`, matching the rest of the test file's idiom and CodeRabbit's recommendation. waitFor's retry interval still gives any would-be probe calls time to issue before the assertion concludes, but uses RTL's deterministic helper rather than wall-clock sleep. The companion `it.each` for non-http(s) IRIs already uses waitFor against a positive signal (search WAS called) — kept as is, since that's the gold-standard pattern. Full suite: 2731 tests passing. Co-Authored-By: Claude Opus 4.7 (1M context) --- __tests__/lib/hooks/useIriLabels.test.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/__tests__/lib/hooks/useIriLabels.test.ts b/__tests__/lib/hooks/useIriLabels.test.ts index d18f0b90..327a9955 100644 --- a/__tests__/lib/hooks/useIriLabels.test.ts +++ b/__tests__/lib/hooks/useIriLabels.test.ts @@ -217,11 +217,13 @@ describe("useIriLabels", () => { useIriLabels([iri], { projectId: "proj-1", accessToken: "token" }), ); - // Give the effect a chance to run - await new Promise((r) => setTimeout(r, 50)); - - expect(mockedGetClassDetail).not.toHaveBeenCalled(); - expect(mockedSearchEntities).not.toHaveBeenCalled(); + // Wait for the hook's effect to settle. waitFor's retry interval gives + // any would-be probe calls time to issue before we assert their absence. + // The result for an external-vocab IRI stays undefined throughout. + await waitFor(() => { + expect(mockedGetClassDetail).not.toHaveBeenCalled(); + expect(mockedSearchEntities).not.toHaveBeenCalled(); + }); expect(result.current[iri]).toBeUndefined(); }); From 6f9a2610e945c8bb8d8690f984bcb02d6d3bb634 Mon Sep 17 00:00:00 2001 From: damienriehl Date: Sat, 2 May 2026 07:59:04 -0500 Subject: [PATCH 20/21] refactor(ClassDetailPanel): drop unused isSuggestionMode prop The prop was declared on ClassDetailPanelProps but never destructured or read by the component. Both layouts (Standard, Developer) passed it through; the panel ignored it. Sister panels PropertyDetailPanel and IndividualDetailPanel never had this prop at all. Drops the prop from the interface and from the two call sites, and removes a now-stale isSuggestionMode assertion from the StandardEditorLayout test (the canEdit assertion still covers the suggestion-mode case). Closes #225 Co-Authored-By: Claude Opus 4.7 --- .../components/editor/standard/StandardEditorLayout.test.tsx | 1 - components/editor/ClassDetailPanel.tsx | 1 - components/editor/developer/DeveloperEditorLayout.tsx | 1 - components/editor/standard/StandardEditorLayout.tsx | 1 - 4 files changed, 4 deletions(-) diff --git a/__tests__/components/editor/standard/StandardEditorLayout.test.tsx b/__tests__/components/editor/standard/StandardEditorLayout.test.tsx index 1e2fbab2..c15c3ecc 100644 --- a/__tests__/components/editor/standard/StandardEditorLayout.test.tsx +++ b/__tests__/components/editor/standard/StandardEditorLayout.test.tsx @@ -442,7 +442,6 @@ describe("StandardEditorLayout", () => { /> ); expect(_classDetailProps.canEdit).toBe(true); - expect(_classDetailProps.isSuggestionMode).toBe(true); }); it("forwards detailRefreshKey to ClassDetailPanel", () => { diff --git a/components/editor/ClassDetailPanel.tsx b/components/editor/ClassDetailPanel.tsx index 9902cc5b..9119b552 100644 --- a/components/editor/ClassDetailPanel.tsx +++ b/components/editor/ClassDetailPanel.tsx @@ -71,7 +71,6 @@ interface ClassDetailPanelProps { onCopyIri?: (iri: string) => void; selectedNodeFallback?: TreeNodeFallback | null; canEdit?: boolean; - isSuggestionMode?: boolean; onUpdateClass?: (classIri: string, data: ClassUpdatePayload) => Promise; refreshKey?: number; /** Extra actions rendered in the header row (e.g. Graph button) */ diff --git a/components/editor/developer/DeveloperEditorLayout.tsx b/components/editor/developer/DeveloperEditorLayout.tsx index cb4c3b0d..e4502a61 100644 --- a/components/editor/developer/DeveloperEditorLayout.tsx +++ b/components/editor/developer/DeveloperEditorLayout.tsx @@ -556,7 +556,6 @@ export function DeveloperEditorLayout(props: DeveloperEditorLayoutProps) { onCopyIri={onCopyIri} selectedNodeFallback={selectedNodeFallback} canEdit={canEdit || isSuggestionMode} - isSuggestionMode={isSuggestionMode} onUpdateClass={onUpdateClass} refreshKey={detailRefreshKey} /> diff --git a/components/editor/standard/StandardEditorLayout.tsx b/components/editor/standard/StandardEditorLayout.tsx index 0ecdf71b..e7a81e94 100644 --- a/components/editor/standard/StandardEditorLayout.tsx +++ b/components/editor/standard/StandardEditorLayout.tsx @@ -452,7 +452,6 @@ export function StandardEditorLayout(props: StandardEditorLayoutProps) { onCopyIri={onCopyIri} selectedNodeFallback={selectedNodeFallback} canEdit={canEdit || isSuggestionMode} - isSuggestionMode={isSuggestionMode} onUpdateClass={onUpdateClass} refreshKey={detailRefreshKey} headerActions={selectedIri ? ( From 898137f9acda1ec490e67ee6c56c26d3ed9a4e63 Mon Sep 17 00:00:00 2001 From: damienriehl Date: Sat, 2 May 2026 07:59:18 -0500 Subject: [PATCH 21/21] refactor(IndividualDetailPanel): adopt flushToGitRef pattern for unmount-flush MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ClassDetailPanel and PropertyDetailPanel route their unmount-flush through a flushToGitRef that's kept in sync via a separate effect, so cleanup sees the latest closure rather than the one captured at mount time (relevant if flushToGit is recreated mid-life by token refresh, branch change, etc.). IndividualDetailPanel was the only one of the three panels still using the older [individualIri]-keyed cleanup. Aligned it with the others. Updated the matching test to exercise the unmount contract directly (remember both layouts already remount the panel via key= on selection change, so unmount IS the production "navigate away" signal). Suppressed two react-hooks/set-state-in-effect warnings in pre-existing patterns (resetting edit state on selection change, restoring draft state on auto-enter) that surfaced after the refactor shifted line numbers — matching the suppression style already used in this file. Closes #226 Co-Authored-By: Claude Opus 4.7 --- .../editor/IndividualDetailPanel.test.tsx | 17 +++++++---------- components/editor/IndividualDetailPanel.tsx | 18 ++++++++++++++---- 2 files changed, 21 insertions(+), 14 deletions(-) diff --git a/__tests__/components/editor/IndividualDetailPanel.test.tsx b/__tests__/components/editor/IndividualDetailPanel.test.tsx index e7323b1c..56c56e2e 100644 --- a/__tests__/components/editor/IndividualDetailPanel.test.tsx +++ b/__tests__/components/editor/IndividualDetailPanel.test.tsx @@ -676,19 +676,16 @@ describe("IndividualDetailPanel", () => { expect(mockClearRestoredDraft).toHaveBeenCalled(); }); - // ── flushToGit on IRI change ── + // ── flushToGit on unmount ── - it("calls flushToGit when individualIri changes", async () => { - const { rerender } = render( + it("flushes pending draft to git on unmount", async () => { + // The parent layout remounts the panel on selection change via a key + // prop, so the panel's contract for "navigate away" is "unmount and let + // the cleanup flush". This test exercises that contract directly. + const { unmount } = render( ); - rerender( - - ); + unmount(); await waitFor(() => { expect(mockFlushToGit).toHaveBeenCalled(); }); diff --git a/components/editor/IndividualDetailPanel.tsx b/components/editor/IndividualDetailPanel.tsx index e29284a4..adcf78d0 100644 --- a/components/editor/IndividualDetailPanel.tsx +++ b/components/editor/IndividualDetailPanel.tsx @@ -233,17 +233,26 @@ export function IndividualDetailPanel({ setEditAnnotations(regularAnnotations); }, []); - // Flush the current entity when navigating away (cleanup runs with the old closure) + // Flush any pending draft to git when this panel unmounts. The flush + // closure captures props that can change between mount and unmount, so + // route the call through a ref that we keep up to date in an effect — the + // cleanup then sees the latest closure rather than the one captured on + // first render. (Mirrors the pattern used in ClassDetailPanel / + // PropertyDetailPanel.) + const flushToGitRef = useRef(flushToGit); + useEffect(() => { + flushToGitRef.current = flushToGit; + }, [flushToGit]); useEffect(() => { return () => { - flushToGit(); + flushToGitRef.current(); }; - // eslint-disable-next-line react-hooks/exhaustive-deps -- flush must capture the current entity's closure - }, [individualIri]); + }, []); // Reset edit state when the selected individual changes useEffect(() => { editInitializedRef.current = false; + // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional reset on selection change; matches ClassDetailPanel/PropertyDetailPanel patterns setIsEditing(false); }, [individualIri]); @@ -272,6 +281,7 @@ export function IndividualDetailPanel({ if (restoredDraft && restoredDraft.entityType === "individual" && individualIri) { const d = restoredDraft as IndividualDraftEntry; + // eslint-disable-next-line react-hooks/set-state-in-effect -- restoring draft state from store; matches PropertyDetailPanel auto-enter pattern setEditLabels(d.labels); setEditComments(ensureTrailingEmpty(d.comments)); setEditDefinitions(ensureTrailingEmpty(d.definitions));