Skip to content

Commit 7179285

Browse files
committed
test: add comprehensive tests for RiPopover and fix standalone scalar handling
Add test suite for RiPopover covering all props and behaviors. Fix standalone prop to wrap scalar values in span for asChild compatibility. Improve code readability.
1 parent e6e8ad7 commit 7179285

File tree

3 files changed

+312
-13
lines changed

3 files changed

+312
-13
lines changed
Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
1+
import React from 'react'
2+
import { render, waitForRiPopoverVisible, screen } from 'uiSrc/utils/test-utils'
3+
import { RiPopover } from './RiPopover'
4+
import { RiPopoverProps } from './types'
5+
6+
const TestButton = () => (
7+
<button type="button" data-testid="popover-trigger">
8+
Click me
9+
</button>
10+
)
11+
12+
const renderPopover = (overrides: Partial<RiPopoverProps> = {}) => {
13+
return render(
14+
<RiPopover
15+
button={<TestButton />}
16+
isOpen={false}
17+
closePopover={jest.fn()}
18+
{...overrides}
19+
>
20+
<div data-testid="popover-content">Popover content</div>
21+
</RiPopover>,
22+
)
23+
}
24+
25+
describe('RiPopover', () => {
26+
it('should render', () => {
27+
expect(renderPopover()).toBeTruthy()
28+
})
29+
30+
it('should render trigger button', () => {
31+
renderPopover()
32+
33+
expect(screen.getByTestId('popover-trigger')).toBeInTheDocument()
34+
})
35+
36+
it('should render popover content when isOpen is true', async () => {
37+
renderPopover({ isOpen: true })
38+
39+
await waitForRiPopoverVisible()
40+
41+
expect(screen.getByTestId('popover-content')).toBeInTheDocument()
42+
})
43+
44+
it('should not render popover content when isOpen is false', () => {
45+
renderPopover({ isOpen: false })
46+
47+
expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument()
48+
})
49+
50+
describe('button prop (legacy)', () => {
51+
it('should wrap button in span by default', () => {
52+
renderPopover()
53+
54+
const trigger = screen.getByTestId('popover-trigger')
55+
const wrapper = trigger.parentElement
56+
57+
expect(wrapper?.tagName).toBe('SPAN')
58+
})
59+
60+
it('should apply anchorClassName to wrapper span', () => {
61+
renderPopover({ anchorClassName: 'custom-anchor-class' })
62+
63+
const trigger = screen.getByTestId('popover-trigger')
64+
const wrapper = trigger.parentElement
65+
66+
expect(wrapper).toHaveClass('custom-anchor-class')
67+
})
68+
})
69+
70+
describe('trigger prop (new)', () => {
71+
it('should use trigger when provided', () => {
72+
renderPopover({
73+
trigger: <button data-testid="new-trigger">New Trigger</button>,
74+
})
75+
76+
expect(screen.getByTestId('new-trigger')).toBeInTheDocument()
77+
expect(screen.queryByTestId('popover-trigger')).not.toBeInTheDocument()
78+
})
79+
80+
it('should wrap trigger in span by default (standalone=false)', () => {
81+
renderPopover({
82+
trigger: <button data-testid="new-trigger">New Trigger</button>,
83+
})
84+
85+
const trigger = screen.getByTestId('new-trigger')
86+
const wrapper = trigger.parentElement
87+
88+
expect(wrapper?.tagName).toBe('SPAN')
89+
})
90+
91+
it('should render trigger directly when standalone is true', () => {
92+
renderPopover({
93+
trigger: <div data-testid="standalone-trigger">Standalone</div>,
94+
standalone: true,
95+
})
96+
97+
const trigger = screen.getByTestId('standalone-trigger')
98+
const wrapper = trigger.parentElement
99+
100+
// Should not be wrapped in span
101+
expect(wrapper?.tagName).not.toBe('SPAN')
102+
expect(wrapper?.tagName).toBe('DIV')
103+
})
104+
105+
it('should apply anchorClassName to wrapper when standalone is false', () => {
106+
renderPopover({
107+
trigger: <button data-testid="new-trigger">New Trigger</button>,
108+
anchorClassName: 'custom-anchor-class',
109+
})
110+
111+
const trigger = screen.getByTestId('new-trigger')
112+
const wrapper = trigger.parentElement
113+
114+
expect(wrapper).toHaveClass('custom-anchor-class')
115+
})
116+
117+
it('should not apply anchorClassName when standalone is true', () => {
118+
renderPopover({
119+
trigger: <div data-testid="standalone-trigger">Standalone</div>,
120+
standalone: true,
121+
anchorClassName: 'custom-anchor-class',
122+
})
123+
124+
const trigger = screen.getByTestId('standalone-trigger')
125+
126+
// anchorClassName should not be applied since there's no wrapper
127+
expect(trigger).not.toHaveClass('custom-anchor-class')
128+
})
129+
})
130+
131+
describe('prop conflicts and warnings', () => {
132+
it('should warn when both button and trigger are provided', () => {
133+
const consoleWarnSpy = jest.spyOn(console, 'warn')
134+
135+
renderPopover({ trigger: <button>Trigger</button> })
136+
137+
expect(consoleWarnSpy).toHaveBeenCalledWith(
138+
"[RiPopover]: Both 'button' and 'trigger' props are provided. Using 'trigger'. Please migrate to 'trigger' prop.",
139+
)
140+
})
141+
142+
it('should warn when both panelClassName and className are provided', () => {
143+
const consoleWarnSpy = jest.spyOn(console, 'warn')
144+
145+
renderPopover({
146+
panelClassName: 'old-class',
147+
className: 'new-class',
148+
})
149+
150+
expect(consoleWarnSpy).toHaveBeenCalledWith(
151+
"[RiPopover]: Both 'panelClassName' and 'className' props are provided. Using 'className'. Please migrate to 'className' prop.",
152+
)
153+
})
154+
})
155+
156+
describe('className prop', () => {
157+
it('should use className when provided', async () => {
158+
renderPopover({
159+
isOpen: true,
160+
className: 'custom-class',
161+
})
162+
163+
await waitForRiPopoverVisible()
164+
165+
const popover = screen.queryByRole('dialog')
166+
expect(popover).toBeInTheDocument()
167+
expect(popover).toHaveClass('custom-class')
168+
})
169+
170+
it('should fall back to panelClassName when className is not provided', async () => {
171+
renderPopover({
172+
isOpen: true,
173+
panelClassName: 'fallback-class',
174+
})
175+
176+
await waitForRiPopoverVisible()
177+
178+
const popover = screen.queryByRole('dialog')
179+
expect(popover).toBeInTheDocument()
180+
expect(popover).toHaveClass('fallback-class')
181+
})
182+
183+
it('should prefer className over panelClassName when both are provided', async () => {
184+
renderPopover({
185+
isOpen: true,
186+
panelClassName: 'old-class',
187+
className: 'new-class',
188+
})
189+
190+
await waitForRiPopoverVisible()
191+
192+
const popover = screen.queryByRole('dialog')
193+
194+
expect(popover).toBeInTheDocument()
195+
expect(popover).toHaveClass('new-class')
196+
expect(popover).not.toHaveClass('old-class')
197+
})
198+
})
199+
200+
describe('panelPaddingSize', () => {
201+
it('should apply padding style based on panelPaddingSize', async () => {
202+
renderPopover({
203+
isOpen: true,
204+
panelPaddingSize: 'm',
205+
})
206+
207+
await waitForRiPopoverVisible()
208+
209+
const popover = screen.queryByRole('dialog')
210+
211+
expect(popover).toBeInTheDocument()
212+
expect(popover).toHaveStyle({ padding: '18px' })
213+
})
214+
215+
it('should apply no padding when panelPaddingSize is none', async () => {
216+
renderPopover({
217+
isOpen: true,
218+
panelPaddingSize: 'none',
219+
})
220+
221+
await waitForRiPopoverVisible()
222+
223+
const popover = screen.queryByRole('dialog')
224+
225+
expect(popover).toBeInTheDocument()
226+
expect(popover).toHaveStyle({ padding: '0px' })
227+
})
228+
})
229+
230+
describe('scalar trigger values', () => {
231+
it('should wrap string trigger in span', () => {
232+
const { container } = renderPopover({ trigger: 'String trigger' })
233+
234+
const text = screen.getByText('String trigger')
235+
// The Popover component might wrap our span in a div, so check if span exists
236+
const span = container.querySelector('span')
237+
expect(span).toBeInTheDocument()
238+
expect(span).toContainElement(text)
239+
})
240+
241+
it('should wrap number trigger in span', () => {
242+
const { container } = renderPopover({ trigger: 123 })
243+
244+
const text = screen.getByText('123')
245+
// The Popover component might wrap our span in a div, so check if span exists
246+
const span = container.querySelector('span')
247+
expect(span).toBeInTheDocument()
248+
expect(span).toContainElement(text)
249+
})
250+
251+
it('should wrap scalar trigger in span when standalone is true', () => {
252+
// When standalone is true and trigger is a scalar (string, number, etc.),
253+
// we wrap it in a span because RadixPopover.Trigger with asChild requires a React element
254+
const { container } = renderPopover({
255+
trigger: 'String trigger',
256+
standalone: true,
257+
})
258+
259+
const text = screen.getByText('String trigger')
260+
// Should be wrapped in a span (without anchorClassName)
261+
const span = container.querySelector('span')
262+
expect(span).toBeInTheDocument()
263+
expect(span).toContainElement(text)
264+
// The span should not have anchorClassName when standalone is true
265+
expect(span).not.toHaveClass()
266+
})
267+
})
268+
269+
describe('backwards compatibility', () => {
270+
it('should work with button prop only (legacy behavior)', () => {
271+
renderPopover()
272+
273+
expect(screen.getByTestId('popover-trigger')).toBeInTheDocument()
274+
})
275+
276+
it('should work with panelClassName prop only (legacy behavior)', async () => {
277+
const { getByRole } = renderPopover({
278+
isOpen: true,
279+
panelClassName: 'legacy-class',
280+
})
281+
282+
await waitForRiPopoverVisible()
283+
284+
const popover = getByRole('dialog')
285+
expect(popover).toBeInTheDocument()
286+
expect(popover).toHaveClass('legacy-class')
287+
})
288+
})
289+
})

