Skip to content

Commit 59a0bb5

Browse files
bot comments, hide popup when overlapping existing highlight
Signed-off-by: Rudransh Shrivastava <rudransh.shrivastava@owasp.org>
1 parent b5a000a commit 59a0bb5

6 files changed

Lines changed: 161 additions & 180 deletions

File tree

frontend/__tests__/unit/components/AnnotatedProfile.test.tsx

Lines changed: 45 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { fireEvent, render, screen } from '@testing-library/react'
22
import { useProfileSelection } from 'hooks/useProfileSelection'
33
import { useRouter } from 'next/navigation'
4+
import React from 'react'
45

56
import { ClaimStatusEnum } from 'types/__generated__/graphql'
67
import AnnotatedProfile from 'components/AnnotatedProfile'
@@ -9,6 +10,36 @@ jest.mock('hooks/useProfileSelection', () => ({
910
useProfileSelection: jest.fn(() => null),
1011
}))
1112

13+
jest.mock(
14+
'components/ClaimHighlight',
15+
() =>
16+
function MockClaimHighlight({
17+
children,
18+
'data-claim-key': claimKey,
19+
'data-claim-name': claimName,
20+
'data-claim-status': claimStatus,
21+
...rest
22+
}: {
23+
children?: React.ReactNode
24+
'data-claim-key'?: string
25+
'data-claim-name'?: string
26+
'data-claim-status'?: string
27+
} & Record<string, unknown>) {
28+
if (!claimKey) return <span {...rest}>{children}</span>
29+
return (
30+
<span
31+
data-testid="claim-highlight"
32+
data-claim-highlight="true"
33+
data-claim-key={claimKey}
34+
data-claim-name={claimName}
35+
data-claim-status={claimStatus}
36+
>
37+
{children}
38+
</span>
39+
)
40+
}
41+
)
42+
1243
const mockUseProfileSelection = useProfileSelection as jest.Mock
1344

1445
const baseProps = {
@@ -48,9 +79,11 @@ describe('AnnotatedProfile', () => {
4879
]}
4980
/>
5081
)
51-
const link = screen.getByRole('link', { name: /Claim: A Claim, status Approved/i })
52-
expect(link).toBeInTheDocument()
53-
expect(link.textContent).toBe('This is my claim.')
82+
const mark = screen.getByTestId('claim-highlight')
83+
expect(mark).toHaveAttribute('data-claim-key', 'claim-key')
84+
expect(mark).toHaveAttribute('data-claim-name', 'A Claim')
85+
expect(mark).toHaveAttribute('data-claim-status', ClaimStatusEnum.Approved)
86+
expect(mark.textContent).toBe('This is my claim.')
5487
})
5588

5689
it('skips claims whose sourceText contains a blank line', () => {
@@ -69,7 +102,7 @@ describe('AnnotatedProfile', () => {
69102
]}
70103
/>
71104
)
72-
expect(screen.queryByRole('link')).not.toBeInTheDocument()
105+
expect(screen.queryByTestId('claim-highlight')).not.toBeInTheDocument()
73106
})
74107

75108
it('drops claims whose sourceText is not found', () => {
@@ -88,7 +121,7 @@ describe('AnnotatedProfile', () => {
88121
]}
89122
/>
90123
)
91-
expect(screen.queryByRole('link')).not.toBeInTheDocument()
124+
expect(screen.queryByTestId('claim-highlight')).not.toBeInTheDocument()
92125
})
93126

94127
it('gives longer sourceText priority when two claims overlap', () => {
@@ -114,9 +147,10 @@ describe('AnnotatedProfile', () => {
114147
]}
115148
/>
116149
)
117-
const links = screen.getAllByRole('link')
118-
expect(links).toHaveLength(1)
119-
expect(links[0]).toHaveTextContent('Overlap area')
150+
const marks = screen.getAllByTestId('claim-highlight')
151+
expect(marks).toHaveLength(1)
152+
expect(marks[0]).toHaveTextContent('Overlap area')
153+
expect(marks[0]).toHaveAttribute('data-claim-key', 'long')
120154
})
121155

122156
it('escapes special characters in claim attributes without breaking the markup', () => {
@@ -135,10 +169,9 @@ describe('AnnotatedProfile', () => {
135169
]}
136170
/>
137171
)
138-
const link = screen.getByRole('link')
139-
expect(link.textContent).toBe('text is here')
140-
expect(link).toHaveAttribute('aria-label', expect.stringContaining('Claim:'))
141-
expect(link).toHaveAttribute('aria-label', expect.stringContaining('status Draft'))
172+
const mark = screen.getByTestId('claim-highlight')
173+
expect(mark.textContent).toBe('text is here')
174+
expect(mark).toHaveAttribute('data-claim-status', ClaimStatusEnum.Draft)
142175
})
143176

