Skip to content

Commit 5e0644b

Browse files
fix(library): guard read-only assessment popovers
1 parent 70e9eb2 commit 5e0644b

4 files changed

Lines changed: 119 additions & 17 deletions

File tree

library/lib/hooks/useElementInteractions.ts

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { usePopoverStore } from "@/store/context"
2-
import { useMetadataStore } from "@/store"
2+
import { useDiagramStore, useMetadataStore } from "@/store"
33
import { ApollonMode } from "@/typings"
44
import {
55
NodeMouseHandler,
@@ -12,10 +12,19 @@ import { useShallow } from "zustand/shallow"
1212
import { useDiagramModifiable } from "./useDiagramModifiable"
1313
import { isElementInOverlay } from "@/keyboard"
1414
import { useCallback } from "react"
15+
import { hasAssessmentToShow } from "@/utils/assessmentPresence"
1516

1617
export const useElementInteractions = () => {
1718
const isDiagramModifiable = useDiagramModifiable()
18-
const mode = useMetadataStore((state) => state.mode)
19+
const { mode, readonly } = useMetadataStore(
20+
useShallow((state) => ({ mode: state.mode, readonly: state.readonly }))
21+
)
22+
const { nodes, getAssessment } = useDiagramStore(
23+
useShallow((state) => ({
24+
nodes: state.nodes,
25+
getAssessment: state.getAssessment,
26+
}))
27+
)
1928
const { setPopOverElementId } = usePopoverStore(
2029
useShallow((state) => ({
2130
setPopOverElementId: state.setPopOverElementId,
@@ -59,18 +68,38 @@ export const useElementInteractions = () => {
5968

6069
const onNodeClick: NodeMouseHandler<Node> = useCallback(
6170
(_event, node) => {
62-
if (!canOpenAssessmentPopover) return
71+
if (
72+
!canOpenAssessmentPopover ||
73+
(readonly && !hasAssessmentToShow(node.id, nodes, getAssessment))
74+
)
75+
return
6376
setPopOverElementId(node.id)
6477
},
65-
[canOpenAssessmentPopover, setPopOverElementId]
78+
[
79+
canOpenAssessmentPopover,
80+
getAssessment,
81+
nodes,
82+
readonly,
83+
setPopOverElementId,
84+
]
6685
)
6786

6887
const onEdgeClick: EdgeMouseHandler<Edge> = useCallback(
6988
(_event, edge) => {
70-
if (!canOpenAssessmentPopover) return
89+
if (
90+
!canOpenAssessmentPopover ||
91+
(readonly && !hasAssessmentToShow(edge.id, nodes, getAssessment))
92+
)
93+
return
7194
setPopOverElementId(edge.id)
7295
},
73-
[canOpenAssessmentPopover, setPopOverElementId]
96+
[
97+
canOpenAssessmentPopover,
98+
getAssessment,
99+
nodes,
100+
readonly,
101+
setPopOverElementId,
102+
]
74103
)
75104
return {
76105
onBeforeDelete,
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest"
2+
import { act, renderHook } from "@testing-library/react"
3+
import { ApollonMode } from "@/typings"
4+
5+
const setPopOverElementId = vi.fn()
6+
const nodes = [{ id: "node", data: {} }]
7+
const assessments: Record<string, { score?: number; feedback?: string }> = {}
8+
9+
vi.mock("@/store", () => ({
10+
useMetadataStore: (select: (state: unknown) => unknown) =>
11+
select({ mode: ApollonMode.Assessment, readonly: true }),
12+
useDiagramStore: (select: (state: unknown) => unknown) =>
13+
select({
14+
nodes,
15+
getAssessment: (id: string) => assessments[id],
16+
}),
17+
}))
18+
19+
vi.mock("@/store/context", () => ({
20+
usePopoverStore: (select: (state: unknown) => unknown) =>
21+
select({ setPopOverElementId }),
22+
}))
23+
24+
vi.mock("@/hooks/useDiagramModifiable", () => ({
25+
useDiagramModifiable: () => false,
26+
}))
27+
28+
import { useElementInteractions } from "@/hooks/useElementInteractions"
29+
30+
describe("read-only assessment element interactions", () => {
31+
beforeEach(() => {
32+
vi.clearAllMocks()
33+
for (const id of Object.keys(assessments)) delete assessments[id]
34+
})
35+
36+
it("opens only nodes and edges that have feedback to show", () => {
37+
const { result } = renderHook(() => useElementInteractions())
38+
const node = { id: "node" }
39+
const edge = { id: "edge" }
40+
41+
act(() => {
42+
result.current.onNodeClick(undefined as never, node as never)
43+
result.current.onEdgeClick(undefined as never, edge as never)
44+
})
45+
expect(setPopOverElementId).not.toHaveBeenCalled()
46+
47+
assessments.node = { score: 1 }
48+
assessments.edge = { feedback: "Useful feedback" }
49+
act(() => {
50+
result.current.onNodeClick(undefined as never, node as never)
51+
result.current.onEdgeClick(undefined as never, edge as never)
52+
})
53+
expect(setPopOverElementId).toHaveBeenNthCalledWith(1, "node")
54+
expect(setPopOverElementId).toHaveBeenNthCalledWith(2, "edge")
55+
})
56+
})

library/tests/unit/onBeforeDelete.test.tsx

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,22 +3,33 @@ import type { ReactNode } from "react"
33
import { renderHook } from "@testing-library/react"
44
import { createMetadataStore } from "@/store/metadataStore"
55
import { createPopoverStore } from "@/store/popoverStore"
6-
import { MetadataStoreContext, PopoverStoreContext } from "@/store/context"
6+
import { createDiagramStore } from "@/store/diagramStore"
7+
import {
8+
DiagramStoreContext,
9+
MetadataStoreContext,
10+
PopoverStoreContext,
11+
} from "@/store/context"
712
import { useElementInteractions } from "@/hooks/useElementInteractions"
13+
import * as Y from "yjs"
814

915
/**
1016
* `onBeforeDelete` is the one gate every React Flow deletion funnels through.
1117
* Besides read-only, it blocks a Delete pressed while focus is in an overlay
1218
* over the canvas — React Flow's delete listener is document-level, so without
1319
* this a dialog's Delete would remove the selection behind it.
1420
*/
15-
const wrapper = ({ children }: { children: ReactNode }) => (
16-
<MetadataStoreContext value={createMetadataStore()}>
17-
<PopoverStoreContext value={createPopoverStore()}>
18-
{children}
19-
</PopoverStoreContext>
20-
</MetadataStoreContext>
21-
)
21+
const wrapper = ({ children }: { children: ReactNode }) => {
22+
const ydoc = new Y.Doc()
23+
return (
24+
<DiagramStoreContext value={createDiagramStore(ydoc)}>
25+
<MetadataStoreContext value={createMetadataStore(ydoc)}>
26+
<PopoverStoreContext value={createPopoverStore()}>
27+
{children}
28+
</PopoverStoreContext>
29+
</MetadataStoreContext>
30+
</DiagramStoreContext>
31+
)
32+
}
2233

2334
const onBeforeDelete = () =>
2435
renderHook(() => useElementInteractions(), { wrapper }).result.current
@@ -34,10 +45,13 @@ describe("useElementInteractions.onBeforeDelete", () => {
3445
it("keeps React Flow callback identities stable across parent renders", () => {
3546
const metadata = createMetadataStore()
3647
const popover = createPopoverStore()
48+
const diagram = createDiagramStore(new Y.Doc())
3749
const stableWrapper = ({ children }: { children: ReactNode }) => (
38-
<MetadataStoreContext value={metadata}>
39-
<PopoverStoreContext value={popover}>{children}</PopoverStoreContext>
40-
</MetadataStoreContext>
50+
<DiagramStoreContext value={diagram}>
51+
<MetadataStoreContext value={metadata}>
52+
<PopoverStoreContext value={popover}>{children}</PopoverStoreContext>
53+
</MetadataStoreContext>
54+
</DiagramStoreContext>
4155
)
4256
const hook = renderHook(() => useElementInteractions(), {
4357
wrapper: stableWrapper,

library/tests/unit/revealAssessment.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,8 @@ describe("ApollonEditor.revealAssessment", () => {
208208
model: MODEL,
209209
})
210210

211+
const selectionChanges: string[][] = []
212+
editor.subscribeToSelectionChange((ids) => selectionChanges.push(ids))
211213
editor.revealAssessment("edge-ab")
212214

213215
const internals = editor as unknown as {
@@ -222,6 +224,7 @@ describe("ApollonEditor.revealAssessment", () => {
222224
expect(internals.diagramStore.getState().selectedElementIds).toEqual([
223225
"edge-ab",
224226
])
227+
expect(selectionChanges).toEqual([["edge-ab"]])
225228
expect(getEdgesMap(internals.ydoc).get("edge-ab")?.selected).toBeUndefined()
226229
expect(internals.diagramStore.getState().undoManager?.undoStack).toEqual([])
227230
})

0 commit comments

Comments
 (0)