redisinsight/ui/src/components/base/popover/RiPopover.tsx

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,14 @@ export const RiPopover = ({
2424
// Warn if both button and trigger are provided
2525
if (button !== undefined && trigger !== undefined) {
2626
console.warn(
27-
"RiPopover: Both 'button' and 'trigger' props are provided. Using 'trigger'. Please migrate to 'trigger' prop.",
27+
"[RiPopover]: Both 'button' and 'trigger' props are provided. Using 'trigger'. Please migrate to 'trigger' prop.",
2828
)
2929
}
3030

3131
// Warn if both panelClassName and className are provided
3232
if (panelClassName !== undefined && className !== undefined) {
3333
console.warn(
34-
"RiPopover: Both 'panelClassName' and 'className' props are provided. Using 'className'. Please migrate to 'className' prop.",
34+
"[RiPopover]: Both 'panelClassName' and 'className' props are provided. Using 'className'. Please migrate to 'className' prop.",
3535
)
3636
}
3737

@@ -43,13 +43,23 @@ export const RiPopover = ({
4343

4444
// Render trigger element
4545
// If standalone is true, the trigger will be standalone and will not be wrapped in a span
46-
// for this to work properly, ether base trigger element is `div`, `span` etc. (base dom element)
46+
// for this to work properly, either base trigger element is `div`, `span` etc. (base dom element)
4747
// or a component that forwards ref
48-
const triggerElement = standalone ? (
49-
activeTrigger
50-
) : (
51-
<span className={anchorClassName}>{activeTrigger}</span>
52-
)
48+
// However, if standalone is true and trigger is a scalar (string, number, etc.),
49+
// we need to wrap it in a span because RadixPopover.Trigger with asChild requires a React element
50+
let triggerElement: React.ReactNode
51+
52+
if (standalone) {
53+
if (React.isValidElement(activeTrigger)) {
54+
triggerElement = activeTrigger
55+
} else {
56+
// Wrap scalar values in span for asChild compatibility
57+
triggerElement = <span>{activeTrigger}</span>
58+
}
59+
} else {
60+
// Always wrap in span with anchorClassName for backwards compatibility
61+
triggerElement = <span className={anchorClassName}>{activeTrigger}</span>
62+
}
5363

5464
const placement =
5565
anchorPosition && anchorPositionMap[anchorPosition]?.placement

redisinsight/ui/src/components/base/popover/types.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,18 +20,18 @@ export type RiPopoverProps = Omit<
2020
isOpen?: PopoverProps['open']
2121
closePopover?: PopoverProps['onClickOutside']
2222
ownFocus?: PopoverProps['autoFocus']
23-
/* @deprecated old prop for popover trigger element, use trigger */
23+
/** @deprecated old prop for popover trigger element, use {@linkcode trigger} */
2424
button?: PopoverProps['content']
25-
/* preferred prop for popover trigger element (optional) */
25+
/** preferred prop for popover trigger element (optional) */
2626
trigger?: ReactNode
2727
anchorPosition?: AnchorPosition
2828
panelPaddingSize?: PanelPaddingSize
2929
anchorClassName?: string
30-
/* @deprecated - use @see {@link className} - this is popover content wrapper class name */
30+
/** @deprecated - use {@linkcode className} - this is popover content wrapper class name */
3131
panelClassName?: string
32-
/* new preferred prop for popover content wrapper class name (optional) */
32+
/** new preferred prop for popover content wrapper class name (optional) */
3333
className?: string
3434
'data-testid'?: string
35-
/* if true, the trigger will be standalone and will not be wrapped in a span */
35+
/** if true, the trigger will be standalone and will not be wrapped in a span */
3636
standalone?: boolean
3737
}

0 commit comments

Comments
 (0)