Skip to content

Commit 611a283

Browse files
authored
feat(popup): Add threaded annotations v2 popup and thread view (#743)
* feat(popup): add threaded-annotations to PopupReplyV2 Add @box/threaded-annotations and peer dependencies (blueprint-web, blueprint-web-assets, collaboration-popover, readable-time, user-selector) to box-annotations. Replace PopupReplyV2 stub with real ThreadedAnnotationsV2 component that renders threaded reply UI with mention support, TipTap editor, and annotation data adapters. Add webpack externals for @box/* packages as a temporary workaround for webpack 4 ESM exports incompatibility * feat(popup): add threaded annotations v2 popup and thread view Add @box/threaded-annotations integration with MessageEditorV2 for creating annotations and ThreadedAnnotationsV2 for viewing existing threads. Gated behind the isThreadedAnnotation feature flag. - Add PopupReplyV2 with MessageEditorV2 for annotation creation - Add PopupThreadV2 with ThreadedAnnotationsV2 for thread viewing - Add data adapters for annotation-to-message conversion - Add store actions for reply, delete, and resolve/unresolve - Add webpack externals for shared runtime dependencies - Add data-ba-annotation-id to annotation targets for DOM lookup - Upgrade autoprefixer for modern CSS compatibility - Add CSS isolation to prevent BUE style leakage into Blueprint - Add BlueprintModernizationProvider for modern button styling * refactor(popup): Move v1/v2 routing to PopupLayer Move the isThreadedAnnotation branching from PopupReply to PopupLayer so PopupReply is a clean v1-only component. PopupLayer now renders PopupReplyV2 or PopupReply directly based on the feature flag. * refactor(popup): Merge PopupReplyV2 and PopupThreadV2 into PopupV2 Combine the create and thread view popups into a single PopupV2 component that routes based on the presence of annotationId. Rename PopupReplyV2.scss to PopupV2.scss. Add CSS.escape for annotation ID DOM queries.
1 parent 2f5eb81 commit 611a283

30 files changed

Lines changed: 3291 additions & 176 deletions

i18n/en-US.properties

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ ba.boundingBoxHighlight.viewPrevReference = View previous reference
1616
ba.popup.reply.comment = Comment
1717
# Aria label description for annotation comment field
1818
ba.popup.reply.field = Type a comment
19+
# Aria label for mention selector loading state
20+
ba.popup.reply.mentionLoading = Loading users...
21+
# Aria role description for mention user selector
22+
ba.popup.reply.mentionSelector = Mention a collaborator
1923
# Button label for cancelling the creation of a description, comment, or reply
2024
ba.popups.cancel = Cancel
2125
# Button label for adding a comment in the drawing toolbar

package.json

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,13 @@
2828
"@babel/preset-react": "^7.25.9",
2929
"@babel/preset-typescript": "^7.25.9",
3030
"@box/frontend": "^10.0.0",
31+
"@box/blueprint-web": "^14.12.2",
32+
"@box/blueprint-web-assets": "^4.114.3",
33+
"@box/collaboration-popover": "^1.60.2",
3134
"@box/languages": "^1.1.0",
35+
"@box/readable-time": "^1.39.52",
36+
"@box/threaded-annotations": "^1.80.0",
37+
"@box/user-selector": "^1.74.52",
3238
"@cfaester/enzyme-adapter-react-18": "^0.8.0",
3339
"@commitlint/cli": "^8.3.5",
3440
"@commitlint/config-conventional": "^8.2.0",
@@ -50,7 +56,7 @@
5056
"@types/uuid": "^8.3.0",
5157
"@typescript-eslint/eslint-plugin": "^7.3.1",
5258
"@typescript-eslint/parser": "^7.3.1",
53-
"autoprefixer": "^9.7.6",
59+
"autoprefixer": "^10.4.19",
5460
"axios": "^0.24.0",
5561
"babel-eslint": "^10.1.0",
5662
"babel-jest": "^24.9.0",
@@ -62,6 +68,7 @@
6268
"box-ui-elements": "^20.0.0",
6369
"circular-dependency-plugin": "^5.2.0",
6470
"classnames": "^2.2.5",
71+
"clsx": "^2.1.1",
6572
"conventional-github-releaser": "^3.1.3",
6673
"core-js": "^3.38.1",
6774
"css-loader": "^7.1.2",
@@ -120,6 +127,14 @@
120127
"webpack-dev-server": "^5.2.3",
121128
"worker-farm": "^1.7.0"
122129
},
130+
"peerDependencies": {
131+
"@box/blueprint-web": "^14.12.2",
132+
"@box/blueprint-web-assets": "^4.114.3",
133+
"@box/collaboration-popover": "^1.60.2",
134+
"@box/readable-time": "^1.39.52",
135+
"@box/threaded-annotations": "^1.80.0",
136+
"@box/user-selector": "^1.74.52"
137+
},
123138
"scripts": {
124139
"build": "yarn setup && yarn build:prod:dist",
125140
"build:i18n": "props2es",

scripts/webpack.config.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,24 @@ const config = Object.assign(commonConfig(), {
2929
entry: {
3030
annotations: ['./src/BoxAnnotations.ts'],
3131
},
32+
externals: [
33+
// @box/threaded-annotations and its transitive dependencies are provided
34+
// by the consuming application (EUA) at runtime. Marking them as externals
35+
// avoids duplicating code that is already bundled by the host app.
36+
/^react(\/.*)?$/,
37+
/^react-dom(\/.*)?$/,
38+
/^react-intl(\/.*)?$/,
39+
/^react-redux(\/.*)?$/,
40+
/^@box\/threaded-annotations/,
41+
/^@box\/blueprint-web/,
42+
/^@box\/blueprint-web-assets/,
43+
/^@box\/collaboration-popover/,
44+
/^@box\/readable-time/,
45+
/^@box\/user-selector/,
46+
/^@box\/combobox-with-api/,
47+
/^@tiptap\//,
48+
],
49+
externalsType: 'commonjs',
3250
output: {
3351
filename: '[name].js',
3452
path: path.resolve('dist'),

src/__tests__/BoxAnnotations-test.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import BoxAnnotations from '../BoxAnnotations';
22

3+
jest.mock('@box/blueprint-web', () => ({}));
4+
jest.mock('@box/threaded-annotations', () => ({}));
5+
36
describe('BoxAnnotations', () => {
47
let loader;
58

Lines changed: 298 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
1+
import {
2+
annotationToMessages,
3+
collaboratorToUserContact,
4+
deserializeMentionMarkup,
5+
replyToTextMessage,
6+
} from '../threadedAnnotationsAdapters';
7+
import type { Annotation, Collaborator, Reply } from '../../@types';
8+
import { TARGET_TYPE } from '../../constants';
9+
10+
describe('threadedAnnotationsAdapters', () => {
11+
describe('deserializeMentionMarkup', () => {
12+
test('should return empty doc for empty string', () => {
13+
const result = deserializeMentionMarkup('');
14+
expect(result).toEqual({ type: 'doc', content: [] });
15+
});
16+
17+
test('should parse plain text into a single paragraph', () => {
18+
const result = deserializeMentionMarkup('Hello world');
19+
expect(result).toEqual({
20+
type: 'doc',
21+
content: [
22+
{
23+
type: 'paragraph',
24+
content: [{ type: 'text', text: 'Hello world' }],
25+
},
26+
],
27+
});
28+
});
29+
30+
test('should parse mention markup into mention nodes', () => {
31+
const result = deserializeMentionMarkup('Hello @[123:John Doe] how are you?');
32+
expect(result).toEqual({
33+
type: 'doc',
34+
content: [
35+
{
36+
type: 'paragraph',
37+
content: [
38+
{ type: 'text', text: 'Hello ' },
39+
{
40+
type: 'mention',
41+
attrs: {
42+
authorId: '',
43+
mentionId: '123',
44+
mentionedUserId: '123',
45+
mentionedUserName: 'John Doe',
46+
},
47+
},
48+
{ type: 'text', text: ' how are you?' },
49+
],
50+
},
51+
],
52+
});
53+
});
54+
55+
test('should handle multiple mentions in the same line', () => {
56+
const result = deserializeMentionMarkup('@[1:Alice] and @[2:Bob]');
57+
expect(result.content[0].content).toHaveLength(3);
58+
expect(result.content[0].content?.[0]).toEqual({
59+
type: 'mention',
60+
attrs: { authorId: '', mentionId: '1', mentionedUserId: '1', mentionedUserName: 'Alice' },
61+
});
62+
expect(result.content[0].content?.[1]).toEqual({ type: 'text', text: ' and ' });
63+
expect(result.content[0].content?.[2]).toEqual({
64+
type: 'mention',
65+
attrs: { authorId: '', mentionId: '2', mentionedUserId: '2', mentionedUserName: 'Bob' },
66+
});
67+
});
68+
69+
test('should split newlines into separate paragraphs', () => {
70+
const result = deserializeMentionMarkup('Line 1\nLine 2\nLine 3');
71+
expect(result.content).toHaveLength(3);
72+
expect(result.content[0].content?.[0]).toEqual({ type: 'text', text: 'Line 1' });
73+
expect(result.content[1].content?.[0]).toEqual({ type: 'text', text: 'Line 2' });
74+
expect(result.content[2].content?.[0]).toEqual({ type: 'text', text: 'Line 3' });
75+
});
76+
77+
test('should create empty paragraph for blank line', () => {
78+
const result = deserializeMentionMarkup('Before\n\nAfter');
79+
expect(result.content).toHaveLength(3);
80+
expect(result.content[1]).toEqual({ type: 'paragraph' });
81+
});
82+
83+
test('should handle mention at start of text', () => {
84+
const result = deserializeMentionMarkup('@[99:Jane] please review');
85+
expect(result.content[0].content?.[0]).toEqual({
86+
type: 'mention',
87+
attrs: { authorId: '', mentionId: '99', mentionedUserId: '99', mentionedUserName: 'Jane' },
88+
});
89+
expect(result.content[0].content?.[1]).toEqual({ type: 'text', text: ' please review' });
90+
});
91+
92+
test('should handle mention at end of text', () => {
93+
const result = deserializeMentionMarkup('cc @[50:Sam]');
94+
expect(result.content[0].content?.[0]).toEqual({ type: 'text', text: 'cc ' });
95+
expect(result.content[0].content?.[1]).toEqual({
96+
type: 'mention',
97+
attrs: { authorId: '', mentionId: '50', mentionedUserId: '50', mentionedUserName: 'Sam' },
98+
});
99+
});
100+
});
101+
102+
describe('replyToTextMessage', () => {
103+
const mockReply: Reply = {
104+
created_at: '2026-03-15T10:30:00Z',
105+
created_by: { id: '42', login: 'jdoe@box.com', name: 'Jane Doe', type: 'user' },
106+
id: 'reply-1',
107+
message: 'This is a reply',
108+
parent: { id: 'annotation-1', type: 'annotation' },
109+
type: 'reply',
110+
};
111+
112+
test('should map reply fields to TextMessageType', () => {
113+
const result = replyToTextMessage(mockReply);
114+
115+
expect(result.id).toBe('reply-1');
116+
expect(result.author.id).toBe(42);
117+
expect(result.author.name).toBe('Jane Doe');
118+
expect(result.author.email).toBe('jdoe@box.com');
119+
expect(result.createdAt).toBe(new Date('2026-03-15T10:30:00Z').getTime());
120+
});
121+
122+
test('should deserialize mention markup in message', () => {
123+
const reply: Reply = {
124+
...mockReply,
125+
message: 'Hey @[10:Alice] check this',
126+
};
127+
const result = replyToTextMessage(reply);
128+
129+
expect(result.message.content[0].content).toHaveLength(3);
130+
expect(result.message.content[0].content?.[1]).toMatchObject({
131+
type: 'mention',
132+
attrs: { mentionedUserId: '10', mentionedUserName: 'Alice' },
133+
});
134+
});
135+
136+
test('should set default permissions for replies', () => {
137+
const result = replyToTextMessage(mockReply);
138+
139+
expect(result.permissions).toEqual({
140+
canDelete: false,
141+
canEdit: false,
142+
canReply: true,
143+
canResolve: false,
144+
});
145+
});
146+
});
147+
148+
describe('annotationToMessages', () => {
149+
const baseAnnotation: Annotation = {
150+
created_at: '2026-01-01T00:00:00Z',
151+
created_by: { id: '1', login: 'user@box.com', name: 'User', type: 'user' },
152+
id: 'ann-1',
153+
modified_at: '2026-01-01T00:00:00Z',
154+
modified_by: { id: '1', login: 'user@box.com', name: 'User', type: 'user' },
155+
permissions: { can_delete: true, can_edit: true },
156+
target: { type: 'point', location: { type: TARGET_TYPE.PAGE, value: 1 }, x: 0, y: 0 },
157+
type: 'annotation',
158+
};
159+
160+
test('should return empty array when no description or replies', () => {
161+
const result = annotationToMessages(baseAnnotation);
162+
expect(result).toEqual([]);
163+
});
164+
165+
test('should include description as first message', () => {
166+
const annotation: Annotation = {
167+
...baseAnnotation,
168+
description: {
169+
created_at: '2026-01-01T00:00:00Z',
170+
created_by: { id: '1', login: 'user@box.com', name: 'User', type: 'user' },
171+
id: 'desc-1',
172+
message: 'Root message',
173+
parent: { id: 'ann-1', type: 'annotation' },
174+
type: 'reply',
175+
},
176+
};
177+
const result = annotationToMessages(annotation);
178+
179+
expect(result).toHaveLength(1);
180+
expect(result[0].id).toBe('desc-1');
181+
expect(result[0].permissions.canDelete).toBe(true);
182+
expect(result[0].permissions.canEdit).toBe(true);
183+
});
184+
185+
test('should include description and replies in order', () => {
186+
const annotation: Annotation = {
187+
...baseAnnotation,
188+
description: {
189+
created_at: '2026-01-01T00:00:00Z',
190+
created_by: { id: '1', login: 'user@box.com', name: 'User', type: 'user' },
191+
id: 'desc-1',
192+
message: 'Root',
193+
parent: { id: 'ann-1', type: 'annotation' },
194+
type: 'reply',
195+
},
196+
replies: [
197+
{
198+
created_at: '2026-01-02T00:00:00Z',
199+
created_by: { id: '2', login: 'other@box.com', name: 'Other', type: 'user' },
200+
id: 'reply-1',
201+
message: 'First reply',
202+
parent: { id: 'ann-1', type: 'annotation' },
203+
type: 'reply',
204+
},
205+
],
206+
};
207+
const result = annotationToMessages(annotation);
208+
209+
expect(result).toHaveLength(2);
210+
expect(result[0].id).toBe('desc-1');
211+
expect(result[1].id).toBe('reply-1');
212+
});
213+
214+
test('should fall back to annotation fields when description is sparse', () => {
215+
const annotation: Annotation = {
216+
...baseAnnotation,
217+
description: {
218+
message: 'Sparse description',
219+
} as unknown as Reply,
220+
};
221+
const result = annotationToMessages(annotation);
222+
223+
expect(result).toHaveLength(1);
224+
expect(result[0].id).toBe('ann-1');
225+
expect(result[0].author.name).toBe('User');
226+
expect(result[0].author.email).toBe('user@box.com');
227+
expect(result[0].author.id).toBe(1);
228+
expect(result[0].createdAt).toBe(new Date('2026-01-01T00:00:00Z').getTime());
229+
});
230+
231+
test('should handle description with missing created_by gracefully', () => {
232+
const annotation: Annotation = {
233+
...baseAnnotation,
234+
description: {
235+
id: 'desc-1',
236+
message: 'Test',
237+
created_at: '2026-01-01T00:00:00Z',
238+
} as unknown as Reply,
239+
};
240+
const result = annotationToMessages(annotation);
241+
242+
expect(result).toHaveLength(1);
243+
expect(result[0].author.name).toBe('User');
244+
expect(result[0].author.email).toBe('user@box.com');
245+
});
246+
});
247+
248+
describe('collaboratorToUserContact', () => {
249+
test('should map collaborator with user item', () => {
250+
const collaborator: Collaborator = {
251+
id: 'collab-1',
252+
item: {
253+
avatar_url: 'https://example.com/avatar.png',
254+
email: 'jane@box.com',
255+
id: '42',
256+
login: 'jane@box.com',
257+
name: 'Jane Doe',
258+
type: 'user',
259+
},
260+
name: 'Jane Doe',
261+
};
262+
const result = collaboratorToUserContact(collaborator);
263+
264+
expect(result.email).toBe('jane@box.com');
265+
expect(result.id).toBe(42);
266+
expect(result.name).toBe('Jane Doe');
267+
expect(result.value).toBe('collab-1');
268+
});
269+
270+
test('should fall back to login when email is missing', () => {
271+
const collaborator: Collaborator = {
272+
id: 'collab-2',
273+
item: {
274+
id: '10',
275+
login: 'bob@box.com',
276+
name: 'Bob',
277+
type: 'user',
278+
},
279+
name: 'Bob',
280+
};
281+
const result = collaboratorToUserContact(collaborator);
282+
283+
expect(result.email).toBe('bob@box.com');
284+
});
285+
286+
test('should fall back to collaborator name when item is missing', () => {
287+
const collaborator: Collaborator = {
288+
id: 'collab-3',
289+
name: 'Group Name',
290+
};
291+
const result = collaboratorToUserContact(collaborator);
292+
293+
expect(result.name).toBe('Group Name');
294+
expect(result.email).toBe('');
295+
expect(result.id).toBeNaN();
296+
});
297+
});
298+
});

0 commit comments

Comments
 (0)