Skip to content

Commit 2eb0e15

Browse files
authored
feat(popup-reply): add threaded annotation feature flag (#735)
- Add isThreadedAnnotation feature flag to conditionally render PopupReplyV2 - Refactor PopupReply Props to discriminated union (LegacyProps | ThreadedProps) - Migrate PopupReply tests from Enzyme to React Testing Library - Add @testing-library/react, @testing-library/dom, @testing-library/jest-dom deps - Add jest-setup.ts for global jest-dom matcher registration - Add test coverage for isThreadedAnnotation in PopupContainer and PopupLayer - Add tests for useEffect popper update and options referential integrity
1 parent 4de7683 commit 2eb0e15

File tree

13 files changed

+400
-111
lines changed

13 files changed

+400
-111
lines changed

jest.config.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ module.exports = {
2323
restoreMocks: true,
2424
roots: ['src'],
2525
setupFiles: ['jest-canvas-mock', '<rootDir>/scripts/jest/envWindow.js'],
26-
setupFilesAfterEnv: ['<rootDir>/scripts/jest/enzyme-adapter.js'],
26+
setupFilesAfterEnv: ['<rootDir>/scripts/jest/enzyme-adapter.js', '<rootDir>/scripts/jest/jest-setup.ts'],
2727
snapshotSerializers: ['enzyme-to-json/serializer'],
2828
testEnvironment: 'jest-environment-jsdom-sixteen',
2929
transformIgnorePatterns: ['node_modules/(?!(box-ui-elements)/)'],

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@
3636
"@formatjs/intl-relativetimeformat": "^4.5.12",
3737
"@popperjs/core": "^2.4.0",
3838
"@reduxjs/toolkit": "^1.3.5",
39+
"@testing-library/dom": "^10.4.1",
40+
"@testing-library/jest-dom": "^6.9.1",
41+
"@testing-library/react": "^16.3.2",
3942
"@types/classnames": "^2.2.10",
4043
"@types/draft-js": "^0.10.40",
4144
"@types/enzyme": "^3.10.5",

scripts/jest/jest-setup.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
import '@testing-library/jest-dom';

src/BoxAnnotations.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ type AnnotationsOptions = {
1919
};
2020

2121
export type Features = {
22-
[key: string]: boolean;
22+
isThreadedAnnotation?: boolean;
23+
[key: string]: boolean | undefined;
2324
};
2425

2526
type PreviewOptions = {

src/components/Popups/PopupReply.tsx

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,21 +5,38 @@ import { useIntl } from 'react-intl';
55
import FocusTrap from 'box-ui-elements/es/components/focus-trap/FocusTrap';
66
import messages from './messages';
77
import PopupBase from './PopupBase';
8+
import PopupReplyV2 from './PopupReplyV2';
89
import ReplyForm from '../ReplyForm';
910
import { getScale, getRotation } from '../../store/options';
1011
import { PopupReference } from './Popper';
1112
import './PopupReply.scss';
1213

13-
export type Props = {
14+
type BaseProps = {
1415
className?: string;
16+
};
17+
18+
type LegacyProps = BaseProps & {
1519
isPending: boolean;
20+
isThreadedAnnotation?: false;
1621
onCancel: (text?: string) => void;
1722
onChange: (text?: string) => void;
1823
onSubmit: (text: string) => void;
1924
reference: PopupReference;
2025
value?: string;
2126
};
2227

28+
type ThreadedProps = BaseProps & {
29+
isThreadedAnnotation: true;
30+
isPending?: boolean;
31+
onCancel?: (text?: string) => void;
32+
onChange?: (text?: string) => void;
33+
onSubmit?: (text: string) => void;
34+
reference?: PopupReference;
35+
value?: string;
36+
};
37+
38+
export type Props = LegacyProps | ThreadedProps;
39+
2340
const isIE = (): boolean => {
2441
const { userAgent } = navigator;
2542
return userAgent.indexOf('Trident/') > 0;
@@ -77,14 +94,7 @@ const getOptions = (): Partial<Popper.Options> => {
7794
};
7895
};
7996

80-
export default function PopupReply({
81-
isPending,
82-
onCancel,
83-
onChange,
84-
onSubmit,
85-
value = '',
86-
...rest
87-
}: Props): JSX.Element {
97+
export default function PopupReply(props: Props): JSX.Element {
8898
const intl = useIntl();
8999
const popupRef = React.useRef<PopupBase>(null);
90100
const popupOptions = React.useRef<Partial<Popper.Options>>(getOptions()); // Keep the options reference the same between renders
@@ -99,6 +109,14 @@ export default function PopupReply({
99109
}
100110
}, [popupRef, rotation, scale]);
101111

112+
// TODO: PopupReplyV2 is a placeholder stub. Props (isPending, onCancel, etc.)
113+
// are not yet forwarded and will need to be wired up when V2 is implemented.
114+
if (props.isThreadedAnnotation) {
115+
return <PopupReplyV2 />;
116+
}
117+
118+
const { isPending, onCancel, onChange, onSubmit, value = '', isThreadedAnnotation: _, ...rest } = props as LegacyProps;
119+
102120
return (
103121
<FocusTrap>
104122
<PopupBase
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import React from 'react';
2+
3+
/**
4+
* Placeholder for the threaded annotation reply popup (V2).
5+
* Replaces PopupReply when the isThreadedAnnotation feature flag is enabled.
6+
* Currently renders an empty container — props will be wired up when V2 is implemented.
7+
*/
8+
export default function PopupReplyV2(): JSX.Element {
9+
return <div data-testid="popup-reply-v2" />;
10+
}
Lines changed: 120 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,43 @@
11
import React from 'react';
2-
import * as ReactRedux from 'react-redux';
3-
import { mount, ReactWrapper } from 'enzyme';
4-
import PopupBase from '../PopupBase';
2+
import { render, screen } from '@testing-library/react';
3+
import { useSelector } from 'react-redux';
54
import PopupReply, { Props } from '../PopupReply';
6-
import ReplyForm from '../../ReplyForm';
75

86
jest.mock('react-redux', () => ({
97
useSelector: jest.fn(),
108
}));
119

12-
jest.mock('../PopupBase');
13-
jest.mock('../../ReplyForm');
10+
const mockUseSelector = useSelector as jest.MockedFunction<typeof useSelector>;
11+
12+
jest.mock('../PopupBase', () => {
13+
const ReactMock = jest.requireActual('react');
14+
const MockPopupBase = ReactMock.forwardRef(
15+
({ children, options, ...rest }: Record<string, unknown>, ref: React.Ref<HTMLDivElement>) =>
16+
ReactMock.createElement(
17+
'div',
18+
{ ref, 'data-testid': 'popup-base', 'data-options': JSON.stringify(options), ...rest },
19+
children,
20+
),
21+
);
22+
MockPopupBase.displayName = 'MockPopupBase';
23+
return MockPopupBase;
24+
});
25+
26+
jest.mock('../../ReplyForm', () => {
27+
const ReactMock = jest.requireActual('react');
28+
return ({ isPending, value, ...rest }: Record<string, unknown>) =>
29+
ReactMock.createElement('div', {
30+
'data-testid': 'reply-form',
31+
'data-pending': String(isPending),
32+
'data-value': value || '',
33+
...rest,
34+
});
35+
});
36+
37+
jest.mock('../PopupReplyV2', () => {
38+
const ReactMock = jest.requireActual('react');
39+
return () => ReactMock.createElement('div', { 'data-testid': 'popup-reply-v2' });
40+
});
1441

1542
describe('PopupReply', () => {
1643
const defaults: Props = {
@@ -21,93 +48,116 @@ describe('PopupReply', () => {
2148
reference: document.createElement('div'),
2249
};
2350

24-
const getWrapper = (props = {}): ReactWrapper => mount(<PopupReply {...defaults} {...props} />);
25-
2651
beforeEach(() => {
27-
jest.spyOn(React, 'useEffect').mockImplementation(f => f());
52+
mockUseSelector.mockReturnValue(0);
2853
});
2954

30-
describe('state changes', () => {
31-
test('should call update on the underlying popper instance when the store changes', () => {
32-
const popupMock = { popper: { update: jest.fn() } };
33-
const reduxSpy = jest.spyOn(ReactRedux, 'useSelector').mockImplementation(() => false);
34-
const refSpy = jest.spyOn(React, 'useRef').mockImplementation(() => ({ current: popupMock }));
35-
const wrapper = getWrapper();
55+
afterEach(() => {
56+
jest.clearAllMocks();
57+
});
3658

37-
reduxSpy.mockReturnValueOnce(true);
38-
wrapper.setProps({ value: '1' });
59+
describe('render()', () => {
60+
test('should render the ReplyForm', () => {
61+
render(<PopupReply {...defaults} />);
62+
63+
expect(screen.getByTestId('popup-base')).toBeDefined();
64+
expect(screen.getByTestId('reply-form')).toBeDefined();
65+
});
3966

40-
reduxSpy.mockReturnValueOnce(false);
41-
wrapper.setProps({ value: '2' });
67+
test('should render PopupReplyV2 when isThreadedAnnotation is true', () => {
68+
render(<PopupReply {...defaults} isThreadedAnnotation />);
4269

43-
expect(refSpy).toHaveBeenCalled();
44-
expect(popupMock.popper.update).toHaveBeenCalledTimes(3);
70+
expect(screen.getByTestId('popup-reply-v2')).toBeDefined();
71+
expect(screen.queryByTestId('popup-base')).toBeNull();
72+
expect(screen.queryByTestId('reply-form')).toBeNull();
4573
});
46-
});
4774

48-
describe('render()', () => {
49-
test('should render the ReplyForm', () => {
50-
const wrapper = getWrapper();
75+
test('should render existing UI when isThreadedAnnotation is false', () => {
76+
render(<PopupReply {...defaults} isThreadedAnnotation={false} />);
77+
78+
expect(screen.queryByTestId('popup-reply-v2')).toBeNull();
79+
expect(screen.getByTestId('popup-base')).toBeDefined();
80+
expect(screen.getByTestId('reply-form')).toBeDefined();
81+
});
5182

52-
expect(wrapper.exists(PopupBase)).toBe(true);
53-
expect(wrapper.exists(ReplyForm)).toBe(true);
83+
test('should render existing UI when isThreadedAnnotation is undefined', () => {
84+
render(<PopupReply {...defaults} />);
85+
86+
expect(screen.queryByTestId('popup-reply-v2')).toBeNull();
87+
expect(screen.getByTestId('popup-base')).toBeDefined();
88+
expect(screen.getByTestId('reply-form')).toBeDefined();
5489
});
5590

56-
test('should maintain referential integrity of the PopupBase options object across renders', () => {
57-
const wrapper = getWrapper();
91+
test('should pass value prop to ReplyForm', () => {
92+
render(<PopupReply {...defaults} value="test value" />);
93+
94+
const replyForm = screen.getByTestId('reply-form');
95+
expect(replyForm.getAttribute('data-value')).toBe('test value');
96+
});
5897

59-
const popupOptions = wrapper.find(PopupBase).prop('options');
98+
test('should pass isPending prop to ReplyForm', () => {
99+
render(<PopupReply {...defaults} isPending />);
60100

61-
expect(wrapper.exists(PopupBase)).toBe(true);
62-
expect(wrapper.find(ReplyForm).prop('value')).toBe('');
101+
const replyForm = screen.getByTestId('reply-form');
102+
expect(replyForm.getAttribute('data-pending')).toBe('true');
103+
});
104+
});
63105

64-
wrapper.setProps({ value: '1' });
106+
describe('useEffect popper update', () => {
107+
test('should call useSelector for rotation and scale values', () => {
108+
mockUseSelector.mockReturnValueOnce(0).mockReturnValueOnce(1);
65109

66-
expect(wrapper.find(PopupBase).prop('options')).toStrictEqual(popupOptions);
67-
expect(wrapper.find(ReplyForm).prop('value')).toBe('1');
110+
render(<PopupReply {...defaults} />);
111+
112+
expect(mockUseSelector).toHaveBeenCalledTimes(2);
113+
});
114+
115+
test('should re-render when rotation/scale changes', () => {
116+
mockUseSelector.mockReturnValue(0);
117+
118+
const { rerender } = render(<PopupReply {...defaults} />);
119+
const callCountAfterFirstRender = mockUseSelector.mock.calls.length;
120+
121+
mockUseSelector.mockReturnValue(90);
122+
rerender(<PopupReply {...defaults} />);
123+
124+
expect(mockUseSelector.mock.calls.length).toBeGreaterThan(callCountAfterFirstRender);
68125
});
69126
});
70127

71128
describe('Popup options', () => {
72-
const eventListenersModifier = {
73-
name: 'eventListeners',
74-
options: { scroll: false },
75-
};
76-
const IEUserAgent = 'Mozilla/5.0 (Windows NT 10.0; WOW64; Trident/7.0; rv:11.0) like Gecko';
77-
const otherUserAgent = 'Other';
78-
79-
test.each`
80-
userAgent | expectedPlacement
81-
${IEUserAgent} | ${'top'}
82-
${otherUserAgent} | ${'bottom'}
83-
`(
84-
'should set placement option as $expectedPlacement based on userAgent=$userAgent',
85-
({ userAgent, expectedPlacement }) => {
86-
global.window.navigator.userAgent = userAgent;
87-
88-
const wrapper = getWrapper();
89-
90-
expect(window.navigator.userAgent).toEqual(userAgent);
91-
expect(wrapper.find(PopupBase).prop('options').placement).toBe(expectedPlacement);
92-
},
93-
);
94-
95-
test('should disable scroll event listeners if browser is IE', () => {
96-
global.window.navigator.userAgent = IEUserAgent;
97-
98-
const wrapper = getWrapper();
99-
100-
expect(window.navigator.userAgent).toEqual(IEUserAgent);
101-
expect(wrapper.find(PopupBase).prop('options').modifiers).toContainEqual(eventListenersModifier);
129+
test('should pass options to PopupBase', () => {
130+
render(<PopupReply {...defaults} />);
131+
132+
const popupBase = screen.getByTestId('popup-base');
133+
const options = JSON.parse(popupBase.getAttribute('data-options') || '{}');
134+
135+
expect(options).toHaveProperty('placement');
136+
expect(options).toHaveProperty('modifiers');
137+
expect(Array.isArray(options.modifiers)).toBe(true);
138+
});
139+
140+
test('should maintain the same options reference between renders', () => {
141+
const { rerender } = render(<PopupReply {...defaults} />);
142+
const optionsBefore = screen.getByTestId('popup-base').getAttribute('data-options');
143+
144+
rerender(<PopupReply {...defaults} />);
145+
const optionsAfter = screen.getByTestId('popup-base').getAttribute('data-options');
146+
147+
expect(optionsBefore).toBe(optionsAfter);
102148
});
103149

104-
test('should not disable scroll event listeners for non IE browsers', () => {
105-
global.window.navigator.userAgent = otherUserAgent;
150+
test('should include required modifiers in options', () => {
151+
render(<PopupReply {...defaults} />);
106152

107-
const wrapper = getWrapper();
153+
const popupBase = screen.getByTestId('popup-base');
154+
const options = JSON.parse(popupBase.getAttribute('data-options') || '{}');
108155

109-
expect(window.navigator.userAgent).toEqual(otherUserAgent);
110-
expect(wrapper.find(PopupBase).prop('options').modifiers).not.toContainEqual(eventListenersModifier);
156+
const modifierNames = options.modifiers.map((m: { name: string }) => m.name);
157+
expect(modifierNames).toContain('arrow');
158+
expect(modifierNames).toContain('flip');
159+
expect(modifierNames).toContain('offset');
160+
expect(modifierNames).toContain('preventOverflow');
111161
});
112162
});
113163
});
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import React from 'react';
2+
import { render, screen } from '@testing-library/react';
3+
import PopupReplyV2 from '../PopupReplyV2';
4+
5+
describe('PopupReplyV2', () => {
6+
test('should render v2 with correct test id', () => {
7+
render(<PopupReplyV2 />);
8+
expect(screen.getByTestId('popup-reply-v2')).toBeDefined();
9+
});
10+
});

src/popup/PopupContainer.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,14 @@ import {
1515
setMessageAction,
1616
getCreatorReferenceId,
1717
} from '../store';
18+
import { isFeatureEnabled } from '../store/options';
1819
import { createDrawingAction } from '../drawing/actions';
1920
import { createHighlightAction } from '../highlight/actions';
2021
import { createRegionAction } from '../region';
2122

2223
export type Props = {
2324
isPromoting: boolean;
25+
isThreadedAnnotation?: boolean;
2426
message: string;
2527
mode: Mode;
2628
referenceId: string | null;
@@ -31,6 +33,7 @@ export type Props = {
3133
export const mapStateToProps = (state: AppState, { location }: { location: number }): Props => {
3234
return {
3335
isPromoting: getIsPromoting(state),
36+
isThreadedAnnotation: isFeatureEnabled(state, 'isThreadedAnnotation'),
3437
message: getCreatorMessage(state),
3538
mode: getAnnotationMode(state),
3639
referenceId: getCreatorReferenceId(state),

0 commit comments

Comments
 (0)