diff --git a/__tests__/components/editor/ClassDetailPanel.test.tsx b/__tests__/components/editor/ClassDetailPanel.test.tsx index f6b4b302..1ae1819b 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 @@ -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 () => { @@ -430,39 +446,6 @@ describe("ClassDetailPanel", () => { expect(screen.queryByText("Edit Item")).toBeNull(); }); - // ── Edit mode (canEdit=true) ── - - it("shows Edit Item button when canEdit is true and onUpdateClass provided", async () => { - const onUpdateClass = vi.fn(); - render( - - ); - - await waitFor(() => { - expect(screen.getByText("Edit Item")).toBeDefined(); - }); - }); - - 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,8 +564,7 @@ describe("ClassDetailPanel", () => { // ── Edit mode entry ── - it("enters edit mode when Edit Item button is clicked", async () => { - const user = userEvent.setup(); + it("auto-enters edit mode and renders inputs when onUpdateClass is provided", async () => { const onUpdateClass = vi.fn(); render( { ); await waitFor(() => { - expect(screen.getByText("Edit Item")).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"); - 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 ── @@ -620,9 +596,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(); @@ -647,9 +622,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.getAllByLabelText("Language tag").length).toBeGreaterThanOrEqual(1); @@ -665,7 +639,6 @@ describe("ClassDetailPanel", () => { // ── Comment editing ── it("renders comment textareas in edit mode", async () => { - const user = userEvent.setup(); const onUpdateClass = vi.fn(); render( { ); 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 @@ -700,9 +672,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(); @@ -727,9 +698,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(); @@ -745,7 +715,6 @@ describe("ClassDetailPanel", () => { // ── Parent editing ── it("shows parent with remove button in edit mode", async () => { - const user = userEvent.setup(); const onUpdateClass = vi.fn(); render( { ); 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(); @@ -779,9 +747,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(); @@ -808,9 +775,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(); @@ -825,6 +791,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 () => { @@ -839,9 +835,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(); @@ -856,7 +851,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(); @@ -869,9 +864,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(); @@ -883,40 +877,36 @@ 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 () => { + 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( - - ); - - rerender( + const { unmount } = render( ); - await waitFor(() => { - expect(mockFlushToGit).toHaveBeenCalled(); + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); }); + + unmount(); + + expect(mockFlushToGit).toHaveBeenCalled(); }); // ── Multiple labels with remove ── it("shows remove button only when there are multiple labels", async () => { - const user = userEvent.setup(); const onUpdateClass = vi.fn(); mockGetClassDetail.mockResolvedValue( makeClassDetail({ @@ -935,9 +925,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"); @@ -965,9 +954,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(); @@ -981,10 +969,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 () => { - const user = userEvent.setup(); + it("never renders an Edit Item button in editor context", async () => { const onUpdateClass = vi.fn(); render( { ); await waitFor(() => { - expect(screen.getByText("Edit Item")).not.toBeNull(); - }); - await user.click(screen.getByText("Edit Item")); - - await waitFor(() => { - expect(screen.queryByText("Edit Item")).toBeNull(); + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); }); + expect(screen.queryByText("Edit Item")).toBeNull(); }); // ── Definition in edit mode ── it("renders Definition section with annotation row in edit mode", async () => { - const user = userEvent.setup(); const onUpdateClass = vi.fn(); mockGetClassDetail.mockResolvedValue( makeClassDetail({ @@ -1029,9 +1011,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,7 +1189,6 @@ describe("ClassDetailPanel", () => { // ── Edit mode with empty labels (no labels from server) ── it("initializes edit mode with empty label placeholder when server has no labels", async () => { - const user = userEvent.setup(); const onUpdateClass = vi.fn(); mockGetClassDetail.mockResolvedValue(makeClassDetail({ labels: [] })); render( @@ -1220,9 +1200,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 @@ -1235,7 +1214,6 @@ describe("ClassDetailPanel", () => { // ── Comment ghost row (trailing empty placeholder) ── it("shows ghost comment row with placeholder text in edit mode", async () => { - const user = userEvent.setup(); const onUpdateClass = vi.fn(); render( { ); 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 @@ -1280,7 +1257,6 @@ describe("ClassDetailPanel", () => { // ── Edit mode with relationship annotations ── it("initializes edit mode with relationship annotations and regular annotations", async () => { - const user = userEvent.setup(); const onUpdateClass = vi.fn(); mockGetClassDetail.mockResolvedValue( makeClassDetail({ @@ -1307,9 +1283,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(() => { @@ -1343,7 +1318,6 @@ describe("ClassDetailPanel", () => { // ── Edit mode adds Definition section even if not present in server data ── it("adds empty Definition section in edit mode when not in server data", async () => { - const user = userEvent.setup(); const onUpdateClass = vi.fn(); mockGetClassDetail.mockResolvedValue( makeClassDetail({ annotations: [] }) @@ -1357,9 +1331,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(); @@ -1509,7 +1482,6 @@ describe("ClassDetailPanel", () => { // ── Edit mode with isDefinedBy relationship ── it("initializes edit mode with isDefinedBy relationship", async () => { - const user = userEvent.setup(); const onUpdateClass = vi.fn(); mockGetClassDetail.mockResolvedValue( makeClassDetail({ @@ -1531,9 +1503,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(); @@ -1572,9 +1543,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(); @@ -1594,7 +1564,6 @@ describe("ClassDetailPanel", () => { // ── Edit mode: definition section with existing values shows annotation rows ── it("renders definition values in edit mode with annotation rows", async () => { - const user = userEvent.setup(); const onUpdateClass = vi.fn(); const DEFINITION_IRI = "http://www.w3.org/2004/02/skos/core#definition"; mockGetClassDetail.mockResolvedValue( @@ -1620,9 +1589,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,7 +1683,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); @@ -1728,9 +1696,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(); @@ -1747,28 +1714,9 @@ describe("ClassDetailPanel", () => { expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); }); - // ── Continuous editing auto-enter ── + // ── Cancel keeps the panel in edit mode ── - it("auto-enters edit mode when continuousEditing is true", async () => { - editorModeOverrides = { continuousEditing: true }; - const onUpdateClass = vi.fn(); - render( - - ); - - await waitFor(() => { - expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); - }); - }); - - // ── Continuous editing does not auto-enter after cancel ── - - it("does not re-enter edit mode after cancel even with continuousEditing", async () => { - editorModeOverrides = { continuousEditing: true }; + it("stays in edit mode after cancel and discards the draft", async () => { const user = userEvent.setup(); const onUpdateClass = vi.fn(); render( @@ -1788,8 +1736,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 ── @@ -1850,7 +1798,6 @@ describe("ClassDetailPanel", () => { // ── InlineAnnotationAdder onAdd adds to existing annotation ── it("InlineAnnotationAdder adds value to existing annotation property", async () => { - const user = userEvent.setup(); const onUpdateClass = vi.fn(); const PREF_LABEL_IRI = "http://www.w3.org/2004/02/skos/core#prefLabel"; mockGetClassDetail.mockResolvedValue( @@ -1873,9 +1820,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(); @@ -1893,7 +1839,6 @@ describe("ClassDetailPanel", () => { // ── InlineAnnotationAdder onAdd creates new annotation property ── it("InlineAnnotationAdder creates new annotation property when not existing", async () => { - const user = userEvent.setup(); const onUpdateClass = vi.fn(); mockGetClassDetail.mockResolvedValue( makeClassDetail({ annotations: [] }) @@ -1907,9 +1852,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(); @@ -1926,7 +1870,6 @@ describe("ClassDetailPanel", () => { // ── RelationshipSection callbacks in edit mode ── it("passes relationship callbacks to RelationshipSection in edit mode", async () => { - const user = userEvent.setup(); const onUpdateClass = vi.fn(); mockGetClassDetail.mockResolvedValue( makeClassDetail({ @@ -1948,9 +1891,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(); @@ -1965,7 +1907,6 @@ describe("ClassDetailPanel", () => { }); it("invokes removeRelationshipTarget via RelationshipSection", async () => { - const user = userEvent.setup(); const onUpdateClass = vi.fn(); mockGetClassDetail.mockResolvedValue( makeClassDetail({ @@ -1987,9 +1928,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(); @@ -2003,7 +1943,6 @@ describe("ClassDetailPanel", () => { }); it("invokes changeRelationshipProperty via RelationshipSection", async () => { - const user = userEvent.setup(); const onUpdateClass = vi.fn(); mockGetClassDetail.mockResolvedValue( makeClassDetail({ @@ -2025,9 +1964,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(); @@ -2041,7 +1979,6 @@ describe("ClassDetailPanel", () => { }); it("invokes addRelationshipGroup via RelationshipSection", async () => { - const user = userEvent.setup(); const onUpdateClass = vi.fn(); render( { ); 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(); @@ -2073,7 +2009,6 @@ describe("ClassDetailPanel", () => { // ── RelationshipSection onSaveNeeded callback ── it("invokes triggerSave via RelationshipSection onSaveNeeded", async () => { - const user = userEvent.setup(); const onUpdateClass = vi.fn(); mockGetClassDetail.mockResolvedValue( makeClassDetail({ @@ -2095,9 +2030,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(); @@ -2111,7 +2045,6 @@ describe("ClassDetailPanel", () => { // ── Annotation value update and remove callbacks in edit mode ── it("updates annotation value via AnnotationRow callback in edit mode", async () => { - const user = userEvent.setup(); const onUpdateClass = vi.fn(); const PREF_LABEL_IRI = "http://www.w3.org/2004/02/skos/core#prefLabel"; mockGetClassDetail.mockResolvedValue( @@ -2134,9 +2067,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( @@ -2160,7 +2092,6 @@ describe("ClassDetailPanel", () => { }); it("removes annotation value via AnnotationRow callback in edit mode", async () => { - const user = userEvent.setup(); const onUpdateClass = vi.fn(); const PREF_LABEL_IRI = "http://www.w3.org/2004/02/skos/core#prefLabel"; mockGetClassDetail.mockResolvedValue( @@ -2186,9 +2117,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( @@ -2212,7 +2142,6 @@ describe("ClassDetailPanel", () => { // ── Annotation definition update callback via AnnotationRow ── it("updates definition annotation via AnnotationRow callback", async () => { - const user = userEvent.setup(); const onUpdateClass = vi.fn(); const DEFINITION_IRI = "http://www.w3.org/2004/02/skos/core#definition"; mockGetClassDetail.mockResolvedValue( @@ -2235,9 +2164,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( @@ -2263,7 +2191,6 @@ describe("ClassDetailPanel", () => { // ── Definition AnnotationRow onBlur triggers save ── it("definition AnnotationRow onBlur triggers triggerSave", async () => { - const user = userEvent.setup(); const onUpdateClass = vi.fn(); const DEFINITION_IRI = "http://www.w3.org/2004/02/skos/core#definition"; mockGetClassDetail.mockResolvedValue( @@ -2286,9 +2213,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( @@ -2307,10 +2233,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(() => { @@ -2357,7 +2282,6 @@ describe("ClassDetailPanel", () => { // ── Annotation language change callback ── it("updates annotation language via AnnotationRow callback", async () => { - const user = userEvent.setup(); const onUpdateClass = vi.fn(); const PREF_LABEL_IRI = "http://www.w3.org/2004/02/skos/core#prefLabel"; mockGetClassDetail.mockResolvedValue( @@ -2380,9 +2304,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( @@ -2408,7 +2331,6 @@ describe("ClassDetailPanel", () => { // ── Definition remove callback ── it("removes definition value via AnnotationRow callback", async () => { - const user = userEvent.setup(); const onUpdateClass = vi.fn(); const DEFINITION_IRI = "http://www.w3.org/2004/02/skos/core#definition"; mockGetClassDetail.mockResolvedValue( @@ -2434,9 +2356,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( @@ -2459,7 +2380,6 @@ describe("ClassDetailPanel", () => { // ── Annotation AnnotationRow onBlur triggers save ── it("annotation AnnotationRow onBlur triggers triggerSave", async () => { - const user = userEvent.setup(); const onUpdateClass = vi.fn(); const PREF_LABEL_IRI = "http://www.w3.org/2004/02/skos/core#prefLabel"; mockGetClassDetail.mockResolvedValue( @@ -2482,9 +2402,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 e33f7796..56c56e2e 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", () => ({ @@ -366,7 +366,7 @@ describe("IndividualDetailPanel", () => { // ── Edit mode (canEdit=true) ── - it("shows Edit Item button when canEdit is true and onUpdateIndividual provided", () => { + it("never renders an Edit Item button in editor context", async () => { const onUpdateIndividual = vi.fn(); render( { onUpdateIndividual={onUpdateIndividual} /> ); - expect(screen.getByText("Edit Item")).toBeDefined(); - }); - it("enters edit mode when Edit Item is clicked", async () => { - const user = userEvent.setup(); - const onUpdateIndividual = vi.fn(); - render( - - ); - await user.click(screen.getByText("Edit Item")); - expect(screen.getByTestId("auto-save-bar")).toBeDefined(); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); + expect(screen.queryByText("Edit Item")).toBeNull(); }); // ── API call verification ── @@ -448,7 +438,6 @@ describe("IndividualDetailPanel", () => { // ── Edit mode: label editing ── it("renders editable label inputs in edit mode", async () => { - const user = userEvent.setup(); const onUpdateIndividual = vi.fn(); render( { 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); }); it("renders editable comment section in edit mode", async () => { - const user = userEvent.setup(); 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(screen.getByText("Comment(s)")).not.toBeNull(); }); it("renders editable definition section in edit mode", async () => { - const user = userEvent.setup(); 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(screen.getByText("Definition")).not.toBeNull(); }); it("renders types section in edit mode", async () => { - const user = userEvent.setup(); 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(screen.getByText("Type(s)")).not.toBeNull(); }); it("renders same-as section in edit mode", async () => { - const user = userEvent.setup(); 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(screen.getByText("Same As")).not.toBeNull(); }); it("renders different-from section in edit mode", async () => { - const user = userEvent.setup(); 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(screen.getByText("Different From")).not.toBeNull(); }); it("renders object properties section in edit mode", async () => { - const user = userEvent.setup(); 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(screen.getByText("Object Properties")).not.toBeNull(); }); it("renders data properties section in edit mode", async () => { - const user = userEvent.setup(); 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(screen.getByText("Data Properties")).not.toBeNull(); }); it("renders annotations section in edit mode", async () => { - const user = userEvent.setup(); 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(screen.getByText("Annotations")).not.toBeNull(); }); it("renders relationships section in edit mode", async () => { - const user = userEvent.setup(); 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(screen.getByText("Relationships")).not.toBeNull(); }); @@ -600,7 +600,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"); @@ -610,7 +612,6 @@ describe("IndividualDetailPanel", () => { // ── Cancel edit mode ── it("shows auto-save bar when in edit mode", async () => { - const user = userEvent.setup(); 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(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", async () => { const onUpdateIndividual = vi.fn(); render( { 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(); }); @@ -694,10 +693,9 @@ describe("IndividualDetailPanel", () => { // ── 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(); }); @@ -714,7 +712,6 @@ describe("IndividualDetailPanel", () => { // ── Language tag input in edit mode ── it("renders language tag pickers in edit mode", async () => { - const user = userEvent.setup(); const onUpdateIndividual = vi.fn(); render( { onUpdateIndividual={onUpdateIndividual} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); const langPickers = screen.getAllByLabelText("Language tag"); expect(langPickers.length).toBeGreaterThanOrEqual(1); }); @@ -740,7 +739,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(); @@ -763,7 +764,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", @@ -779,7 +780,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 ── @@ -854,7 +860,6 @@ describe("IndividualDetailPanel", () => { ], }) ); - const user = userEvent.setup(); const onUpdateIndividual = vi.fn(); const { container } = render( { 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); }); @@ -881,7 +888,9 @@ describe("IndividualDetailPanel", () => { onUpdateIndividual={onUpdateIndividual} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); const langPickers = screen.getAllByLabelText("Language tag"); expect(langPickers.length).toBeGreaterThanOrEqual(1); await user.selectOptions(langPickers[0], "fr"); @@ -895,7 +904,6 @@ describe("IndividualDetailPanel", () => { mockExtractIndividualDetail.mockReturnValue( makeIndividualDetail({ comments: [] }) ); - const user = userEvent.setup(); 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(screen.getByText("Comment(s)")).not.toBeNull(); }); @@ -914,7 +924,6 @@ describe("IndividualDetailPanel", () => { mockExtractIndividualDetail.mockReturnValue( makeIndividualDetail({ definitions: [] }) ); - const user = userEvent.setup(); 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(screen.getByText("Definition")).not.toBeNull(); }); @@ -933,7 +944,6 @@ describe("IndividualDetailPanel", () => { mockExtractIndividualDetail.mockReturnValue( makeIndividualDetail({ typeIris: [] }) ); - const user = userEvent.setup(); 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(screen.getByText("Type(s)")).not.toBeNull(); }); @@ -952,7 +964,6 @@ describe("IndividualDetailPanel", () => { mockExtractIndividualDetail.mockReturnValue( makeIndividualDetail({ sameAsIris: [] }) ); - const user = userEvent.setup(); 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(screen.getByText("Same As")).not.toBeNull(); }); @@ -971,7 +984,6 @@ describe("IndividualDetailPanel", () => { mockExtractIndividualDetail.mockReturnValue( makeIndividualDetail({ differentFromIris: [] }) ); - const user = userEvent.setup(); 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(screen.getByText("Different From")).not.toBeNull(); }); @@ -1135,7 +1149,6 @@ describe("IndividualDetailPanel", () => { // ── Cancel edit mode via AutoSaveAffordanceBar ── it("invokes cancelEditMode via AutoSaveAffordanceBar onCancel", async () => { - const user = userEvent.setup(); 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(capturedAutoSaveBarProps).not.toBeNull(); const onCancel = capturedAutoSaveBarProps!.onCancel as () => void; onCancel(); @@ -1153,8 +1168,7 @@ describe("IndividualDetailPanel", () => { // ── Manual save via AutoSaveAffordanceBar ── - it("invokes saveAndExitEditMode via AutoSaveAffordanceBar onManualSave", async () => { - const user = userEvent.setup(); + it("invokes flushDraftToGit via AutoSaveAffordanceBar onManualSave", 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(capturedAutoSaveBarProps).not.toBeNull(); const onManualSave = capturedAutoSaveBarProps!.onManualSave as () => Promise; await onManualSave(); @@ -1174,7 +1190,6 @@ describe("IndividualDetailPanel", () => { // ── Retry via AutoSaveAffordanceBar ── it("invokes flushToGit via AutoSaveAffordanceBar onRetry", async () => { - const user = userEvent.setup(); 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(capturedAutoSaveBarProps).not.toBeNull(); const onRetry = capturedAutoSaveBarProps!.onRetry as () => void; onRetry(); @@ -1193,7 +1210,6 @@ describe("IndividualDetailPanel", () => { // ── AnnotationRow callbacks for comments ── it("passes comment callbacks to AnnotationRow", async () => { - const user = userEvent.setup(); const onUpdateIndividual = vi.fn(); render( { 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" ); @@ -1231,7 +1249,6 @@ describe("IndividualDetailPanel", () => { // ── AnnotationRow callbacks for definitions ── it("passes definition callbacks to AnnotationRow", async () => { - const user = userEvent.setup(); const onUpdateIndividual = vi.fn(); render( { 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" ); @@ -1259,7 +1278,6 @@ describe("IndividualDetailPanel", () => { // ── AnnotationRow onBlur triggers save ── it("AnnotationRow onBlur triggers triggerSave", async () => { - const user = userEvent.setup(); const onUpdateIndividual = vi.fn(); render( { 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" ); @@ -1289,7 +1309,6 @@ describe("IndividualDetailPanel", () => { ], }) ); - const user = userEvent.setup(); const onUpdateIndividual = vi.fn(); render( { 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" ); @@ -1323,7 +1344,6 @@ describe("IndividualDetailPanel", () => { ], }) ); - const user = userEvent.setup(); const onUpdateIndividual = vi.fn(); render( { 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" ); @@ -1349,7 +1371,6 @@ describe("IndividualDetailPanel", () => { // ── PropertyAssertionSection callbacks ── it("passes onAdd callback to PropertyAssertionSection for object properties", async () => { - const user = userEvent.setup(); const onUpdateIndividual = vi.fn(); render( { 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" ); @@ -1381,7 +1404,6 @@ describe("IndividualDetailPanel", () => { ], }) ); - const user = userEvent.setup(); const onUpdateIndividual = vi.fn(); render( { 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" ); @@ -1403,7 +1427,6 @@ describe("IndividualDetailPanel", () => { }); it("passes onAdd callback to PropertyAssertionSection for data properties", async () => { - const user = userEvent.setup(); const onUpdateIndividual = vi.fn(); render( { 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" ); @@ -1435,7 +1460,6 @@ describe("IndividualDetailPanel", () => { ], }) ); - const user = userEvent.setup(); const onUpdateIndividual = vi.fn(); render( { 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" ); @@ -1464,7 +1490,6 @@ describe("IndividualDetailPanel", () => { seeAlsoIris: ["http://example.org/ontology#related"], }) ); - const user = userEvent.setup(); 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 onAddTarget = capturedRelationshipSectionProps!.onAddTarget as (groupIdx: number, target: { iri: string; label: string }) => void; onAddTarget(0, { iri: "http://example.org/ontology#newRelated", label: "newRelated" }); @@ -1488,7 +1515,6 @@ describe("IndividualDetailPanel", () => { seeAlsoIris: ["http://example.org/ontology#related"], }) ); - const user = userEvent.setup(); 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 onRemoveTarget = capturedRelationshipSectionProps!.onRemoveTarget as (groupIdx: number, targetIdx: number) => void; onRemoveTarget(0, 0); @@ -1507,7 +1535,6 @@ describe("IndividualDetailPanel", () => { }); it("invokes changeRelationshipProperty via RelationshipSection", async () => { - const user = userEvent.setup(); 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 onChangeProperty = capturedRelationshipSectionProps!.onChangeProperty as (groupIdx: number, newIri: string, newLabel: string) => void; onChangeProperty(0, "http://www.w3.org/2000/01/rdf-schema#isDefinedBy", "Defined By"); @@ -1526,7 +1555,6 @@ describe("IndividualDetailPanel", () => { }); it("invokes addRelationshipGroup via RelationshipSection", async () => { - const user = userEvent.setup(); 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 groupsBefore = (capturedRelationshipSectionProps!.groups as unknown[]).length; const onAddGroup = capturedRelationshipSectionProps!.onAddGroup as () => void; @@ -1560,7 +1590,6 @@ describe("IndividualDetailPanel", () => { ], }) ); - const user = userEvent.setup(); const onUpdateIndividual = vi.fn(); render( { 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" ); @@ -1609,7 +1640,6 @@ describe("IndividualDetailPanel", () => { ], }) ); - const user = userEvent.setup(); const onUpdateIndividual = vi.fn(); render( { 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" ); @@ -1641,7 +1673,6 @@ describe("IndividualDetailPanel", () => { isDefinedByIris: ["http://example.org/ontology#ontology"], }) ); - const user = userEvent.setup(); 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(); expect(capturedRelationshipSectionProps!.isEditing).toBe(true); }); @@ -1675,7 +1708,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]); @@ -1704,7 +1739,6 @@ describe("IndividualDetailPanel", () => { // ── PropertyAssertionSection onSaveNeeded callback ── it("passes onSaveNeeded callback to PropertyAssertionSection", async () => { - const user = userEvent.setup(); const onUpdateIndividual = vi.fn(); render( { 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" ); @@ -1725,8 +1761,7 @@ describe("IndividualDetailPanel", () => { // ── Manual save stays in edit mode when flushToGit fails ── - it("stays in edit mode when saveAndExitEditMode flush fails", async () => { - const user = userEvent.setup(); + it("stays in edit mode when flushDraftToGit flush fails", async () => { const onUpdateIndividual = vi.fn(); mockFlushToGit.mockResolvedValue(false); render( @@ -1736,7 +1771,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(); @@ -1760,7 +1797,6 @@ describe("IndividualDetailPanel", () => { ], }) ); - const user = userEvent.setup(); const onUpdateIndividual = vi.fn(); render( { 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(); @@ -1792,7 +1830,6 @@ describe("IndividualDetailPanel", () => { mockExtractIndividualDetail.mockReturnValue( makeIndividualDetail({ annotations: [] }) ); - const user = userEvent.setup(); const onUpdateIndividual = vi.fn(); render( { 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(); @@ -1821,7 +1860,6 @@ describe("IndividualDetailPanel", () => { // ── InlineAnnotationAdder onSaveNeeded callback ── it("InlineAnnotationAdder onSaveNeeded calls triggerSave", async () => { - const user = userEvent.setup(); const onUpdateIndividual = vi.fn(); render( { 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(); @@ -1844,7 +1884,6 @@ describe("IndividualDetailPanel", () => { // ── RelationshipSection onSaveNeeded callback ── it("RelationshipSection onSaveNeeded calls triggerSave", async () => { - const user = userEvent.setup(); const onUpdateIndividual = vi.fn(); render( { 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(); @@ -1871,10 +1912,9 @@ describe("IndividualDetailPanel", () => { expect(screen.getByText("Label(s)")).not.toBeNull(); }); - // ── Cancel after continuous editing prevents re-entry ── + // ── Cancel keeps the panel in edit mode ── - it("does not re-enter edit mode after cancel even with continuousEditing", async () => { - editorModeOverrides = { continuousEditing: true }; + it("stays in edit mode after cancel and discards the draft", async () => { const onUpdateIndividual = vi.fn(); render( { onUpdateIndividual={onUpdateIndividual} /> ); - expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); // Trigger cancel expect(capturedAutoSaveBarProps).not.toBeNull(); @@ -1892,8 +1934,8 @@ describe("IndividualDetailPanel", () => { 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 ── @@ -1905,7 +1947,6 @@ describe("IndividualDetailPanel", () => { isDefinedByIris: ["http://example.org/ontology#myOntology"], }) ); - const user = userEvent.setup(); 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"); @@ -1946,7 +1989,6 @@ describe("IndividualDetailPanel", () => { mockExtractIndividualDetail.mockReturnValue( makeIndividualDetail({ labels: [] }) ); - const user = userEvent.setup(); const onUpdateIndividual = vi.fn(); render( { 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(""); @@ -1983,7 +2027,6 @@ describe("IndividualDetailPanel", () => { // ── PropertyAssertionSection data onSaveNeeded callback ── it("passes onSaveNeeded callback to data PropertyAssertionSection", async () => { - const user = userEvent.setup(); const onUpdateIndividual = vi.fn(); render( { 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" ); @@ -2015,7 +2060,6 @@ describe("IndividualDetailPanel", () => { ], }) ); - const user = userEvent.setup(); const onUpdateIndividual = vi.fn(); render( { 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 40a68f99..f62312f3 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", () => ({ @@ -381,7 +381,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 () => { - const user = userEvent.setup(); + it("never renders an Edit Item button in editor context", async () => { const onUpdateProperty = vi.fn(); render( { onUpdateProperty={onUpdateProperty} /> ); - await user.click(screen.getByText("Edit Item")); - // Auto-save bar should appear - expect(screen.getByTestId("auto-save-bar")).toBeDefined(); + + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); + expect(screen.queryByText("Edit Item")).toBeNull(); }); // ── API call verification ── @@ -465,7 +468,6 @@ describe("PropertyDetailPanel", () => { // ── Edit mode: label editing ── it("renders editable label inputs in edit mode", async () => { - const user = userEvent.setup(); 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 labelInputs = screen.getAllByPlaceholderText("Label text"); expect(labelInputs.length).toBeGreaterThanOrEqual(1); }); it("renders editable comment section in edit mode", async () => { - const user = userEvent.setup(); const onUpdateProperty = vi.fn(); render( { 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(); }); it("renders editable definition section in edit mode", async () => { - const user = userEvent.setup(); const onUpdateProperty = vi.fn(); render( { 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(); }); it("renders domain section in edit mode", async () => { - const user = userEvent.setup(); const onUpdateProperty = vi.fn(); render( { 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(); }); it("renders range section in edit mode", async () => { - const user = userEvent.setup(); const onUpdateProperty = vi.fn(); render( { 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(); }); it("renders characteristics checkboxes in edit mode for object property", async () => { - const user = userEvent.setup(); const onUpdateProperty = vi.fn(); render( { 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(); }); it("renders inverse-of section in edit mode for object property", async () => { - const user = userEvent.setup(); const onUpdateProperty = vi.fn(); render( { 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(); }); it("renders annotations section in edit mode", async () => { - const user = userEvent.setup(); const onUpdateProperty = vi.fn(); render( { 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(); }); it("renders relationships section in edit mode", async () => { - const user = userEvent.setup(); const onUpdateProperty = vi.fn(); render( { 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(); }); @@ -606,7 +618,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"); @@ -616,7 +630,6 @@ describe("PropertyDetailPanel", () => { // ── Characteristic toggle in edit mode ── it("toggles characteristic checkbox in edit mode", async () => { - const user = userEvent.setup(); const onUpdateProperty = vi.fn(); const { container } = render( { 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"]' @@ -643,8 +658,7 @@ describe("PropertyDetailPanel", () => { // ── Cancel edit mode ── - it("exits edit mode and calls discardDraft when cancel flow is triggered", async () => { - const user = userEvent.setup(); + it("discards draft when cancel is invoked but stays in edit mode", async () => { const onUpdateProperty = vi.fn(); render( { onUpdateProperty={onUpdateProperty} /> ); - await user.click(screen.getByText("Edit Item")); - // Verify we are in edit mode - expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); // Trigger cancel via the captured AutoSaveAffordanceBar callback expect(capturedAutoSaveBarProps).not.toBeNull(); @@ -665,15 +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(); }); - // ── 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", async () => { const onUpdateProperty = vi.fn(); render( { onUpdateProperty={onUpdateProperty} /> ); - // Should auto-enter edit mode and show auto-save bar - expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); }); // ── Draft restoration ── @@ -721,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 ── @@ -771,10 +779,9 @@ describe("PropertyDetailPanel", () => { // ── 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(); }); @@ -791,7 +798,6 @@ describe("PropertyDetailPanel", () => { // ── Language tag input in edit mode ── it("renders language tag pickers in edit mode", async () => { - const user = userEvent.setup(); 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 langPickers = screen.getAllByLabelText("Language tag"); expect(langPickers.length).toBeGreaterThanOrEqual(1); }); @@ -817,7 +825,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(); @@ -841,7 +851,6 @@ describe("PropertyDetailPanel", () => { // ── Parent properties section in edit mode ── it("renders parent properties section in edit mode", async () => { - const user = userEvent.setup(); const onUpdateProperty = vi.fn(); render( { 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", @@ -872,8 +883,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 ── @@ -887,7 +902,6 @@ describe("PropertyDetailPanel", () => { ], }) ); - const user = userEvent.setup(); const onUpdateProperty = vi.fn(); const { container } = render( { 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); }); @@ -914,7 +930,9 @@ describe("PropertyDetailPanel", () => { onUpdateProperty={onUpdateProperty} /> ); - await user.click(screen.getByText("Edit Item")); + await waitFor(() => { + expect(screen.getByTestId("auto-save-bar")).not.toBeNull(); + }); const langPickers = screen.getAllByLabelText("Language tag"); expect(langPickers.length).toBeGreaterThanOrEqual(1); await user.selectOptions(langPickers[0], "fr"); @@ -928,7 +946,6 @@ describe("PropertyDetailPanel", () => { mockExtractPropertyDetail.mockReturnValue( makePropertyDetail({ comments: [] }) ); - const user = userEvent.setup(); const onUpdateProperty = vi.fn(); render( { 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(); }); @@ -948,7 +967,6 @@ describe("PropertyDetailPanel", () => { mockExtractPropertyDetail.mockReturnValue( makePropertyDetail({ definitions: [] }) ); - const user = userEvent.setup(); const onUpdateProperty = vi.fn(); render( { 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(); }); @@ -967,7 +987,6 @@ describe("PropertyDetailPanel", () => { mockExtractPropertyDetail.mockReturnValue( makePropertyDetail({ domainIris: [] }) ); - const user = userEvent.setup(); const onUpdateProperty = vi.fn(); render( { 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(); }); @@ -984,7 +1005,6 @@ describe("PropertyDetailPanel", () => { mockExtractPropertyDetail.mockReturnValue( makePropertyDetail({ rangeIris: [] }) ); - const user = userEvent.setup(); const onUpdateProperty = vi.fn(); render( { 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(); }); @@ -1003,7 +1025,6 @@ describe("PropertyDetailPanel", () => { mockExtractPropertyDetail.mockReturnValue( makePropertyDetail({ parentIris: [] }) ); - const user = userEvent.setup(); const onUpdateProperty = vi.fn(); render( { 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(); }); @@ -1022,7 +1045,6 @@ describe("PropertyDetailPanel", () => { mockExtractPropertyDetail.mockReturnValue( makePropertyDetail({ inverseOf: null }) ); - const user = userEvent.setup(); const onUpdateProperty = vi.fn(); render( { 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(); }); @@ -1041,7 +1065,6 @@ describe("PropertyDetailPanel", () => { mockExtractPropertyDetail.mockReturnValue( makePropertyDetail({ characteristics: [] }) ); - const user = userEvent.setup(); const onUpdateProperty = vi.fn(); const { container } = render( { 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); @@ -1124,7 +1149,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); @@ -1180,7 +1207,6 @@ describe("PropertyDetailPanel", () => { // ── Cancel edit mode via AutoSaveAffordanceBar ── it("invokes cancelEditMode via AutoSaveAffordanceBar onCancel", async () => { - const user = userEvent.setup(); const onUpdateProperty = vi.fn(); render( { 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; @@ -1199,8 +1227,7 @@ describe("PropertyDetailPanel", () => { // ── Manual save via AutoSaveAffordanceBar ── - it("invokes saveAndExitEditMode via AutoSaveAffordanceBar onManualSave", async () => { - const user = userEvent.setup(); + it("invokes flushDraftToGit via AutoSaveAffordanceBar onManualSave", 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(); + }); expect(capturedAutoSaveBarProps).not.toBeNull(); const onManualSave = capturedAutoSaveBarProps!.onManualSave as () => Promise; await onManualSave(); @@ -1220,7 +1249,6 @@ describe("PropertyDetailPanel", () => { // ── Retry via AutoSaveAffordanceBar ── it("invokes flushToGit via AutoSaveAffordanceBar onRetry", async () => { - const user = userEvent.setup(); const onUpdateProperty = vi.fn(); render( { 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(); @@ -1239,7 +1269,6 @@ describe("PropertyDetailPanel", () => { // ── AnnotationRow callbacks for comments ── it("passes comment callbacks to AnnotationRow", async () => { - const user = userEvent.setup(); const onUpdateProperty = vi.fn(); render( { 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" @@ -1281,7 +1312,6 @@ describe("PropertyDetailPanel", () => { // ── AnnotationRow callbacks for definitions ── it("passes definition callbacks to AnnotationRow", async () => { - const user = userEvent.setup(); 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 defRow = capturedAnnotationRowProps.find( (p) => p.propertyIri === "http://www.w3.org/2004/02/skos/core#definition" ); @@ -1309,7 +1341,6 @@ describe("PropertyDetailPanel", () => { // ── AnnotationRow onBlur triggers save ── it("AnnotationRow onBlur triggers triggerSave", async () => { - const user = userEvent.setup(); 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 commentRow = capturedAnnotationRowProps.find( (p) => p.propertyIri === "http://www.w3.org/2000/01/rdf-schema#comment" ); @@ -1339,7 +1372,6 @@ describe("PropertyDetailPanel", () => { ], }) ); - const user = userEvent.setup(); 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 commentRows = capturedAnnotationRowProps.filter( (p) => p.propertyIri === "http://www.w3.org/2000/01/rdf-schema#comment" ); @@ -1374,7 +1408,6 @@ describe("PropertyDetailPanel", () => { ], }) ); - const user = userEvent.setup(); 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 defRows = capturedAnnotationRowProps.filter( (p) => p.propertyIri === "http://www.w3.org/2004/02/skos/core#definition" ); @@ -1405,7 +1440,6 @@ describe("PropertyDetailPanel", () => { seeAlsoIris: ["http://example.org/ontology#related"], }) ); - const user = userEvent.setup(); const onUpdateProperty = vi.fn(); render( { 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 @@ -1431,7 +1467,6 @@ describe("PropertyDetailPanel", () => { seeAlsoIris: ["http://example.org/ontology#related"], }) ); - const user = userEvent.setup(); const onUpdateProperty = vi.fn(); render( { 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); @@ -1450,7 +1487,6 @@ describe("PropertyDetailPanel", () => { }); it("invokes changeRelationshipProperty via RelationshipSection", async () => { - const user = userEvent.setup(); const onUpdateProperty = vi.fn(); render( { 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"); @@ -1469,7 +1507,6 @@ describe("PropertyDetailPanel", () => { }); it("invokes addRelationshipGroup via RelationshipSection", async () => { - const user = userEvent.setup(); const onUpdateProperty = vi.fn(); render( { 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; @@ -1503,7 +1542,6 @@ describe("PropertyDetailPanel", () => { ], }) ); - const user = userEvent.setup(); const onUpdateProperty = vi.fn(); render( { 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" @@ -1553,7 +1593,6 @@ describe("PropertyDetailPanel", () => { ], }) ); - const user = userEvent.setup(); 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 annRows = capturedAnnotationRowProps.filter( (p) => p.propertyIri === "http://www.w3.org/2004/02/skos/core#prefLabel" ); @@ -1585,7 +1626,6 @@ describe("PropertyDetailPanel", () => { isDefinedByIris: ["http://example.org/ontology#ontology"], }) ); - const user = userEvent.setup(); const onUpdateProperty = vi.fn(); render( { 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); }); @@ -1619,7 +1661,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]); @@ -1647,8 +1691,7 @@ describe("PropertyDetailPanel", () => { // ── Manual save stays in edit mode when flushToGit fails ── - it("stays in edit mode when saveAndExitEditMode flush fails", async () => { - const user = userEvent.setup(); + it("stays in edit mode when flushDraftToGit flush fails", async () => { const onUpdateProperty = vi.fn(); mockFlushToGit.mockResolvedValue(false); render( @@ -1658,7 +1701,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(); @@ -1682,7 +1727,6 @@ describe("PropertyDetailPanel", () => { ], }) ); - const user = userEvent.setup(); const onUpdateProperty = vi.fn(); render( { 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(); @@ -1716,7 +1762,6 @@ describe("PropertyDetailPanel", () => { mockExtractPropertyDetail.mockReturnValue( makePropertyDetail({ annotations: [] }) ); - const user = userEvent.setup(); const onUpdateProperty = vi.fn(); render( { 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(); @@ -1745,7 +1792,6 @@ describe("PropertyDetailPanel", () => { // ── InlineAnnotationAdder onSaveNeeded callback ── it("InlineAnnotationAdder onSaveNeeded calls triggerSave", async () => { - const user = userEvent.setup(); const onUpdateProperty = vi.fn(); render( { 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(); @@ -1768,7 +1816,6 @@ describe("PropertyDetailPanel", () => { // ── RelationshipSection onSaveNeeded callback ── it("RelationshipSection onSaveNeeded calls triggerSave", async () => { - const user = userEvent.setup(); const onUpdateProperty = vi.fn(); render( { 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(); @@ -1795,31 +1844,6 @@ describe("PropertyDetailPanel", () => { expect(screen.getByText("Label(s)")).not.toBeNull(); }); - // ── Cancel after continuous editing prevents re-entry ── - - it("does not re-enter edit mode after cancel even with continuousEditing", async () => { - editorModeOverrides = { continuousEditing: true }; - const onUpdateProperty = vi.fn(); - render( - - ); - 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 () => { @@ -1833,7 +1857,6 @@ describe("PropertyDetailPanel", () => { ], }) ); - const user = userEvent.setup(); 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" ); @@ -1864,7 +1889,6 @@ describe("PropertyDetailPanel", () => { mockExtractPropertyDetail.mockReturnValue( makePropertyDetail({ labels: [] }) ); - const user = userEvent.setup(); 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 labelInputs = screen.getAllByPlaceholderText("Label text"); expect(labelInputs.length).toBeGreaterThanOrEqual(1); expect((labelInputs[0] as HTMLInputElement).value).toBe(""); @@ -1888,7 +1914,6 @@ describe("PropertyDetailPanel", () => { isDefinedByIris: ["http://example.org/ontology#myOntology"], }) ); - const user = userEvent.setup(); const onUpdateProperty = vi.fn(); render( { 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__/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/__tests__/components/editor/ViewerEditorSwitcher.test.tsx b/__tests__/components/editor/ViewerEditorSwitcher.test.tsx new file mode 100644 index 00000000..87078bab --- /dev/null +++ b/__tests__/components/editor/ViewerEditorSwitcher.test.tsx @@ -0,0 +1,157 @@ +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, + 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 = ""; + useSelectionStore.getState().clear(); + }); + + 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"), + ); + }); + + 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"), + ); + }); + + 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__/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/__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/__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/__tests__/lib/hooks/useIriLabels.test.ts b/__tests__/lib/hooks/useIriLabels.test.ts index ee7525ab..327a9955 100644 --- a/__tests__/lib/hooks/useIriLabels.test.ts +++ b/__tests__/lib/hooks/useIriLabels.test.ts @@ -27,9 +27,33 @@ 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" }], + labels: [{ value: "Person", lang: "en" }], }); const { result } = renderHook(() => @@ -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", lang: "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", () => { @@ -90,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(() => @@ -122,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( @@ -139,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 @@ -174,4 +198,70 @@ 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", + // 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: [] }); + + const { result } = renderHook(() => + useIriLabels([iri], { projectId: "proj-1", accessToken: "token" }), + ); + + // 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(); + }); + + 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: [] }); + + 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/__tests__/lib/stores/editorModeStore.test.ts b/__tests__/lib/stores/editorModeStore.test.ts index 13a12cda..b4abe34e 100644 --- a/__tests__/lib/stores/editorModeStore.test.ts +++ b/__tests__/lib/stores/editorModeStore.test.ts @@ -82,8 +82,8 @@ describe("useEditorModeStore", () => { useEditorModeStore.setState({ editorMode: "standard", theme: "system", - continuousEditing: false, hideSaveButton: false, + preferEditMode: false, }); }); @@ -92,8 +92,8 @@ 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); + expect(state.preferEditMode).toBe(false); }); }); @@ -133,19 +133,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); @@ -158,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/__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/__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]/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]/editor/page.tsx b/app/projects/[id]/editor/page.tsx index f3772d0e..b8ae215a 100644 --- a/app/projects/[id]/editor/page.tsx +++ b/app/projects/[id]/editor/page.tsx @@ -12,7 +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 { ContinuousEditingToggle } from "@/components/editor/ContinuousEditingToggle"; +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"; @@ -52,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 classIriParam = searchParams.get("classIri"); + // 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; @@ -90,19 +102,50 @@ 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. 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) { + 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 + entityNavigationRef.current(initialSelection.iri, initialSelection.type); + consumedSelectionRef.current = key; + }, [initialSelection, isTreeLoading, nodes.length, selectedIri, navigateToNode, isLoading]); + // Commit dialog const [commitDialogOpen, setCommitDialogOpen] = useState(false); const [pendingSaveContent, setPendingSaveContent] = useState(null); @@ -825,20 +868,12 @@ export default function EditorPage() {
- - - Back - -

{project.name}

{totalClasses} classes - {/* Mode Switcher */} + {/* Viewer / Editor switcher */} + + {/* Standard / Developer mode switcher */} - {canSuggest && } - {/* Suggestion mode indicator */} {isSuggestionMode && ( diff --git a/app/projects/[id]/page.tsx b/app/projects/[id]/page.tsx index 54e3c2dc..b364e2b6 100644 --- a/app/projects/[id]/page.tsx +++ b/app/projects/[id]/page.tsx @@ -1,14 +1,16 @@ "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"; -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,13 @@ function ViewerContent({ sessionStatus: "loading" | "authenticated" | "unauthenticated"; }) { const searchParams = useSearchParams(); - const classIriParam = searchParams.get("classIri"); + // 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 @@ -186,13 +194,43 @@ 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); - // 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; + const key = `${initialSelection.type}:${initialSelection.iri}`; + if (consumedSelectionRef.current === key) return; + + if (initialSelection.type === "class") { + if (isTreeLoading || !nodes.length) return; + if (selectedIri === initialSelection.iri) { + 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); + consumedSelectionRef.current = key; + }, [initialSelection, isTreeLoading, nodes.length, selectedIri, navigateToNode]); const toast = useToast(); const handleCopyIri = useCallback(async (iri: string) => { @@ -223,6 +261,7 @@ function ViewerContent({

{project?.name}

{totalClasses} classes + {canSuggest && }
@@ -240,15 +279,9 @@ function ViewerContent({ - {/* Open Editor / Sign In */} - {canSuggest ? ( - - - - ) : !hasValidAccess ? ( + {/* Sign In affordance for unauthenticated users — the Viewer/Editor switcher above + carries authenticated users into the editor. */} + {!canSuggest && !hasValidAccess && ( - ) : null} + )} {canManage && ( @@ -298,6 +331,7 @@ function ViewerContent({ hasExpandedNodes={hasExpandedNodes} isExpandingAll={isExpandingAll} navigateToNode={navigateToNode} + entityNavigationRef={entityNavigationRef} sourceContent={sourceContent} setSourceContent={setSourceContent as (content: string | ((prev: string) => string)) => void} isLoadingSource={isLoadingSource} @@ -338,6 +372,7 @@ function ViewerContent({ hasExpandedNodes={hasExpandedNodes} isExpandingAll={isExpandingAll} navigateToNode={navigateToNode} + entityNavigationRef={entityNavigationRef} onAddEntity={noop} onCopyIri={handleCopyIri} selectedNodeFallback={selectedNodeFallback} 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/app/settings/page.tsx b/app/settings/page.tsx index 4bd9b4a3..e43cba5f 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -328,9 +328,8 @@ function EditorPreferencesSection() { const setEditorMode = useEditorModeStore((s) => s.setEditorMode); const theme = useEditorModeStore((s) => s.theme); const setTheme = useEditorModeStore((s) => s.setTheme); - const continuousEditing = useEditorModeStore((s) => s.continuousEditing); - const setContinuousEditing = useEditorModeStore((s) => s.setContinuousEditing); - + 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 @@ -427,41 +426,47 @@ function EditorPreferencesSection() {
- {/* Continuous Editing */} -
- - Continuous Editing + {/* Prefer Edit Mode */} +
+ + Prefer Edit Mode diff --git a/components/editor/ClassDetailPanel.tsx b/components/editor/ClassDetailPanel.tsx index 6f69c0cc..9119b552 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 */ @@ -73,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) */ @@ -90,7 +87,6 @@ export function ClassDetailPanel({ onCopyIri, selectedNodeFallback, canEdit, - isSuggestionMode = false, onUpdateClass, refreshKey, headerActions, @@ -113,14 +109,7 @@ 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); - // Track if user explicitly cancelled for this classIri (prevents continuous-editing auto-re-entry) - const cancelledIriRef = useRef(null); - - // Continuous editing from store - const continuousEditing = useEditorModeStore((s) => s.continuousEditing); // Toast for error feedback const toast = useToast(); @@ -166,70 +155,27 @@ 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). + // 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(() => { - if (prevClassIriRef.current && prevClassIriRef.current !== classIri) { - flushToGit(); - } - prevClassIriRef.current = classIri; - 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 - const enterEditMode = useCallback(() => { - if (!classDetail) return; - initEditState(classDetail); - editInitializedRef.current = true; - setIsEditing(true); - }, [classDetail]); // eslint-disable-line react-hooks/exhaustive-deps - - // Cancel edit mode: discard draft, revert to server state - const cancelEditMode = useCallback(() => { - discardDraft(); - if (classDetail) { - initEditState(classDetail); - } - setIsEditing(false); - cancelledIriRef.current = classIri; - }, [classIri, classDetail, discardDraft]); // eslint-disable-line react-hooks/exhaustive-deps - - // Manual save: trigger draft save, flush to git, exit edit mode on success - const saveAndExitEditMode = useCallback(async () => { - triggerSave(); - const ok = await flushToGit(); - if (ok) setIsEditing(false); - }, [triggerSave, flushToGit]); - - // Auto-enter edit mode based on continuous editing or restored draft + flushToGitRef.current = flushToGit; + }, [flushToGit]); useEffect(() => { - if (isEditing || editInitializedRef.current) return; - if (!canEdit || !onUpdateClass || !classDetail) return; - - // Restored draft → always auto-enter - if (restoredDraft && classIri) { - setEditLabels(restoredDraft.labels.length > 0 ? restoredDraft.labels : [{ value: "", lang: "en" }]); - setEditComments(ensureTrailingEmpty(restoredDraft.comments)); - setEditParentIris(restoredDraft.parentIris); - setEditParentLabels(restoredDraft.parentLabels); - setEditAnnotations(restoredDraft.annotations); - setEditRelationships(restoredDraft.relationships); - editInitializedRef.current = true; - setIsEditing(true); - clearRestoredDraft(); - return; - } - - // Continuous editing → auto-enter (unless user explicitly cancelled for this class) - if (continuousEditing && cancelledIriRef.current !== classIri) { - enterEditMode(); - return; - } - }, [classDetail, canEdit, restoredDraft, classIri, clearRestoredDraft, continuousEditing, isEditing, enterEditMode]); // eslint-disable-line react-hooks/exhaustive-deps + return () => { + flushToGitRef.current(); + }; + }, []); - // Initialize edit state from OWLClassDetail + // 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 })))); @@ -270,6 +216,58 @@ export function ClassDetailPanel({ setEditRelationships(relationships); }, [resolvedTargetLabels]); + // Enter edit mode: initialize edit state from classDetail + const enterEditMode = useCallback(() => { + if (!classDetail) return; + initEditState(classDetail); + editInitializedRef.current = true; + setIsEditing(true); + }, [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. + 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, editStateRef]); + + // Manual save: flush the current draft to git. Stays in edit mode. + const flushDraftToGit = useCallback(async () => { + triggerSave(); + await flushToGit(); + }, [triggerSave, flushToGit]); + + // Auto-enter edit mode based on continuous editing or restored draft + useEffect(() => { + if (isEditing || editInitializedRef.current) return; + if (!canEdit || !onUpdateClass || !classDetail) return; + + // Restored draft → always auto-enter + if (restoredDraft && classIri) { + setEditLabels(restoredDraft.labels.length > 0 ? restoredDraft.labels : [{ value: "", lang: "en" }]); + setEditComments(ensureTrailingEmpty(restoredDraft.comments)); + setEditParentIris(restoredDraft.parentIris); + setEditParentLabels(restoredDraft.parentLabels); + setEditAnnotations(restoredDraft.annotations); + setEditRelationships(restoredDraft.relationships); + editInitializedRef.current = true; + setIsEditing(true); + clearRestoredDraft(); + return; + } + + // In editor context (canEdit + onUpdateClass), always auto-enter edit mode. + enterEditMode(); + }, [classDetail, canEdit, restoredDraft, classIri, clearRestoredDraft, onUpdateClass, isEditing, enterEditMode]); + // Fetch class data useEffect(() => { if (!classIri) { @@ -570,7 +568,6 @@ export function ClassDetailPanel({ } const displayLabel = getPreferredLabel(classDetail.labels) || getLocalName(classDetail.iri); - const canEnterEdit = !!canEdit && !!onUpdateClass; return (
@@ -593,16 +590,6 @@ export function ClassDetailPanel({
{headerActions} - {canEnterEdit && !isEditing && ( - - )}
@@ -640,7 +627,7 @@ export function ClassDetailPanel({ error={saveError} validationError={validationError} onRetry={() => flushToGit()} - onManualSave={saveAndExitEditMode} + onManualSave={flushDraftToGit} onCancel={cancelEditMode} /> )} 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 2f1b3910..adcf78d0 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 continuousEditing = useEditorModeStore((s) => s.continuousEditing); const toast = useToast(); const buildDraftEntry = useCallback((): IndividualDraftEntry | null => { @@ -237,19 +233,27 @@ 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); - cancelledIriRef.current = null; }, [individualIri]); const enterEditMode = useCallback(() => { @@ -259,26 +263,25 @@ 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 - const saveAndExitEditMode = useCallback(async () => { + // Manual save: flush the current draft to git. Stays in edit mode. + const flushDraftToGit = useCallback(async () => { triggerSave(); - const ok = await flushToGit(); - if (ok) setIsEditing(false); + await flushToGit(); }, [triggerSave, flushToGit]); 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; + // 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)); @@ -295,10 +298,8 @@ export function IndividualDetailPanel({ return; } - if (continuousEditing && cancelledIriRef.current !== individualIri) { - enterEditMode(); - } - }, [detail, canEdit, restoredDraft, individualIri, clearRestoredDraft, continuousEditing, isEditing, enterEditMode]); + enterEditMode(); + }, [detail, canEdit, restoredDraft, individualIri, clearRestoredDraft, onUpdateIndividual, isEditing, enterEditMode]); // ── Edit helpers ── const updateLabel = useCallback((index: number, field: "value" | "lang", val: string) => { @@ -404,7 +405,6 @@ export function IndividualDetailPanel({ } const displayLabel = detail.labels.length > 0 ? detail.labels[0].value : getLocalName(individualIri); - const canEnterEdit = canEdit && !!onUpdateIndividual; return (
@@ -424,11 +424,6 @@ export function IndividualDetailPanel({ )} - {canEnterEdit && !isEditing && ( -
- -
- )}
Individual @@ -450,7 +445,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 c1fd6449..d67eabeb 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, @@ -123,10 +121,7 @@ export function PropertyDetailPanel({ const [editRelationships, setEditRelationships] = useState([]); const [editPropertyType, setEditPropertyType] = useState("object"); - 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 @@ -254,16 +249,20 @@ export function PropertyDetailPanel({ setEditAnnotations(regularAnnotations); }, []); - // Flush to git on navigate away + // 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(() => { - if (prevIriRef.current && prevIriRef.current !== propertyIri) { - flushToGit(); - } - prevIriRef.current = propertyIri; - editInitializedRef.current = false; - setIsEditing(false); - cancelledIriRef.current = null; - }, [propertyIri]); // eslint-disable-line react-hooks/exhaustive-deps + flushToGitRef.current = flushToGit; + }, [flushToGit]); + useEffect(() => { + return () => { + flushToGitRef.current(); + }; + }, []); const enterEditMode = useCallback(() => { if (!detail) return; @@ -272,24 +271,22 @@ 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 - const saveAndExitEditMode = useCallback(async () => { + // Manual save: flush the current draft to git. Stays in edit mode. + const flushDraftToGit = useCallback(async () => { triggerSave(); - const ok = await flushToGit(); - if (ok) setIsEditing(false); + await flushToGit(); }, [triggerSave, flushToGit]); // 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; @@ -310,10 +307,8 @@ export function PropertyDetailPanel({ return; } - if (continuousEditing && cancelledIriRef.current !== propertyIri) { - enterEditMode(); - } - }, [detail, canEdit, restoredDraft, propertyIri, clearRestoredDraft, continuousEditing, isEditing, enterEditMode]); + enterEditMode(); + }, [detail, canEdit, restoredDraft, propertyIri, clearRestoredDraft, onUpdateProperty, isEditing, enterEditMode]); // ── Edit helpers ── const updateLabel = useCallback((index: number, field: "value" | "lang", val: string) => { @@ -447,7 +442,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 +462,6 @@ export function PropertyDetailPanel({ )} - {canEnterEdit && !isEditing && ( -
- -
- )}
@@ -498,7 +485,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 new file mode 100644 index 00000000..d3d5db6f --- /dev/null +++ b/components/editor/ViewerEditorSwitcher.tsx @@ -0,0 +1,101 @@ +"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 { useSelectionStore } from "@/lib/stores/selectionStore"; +import { + readSelectionFromSearchParams, + SELECTION_PARAM_BY_TYPE, +} 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"; + + // 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); + + // 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 ( +
+ {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/components/editor/developer/DeveloperEditorLayout.tsx b/components/editor/developer/DeveloperEditorLayout.tsx index 3676d45d..e4502a61 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, @@ -527,6 +544,9 @@ export function DeveloperEditorLayout(props: DeveloperEditorLayoutProps) {
{activeTab === "classes" ? ( ) : activeTab === "properties" ? ( ) : ( 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/components/editor/standard/StandardEditorLayout.tsx b/components/editor/standard/StandardEditorLayout.tsx index 38175df4..e7a81e94 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, @@ -423,6 +440,10 @@ export function StandardEditorLayout(props: StandardEditorLayoutProps) {
) : activeTab === "classes" ? ( ) : activeTab === "properties" ? ( ) : ( 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 ( { - // 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 - } + // 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; - // 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( @@ -76,7 +109,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. } }), ); 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}`; +} diff --git a/lib/stores/editorModeStore.ts b/lib/stores/editorModeStore.ts index 88d41fa9..505de8fe 100644 --- a/lib/stores/editorModeStore.ts +++ b/lib/stores/editorModeStore.ts @@ -7,12 +7,13 @@ export type ThemePreference = "light" | "dark" | "system"; interface EditorModeState { editorMode: EditorMode; theme: ThemePreference; - continuousEditing: boolean; 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; - setContinuousEditing: (on: boolean) => void; setHideSaveButton: (on: boolean) => void; + setPreferEditMode: (on: boolean) => void; } /** @@ -39,8 +40,8 @@ export const useEditorModeStore = create()( (set) => ({ editorMode: "standard", theme: "system", - continuousEditing: false, hideSaveButton: false, + preferEditMode: false, setEditorMode: (mode) => set({ editorMode: mode }), @@ -49,8 +50,9 @@ export const useEditorModeStore = create()( set({ theme }); }, - setContinuousEditing: (on) => set({ continuousEditing: on }), setHideSaveButton: (on) => set({ hideSaveButton: on }), + + setPreferEditMode: (on) => set({ preferEditMode: on }), }), { name: "ontokit-editor-preferences", 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 }), +})); 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)}`; +}