144177
it('rewrites relative image URLs against the owasp.org base', () => {
Lines changed: 28 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,10 @@
11
import { fireEvent, render, screen } from '@testing-library/react'
2-
import { useIsMobile } from 'hooks/useIsMobile'
32
import { useRouter } from 'next/navigation'
43
import React from 'react'
54

65
import { ClaimStatusEnum } from 'types/__generated__/graphql'
76
import ClaimHighlight from 'components/ClaimHighlight'
87

9-
jest.mock('hooks/useIsMobile', () => ({
10-
useIsMobile: jest.fn(() => false),
11-
}))
12-
13-
jest.mock('@heroui/tooltip', () => ({
14-
Tooltip: ({ content, children }: { content: React.ReactNode; children: React.ReactNode }) => (
15-
<>
16-
<div data-testid="tooltip-content">{content}</div>
17-
{children}
18-
</>
19-
),
20-
}))
21-
228
jest.mock('@heroui/react', () => ({
239
Popover: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
2410
PopoverTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
@@ -27,7 +13,21 @@ jest.mock('@heroui/react', () => ({
2713
),
2814
}))
2915

30-
const mockUseIsMobile = useIsMobile as jest.Mock
16+
jest.mock('@heroui/button', () => ({
17+
Button: ({
18+
children,
19+
onPress,
20+
className,
21+
}: {
22+
children: React.ReactNode
23+
onPress?: () => void
24+
className?: string
25+
}) => (
26+
<button type="button" onClick={onPress} className={className}>
27+
{children}
28+
</button>
29+
),
30+
}))
3131

3232
const renderHighlight = (extra: Record<string, unknown> = {}) =>
3333
render(
@@ -46,7 +46,6 @@ const renderHighlight = (extra: Record<string, unknown> = {}) =>
4646
describe('ClaimHighlight', () => {
4747
beforeEach(() => {
4848
jest.clearAllMocks()
49-
mockUseIsMobile.mockReturnValue(false)
5049
})
5150

5251
it('renders a plain span when no claim key is present', () => {
@@ -59,49 +58,32 @@ describe('ClaimHighlight', () => {
5958
expect(span).not.toBeNull()
6059
expect(span?.className).toBe('foo')
6160
expect(span?.textContent).toBe('plain text')
62-
expect(screen.queryByTestId('tooltip-content')).not.toBeInTheDocument()
61+
expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument()
6362
})
6463

65-
it('renders desktop tooltip with claim name and status badge', () => {
64+
it('renders a popover with claim name, status badge, and View claim button', () => {
6665
renderHighlight()
67-
const tooltip = screen.getByTestId('tooltip-content')
68-
expect(tooltip).toHaveTextContent('My Claim')
69-
expect(tooltip).toHaveTextContent('Approved')
70-
expect(tooltip).toHaveTextContent('Click to view')
71-
expect(screen.getByRole('link', { name: /Claim: My Claim, status Approved/i })).toBeVisible()
66+
const popover = screen.getByTestId('popover-content')
67+
expect(popover).toHaveTextContent('My Claim')
68+
expect(popover).toHaveTextContent('Approved')
69+
expect(screen.getByRole('button', { name: /View claim/i })).toBeInTheDocument()
7270
})
7371

74-
it('navigates on click when in desktop mode', () => {
72+
it('navigates when the View claim button is clicked', () => {
7573
const push = (useRouter() as unknown as { push: jest.Mock }).push
7674
renderHighlight()
77-
fireEvent.click(screen.getByRole('link'))
75+
fireEvent.click(screen.getByRole('button', { name: /View claim/i }))
7876
expect(push).toHaveBeenCalledWith('/board/2025/candidates/alice/claims/my-claim')
7977
})
8078

81-
it('navigates on Enter key press', () => {
82-
const push = (useRouter() as unknown as { push: jest.Mock }).push
83-
renderHighlight()
84-
fireEvent.keyDown(screen.getByRole('link'), { key: 'Enter' })
85-
expect(push).toHaveBeenCalledWith('/board/2025/candidates/alice/claims/my-claim')
86-
})
87-
88-
it('navigates on Space key press', () => {
89-
const push = (useRouter() as unknown as { push: jest.Mock }).push
90-
renderHighlight()
91-
fireEvent.keyDown(screen.getByRole('link'), { key: ' ' })
92-
expect(push).toHaveBeenCalledWith('/board/2025/candidates/alice/claims/my-claim')
79+
it('falls back to Draft style when status is unknown', () => {
80+
renderHighlight({ 'data-claim-status': 'MYSTERY' })
81+
expect(screen.getByTestId('popover-content')).toHaveTextContent('Draft')
9382
})
9483

95-
it('ignores unrelated keys', () => {
96-
const push = (useRouter() as unknown as { push: jest.Mock }).push
84+
it('exposes the claim in the aria-label on the trigger', () => {
9785
renderHighlight()
98-
fireEvent.keyDown(screen.getByRole('link'), { key: 'a' })
99-
expect(push).not.toHaveBeenCalled()
100-
})
101-
102-
it('falls back to Draft style when status is unknown', () => {
103-
renderHighlight({ 'data-claim-status': 'MYSTERY' })
104-
expect(screen.getByTestId('tooltip-content')).toHaveTextContent('Draft')
86+
expect(screen.getByLabelText('Claim: My Claim, status Approved')).toBeInTheDocument()
10587
})
10688

10789
it('falls back to unnamed in aria-label when name is missing', () => {
@@ -117,33 +99,4 @@ describe('ClaimHighlight', () => {
11799
)
118100
expect(screen.getByLabelText('Claim: unnamed, status Draft')).toBeInTheDocument()
119101
})
120-
121-
describe('mobile', () => {
122-
beforeEach(() => {
123-
mockUseIsMobile.mockReturnValue(true)
124-
})
125-
126-
it('renders a popover with a View claim button', () => {
127-
renderHighlight()
128-
const popover = screen.getByTestId('popover-content')
129-
expect(popover).toHaveTextContent('My Claim')
130-
expect(popover).toHaveTextContent('Approved')
131-
expect(screen.getByRole('button', { name: /View claim/i })).toBeInTheDocument()
132-
expect(screen.queryByRole('link')).not.toBeInTheDocument()
133-
})
134-
135-
it('navigates when the View claim button is clicked', () => {
136-
const push = (useRouter() as unknown as { push: jest.Mock }).push
137-
renderHighlight()
138-
fireEvent.click(screen.getByRole('button', { name: /View claim/i }))
139-
expect(push).toHaveBeenCalledWith('/board/2025/candidates/alice/claims/my-claim')
140-
})
141-
142-
it('does not navigate on click of the highlight trigger', () => {
143-
const push = (useRouter() as unknown as { push: jest.Mock }).push
144-
renderHighlight()
145-
fireEvent.click(screen.getByRole('button', { name: /Claim: My Claim, status Approved/i }))
146-
expect(push).not.toHaveBeenCalled()
147-
})
148-
})
149102
})

frontend/__tests__/unit/hooks/useProfileSelection.test.tsx

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,21 @@ type MockRangeInit = {
99
rect?: Partial<DOMRect>
1010
}
1111

12-
const setupSelection = ({ text, collapsed = false, rangeCount = 1, rect = {} }: MockRangeInit) => {
12+
const setupSelection = ({
13+
text,
14+
collapsed = false,
15+
rangeCount = 1,
16+
rect = {},
17+
intersectsHighlight = false,
18+
}: MockRangeInit & { intersectsHighlight?: boolean }) => {
1319
const startContainer = document.createElement('span')
1420
const endContainer = document.createElement('span')
1521
const boundingRect = { top: 20, left: 10, width: 100, height: 16, ...rect } as DOMRect
1622
const range = {
1723
startContainer,
1824
endContainer,
1925
getBoundingClientRect: () => boundingRect,
26+
intersectsNode: () => intersectsHighlight,
2027
}
2128
const selection = {
2229
rangeCount,
@@ -123,6 +130,29 @@ describe('useProfileSelection', () => {
123130
expect(result.current).toBeNull()
124131
})
125132

133+
it('returns null when the selection intersects an existing claim highlight', () => {
134+
const container = document.createElement('div')
135+
document.body.appendChild(container)
136+
const highlight = document.createElement('span')
137+
highlight.setAttribute('data-claim-highlight', 'true')
138+
container.appendChild(highlight)
139+
const { startContainer, endContainer } = setupSelection({
140+
text: 'hello',
141+
intersectsHighlight: true,
142+
})
143+
container.appendChild(startContainer)
144+
container.appendChild(endContainer)
145+
146+
const containerRef = { current: container }
147+
const { result } = renderHook(() => useProfileSelection(containerRef, true))
148+
149+
act(() => {
150+
document.dispatchEvent(new Event('selectionchange'))
151+
})
152+
153+
expect(result.current).toBeNull()
154+
})
155+
126156
it('removes listener on unmount', () => {
127157
const removeSpy = jest.spyOn(document, 'removeEventListener')
128158
const containerRef = createRef<HTMLDivElement>()

frontend/src/components/AnnotatedProfile.tsx

Lines changed: 25 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ const resolveMediaSrc = <T,>(src: T, year: string): T | string => {
5454
const wrapClaims = (markdown: string, claims: ProfileClaim[]): string => {
5555
const eligible = claims
5656
.filter((c) => c.sourceText && !c.sourceText.includes('\n\n'))
57-
.sort((a, b) => {
57+
.toSorted((a, b) => {
5858
const lengthDiff = b.sourceText.length - a.sourceText.length
5959
if (lengthDiff !== 0) return lengthDiff
6060
return (STATUS_PRIORITY[b.status] ?? 0) - (STATUS_PRIORITY[a.status] ?? 0)
@@ -69,17 +69,29 @@ const wrapClaims = (markdown: string, claims: ProfileClaim[]): string => {
6969
ranges.push({ start, end, claim })
7070
}
7171

72-
return ranges
73-
.sort((a, b) => b.start - a.start)
74-
.reduce((acc, { start, end, claim }) => {
75-
const open =
76-
`<span data-claim-key="${escapeAttribute(claim.key)}"` +
77-
` data-claim-name="${escapeAttribute(claim.name)}"` +
78-
` data-claim-status="${claim.status}">`
79-
return `${acc.slice(0, start)}${open}${acc.slice(start, end)}</span>${acc.slice(end)}`
80-
}, markdown)
72+
const orderedRanges = ranges.toSorted((a, b) => b.start - a.start)
73+
return orderedRanges.reduce((acc, { start, end, claim }) => {
74+
const open =
75+
`<span data-claim-key="${escapeAttribute(claim.key)}"` +
76+
` data-claim-name="${escapeAttribute(claim.name)}"` +
77+
` data-claim-status="${claim.status}">`
78+
return `${acc.slice(0, start)}${open}${acc.slice(start, end)}</span>${acc.slice(end)}`
79+
}, markdown)
8180
}
8281

82+
type MediaImgProps = ImgHTMLAttributes<HTMLImageElement> & { year: string }
83+
84+
const MediaImg = ({ year, ...props }: MediaImgProps) => (
85+
// eslint-disable-next-line @next/next/no-img-element, jsx-a11y/alt-text -- candidate markdown may reference arbitrary hosts and set alt itself
86+
<img {...props} src={resolveMediaSrc(props.src, year)} />
87+
)
88+
89+
type MediaSourceProps = SourceHTMLAttributes<HTMLSourceElement> & { year: string }
90+
91+
const MediaSource = ({ year, ...props }: MediaSourceProps) => (
92+
<source {...props} src={resolveMediaSrc(props.src, year)} />
93+
)
94+
8395
const AnnotatedProfile = ({
8496
claims,
8597
isCandidate,
@@ -96,21 +108,9 @@ const AnnotatedProfile = ({
96108
const markdownOptions = useMemo(
97109
() => ({
98110
overrides: {
99-
span: {
100-
component: ClaimHighlight,
101-
props: { year, login },
102-
},
103-
img: {
104-
component: (props: ImgHTMLAttributes<HTMLImageElement>) => (
105-
// eslint-disable-next-line @next/next/no-img-element, jsx-a11y/alt-text -- candidate markdown may reference arbitrary hosts and set alt itself
106-
<img {...props} src={resolveMediaSrc(props.src, year)} />
107-
),
108-
},
109-
source: {
110-
component: (props: SourceHTMLAttributes<HTMLSourceElement>) => (
111-
<source {...props} src={resolveMediaSrc(props.src, year)} />
112-
),
113-
},
111+
span: { component: ClaimHighlight, props: { year, login } },
112+
img: { component: MediaImg, props: { year } },
113+
source: { component: MediaSource, props: { year } },
114114
},
115115
}),
116116
[year, login]

0 commit comments

Comments
 (0)