From 1d761d7f0e1a26103f54abb6514db36e66b195a6 Mon Sep 17 00:00:00 2001 From: jackiejou <21050234+jackiejou@users.noreply.github.com> Date: Thu, 14 May 2026 10:06:08 -0700 Subject: [PATCH 1/5] chore(deps): Bump @box/threaded-annotations to 1.89.1 Also bumps the @box/* peer ranges (blueprint-web, blueprint-web-assets, collaboration-popover, readable-time, user-selector) to match the new peerDependencies required by @box/threaded-annotations 1.89.1. --- package.json | 24 ++++++++++++------------ yarn.lock | 50 +++++++++++++++++++++++++------------------------- 2 files changed, 37 insertions(+), 37 deletions(-) diff --git a/package.json b/package.json index 94613a459..142f2ea92 100644 --- a/package.json +++ b/package.json @@ -28,13 +28,13 @@ "@babel/preset-react": "^7.25.9", "@babel/preset-typescript": "^7.25.9", "@box/frontend": "^10.0.0", - "@box/blueprint-web": "^14.27.0", - "@box/blueprint-web-assets": "^4.116.6", - "@box/collaboration-popover": "^1.61.30", + "@box/blueprint-web": "^14.30.0", + "@box/blueprint-web-assets": "^4.117.2", + "@box/collaboration-popover": "^1.62.3", "@box/languages": "^1.1.0", - "@box/readable-time": "^1.40.30", - "@box/threaded-annotations": "^1.86.0", - "@box/user-selector": "^1.75.30", + "@box/readable-time": "^1.41.3", + "@box/threaded-annotations": "^1.89.1", + "@box/user-selector": "^1.76.3", "@cfaester/enzyme-adapter-react-18": "^0.8.0", "@commitlint/cli": "^8.3.5", "@commitlint/config-conventional": "^8.2.0", @@ -128,12 +128,12 @@ "worker-farm": "^1.7.0" }, "peerDependencies": { - "@box/blueprint-web": "^14.27.0", - "@box/blueprint-web-assets": "^4.116.6", - "@box/collaboration-popover": "^1.61.30", - "@box/readable-time": "^1.40.30", - "@box/threaded-annotations": "^1.86.0", - "@box/user-selector": "^1.75.30" + "@box/blueprint-web": "^14.30.0", + "@box/blueprint-web-assets": "^4.117.2", + "@box/collaboration-popover": "^1.62.3", + "@box/readable-time": "^1.41.3", + "@box/threaded-annotations": "^1.89.1", + "@box/user-selector": "^1.76.3" }, "scripts": { "build": "yarn setup && yarn build:prod:dist", diff --git a/yarn.lock b/yarn.lock index c6b840b2d..4d935d7a4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1027,19 +1027,19 @@ "@babel/helper-string-parser" "^7.25.9" "@babel/helper-validator-identifier" "^7.25.9" -"@box/blueprint-web-assets@^4.116.6": - version "4.116.6" - resolved "https://registry.yarnpkg.com/@box/blueprint-web-assets/-/blueprint-web-assets-4.116.6.tgz#9475300589e7467dcb76c197b85f2a06c346f018" - integrity sha512-RAA4MEjVGzXgPouyVtu6PSB5p/wvGId3gho2Aw0vPz4pB4pUV71W3gZzu+y+rOAAnayTeodGqjq9jlB2/ET3PQ== +"@box/blueprint-web-assets@^4.117.2": + version "4.117.2" + resolved "https://registry.yarnpkg.com/@box/blueprint-web-assets/-/blueprint-web-assets-4.117.2.tgz#f3793b0ca111baa67eec5f6d1d12e7db8b505c75" + integrity sha512-9PbdGk8R6qkhdcYSRRtTuynSKPfOT0MxEPP256u2IWYv/y4Nmp5HB9dyGJKbOLKLSJsf88Fo3tfHKEYeFZKKfg== -"@box/blueprint-web@^14.27.0": - version "14.27.0" - resolved "https://registry.yarnpkg.com/@box/blueprint-web/-/blueprint-web-14.27.0.tgz#a93f91dcca9c66c6b5372af887bbbd715607ae16" - integrity sha512-DMZJL/DVG4YFJ8hnEjmLCY3fOg2b/WQ+O5pmAmBpguYJuPOv/6Ce8mpw9IfhmupZDgQGssx1lUdgJPdsVpniXg== +"@box/blueprint-web@^14.30.0": + version "14.30.0" + resolved "https://registry.yarnpkg.com/@box/blueprint-web/-/blueprint-web-14.30.0.tgz#c0dac3b29e6dcd3b6a827387eadea78113639bef" + integrity sha512-x0gJPInCmPxPn/hvdKcH0+3i/SDIC97hhnnnXKthryq3VTgWDz0alexcsPvfubmLlszmMuaIuVeihekYpwFlHg== dependencies: "@ariakit/react" "0.4.21" "@ariakit/react-core" "0.4.21" - "@box/blueprint-web-assets" "^4.116.6" + "@box/blueprint-web-assets" "^4.117.2" "@internationalized/date" "^3.12.0" "@radix-ui/react-accordion" "1.1.2" "@radix-ui/react-checkbox" "1.0.4" @@ -1068,10 +1068,10 @@ react-aria-components "1.16.0" type-fest "^3.2.0" -"@box/collaboration-popover@^1.61.30": - version "1.61.30" - resolved "https://registry.yarnpkg.com/@box/collaboration-popover/-/collaboration-popover-1.61.30.tgz#269a82fabd9b574e673f4cbf408b003e62e1dfa7" - integrity sha512-vdbJCDEOhy8OstSAbDG8DDxRyYDtc8cAOr0WKV74hrK0QPHIEy3nY68Gah9iyvDe3iIJQ7WTo8JkacBRoONKvw== +"@box/collaboration-popover@^1.62.3": + version "1.62.3" + resolved "https://registry.yarnpkg.com/@box/collaboration-popover/-/collaboration-popover-1.62.3.tgz#b1cea34df5df771373af57ad0a27dc85a830a3e6" + integrity sha512-eu71Hra6p4QPwaVjQbvR36oLbyukIGSt+nEVvSQzM3pnL+KwD05JJEn2OFnzrPaQUQgUV0bhYzBRFvOADzLS4w== "@box/frontend@^10.0.0": version "10.0.0" @@ -1087,15 +1087,15 @@ resolved "https://registry.yarnpkg.com/@box/languages/-/languages-1.1.0.tgz#58bef2d4166d344aa316919e5dc09c7149ab801f" integrity sha512-nGP1D5mNPwKajbl6i0NERXvHzK56HNjrRnkJlhbVl62IlpgCJtayEpRPWWAbSAC4NFyku03KieavaaXnWalx+A== -"@box/readable-time@^1.40.30": - version "1.40.30" - resolved "https://registry.yarnpkg.com/@box/readable-time/-/readable-time-1.40.30.tgz#dc9a8a4b146532b191b519a2300b21713a6dca6d" - integrity sha512-uvMvH4Bl2e/6jNP/MadvckvWSgHuc3xnZD59fhZtdRYlYgi/e+eaGRHjOYJm0shDoMEDBnUeyHbEZpbkq0yu9g== +"@box/readable-time@^1.41.3": + version "1.41.3" + resolved "https://registry.yarnpkg.com/@box/readable-time/-/readable-time-1.41.3.tgz#423d19e3bb97b08f233a3a1543586dc619b953ff" + integrity sha512-6igs7l8gxXcG8f3fQ+uSl+KE+d3Z+rFU2oyWITw7WoswzQn0w3shezKa27vLT/7Rxa22yxzzkJXUEPZ/j7gIHw== -"@box/threaded-annotations@^1.86.0": - version "1.86.0" - resolved "https://registry.yarnpkg.com/@box/threaded-annotations/-/threaded-annotations-1.86.0.tgz#405e26d2741abfc0862444dea4d792f46cab7e5a" - integrity sha512-0b2xdKCLPfEodG5tLuTe/ErmdHSGWEUR9XDP9DDDrurolRh2eH8lmf/3GznJlFSmuUUUr5xhQTJEos6ZBfB1Nw== +"@box/threaded-annotations@^1.89.1": + version "1.89.1" + resolved "https://registry.yarnpkg.com/@box/threaded-annotations/-/threaded-annotations-1.89.1.tgz#c777560d04150fa4c4039fbeef11edcd59fb2c47" + integrity sha512-vqWhinWEriUjzw1iqVA6/PYN8FI2RBDWaOqIzS5qRgLldr/prwbyjTLGnORvYWPQ0Hsx+1dXjuR2KjCh6pFZBA== dependencies: "@tanstack/react-virtual" "^3.10.8" "@tiptap/core" "2.0.2" @@ -1109,10 +1109,10 @@ "@tiptap/suggestion" "2.0.2" uuid "^9.0.1" -"@box/user-selector@^1.75.30": - version "1.75.30" - resolved "https://registry.yarnpkg.com/@box/user-selector/-/user-selector-1.75.30.tgz#a6e634aa37420cb0d75b18736483c4ff9f2623f8" - integrity sha512-bAfONk/fWvKwxBkDKd2f/j4/T8hW9++9tzv6Xoay9ool0Q4rTeRrJFXpdZ75WUXd+Grt/kQxqS9SZEz+bWKIcA== +"@box/user-selector@^1.76.3": + version "1.76.3" + resolved "https://registry.yarnpkg.com/@box/user-selector/-/user-selector-1.76.3.tgz#9c8d63c00e6be4fb47fe7072b293ace4bd410fca" + integrity sha512-rwLQeF4WqDc2JXptv/ALCe5w1Mw26/8eNssRofqPI400D0KrZXBTmf8ykQ5rk00quNbaKp0GLHT7gSiGIiokFA== "@cfaester/enzyme-adapter-react-18@^0.8.0": version "0.8.0" From 27bef4564cc675ff7c2c7639910d81a97bafc9b2 Mon Sep 17 00:00:00 2001 From: jackiejou <21050234+jackiejou@users.noreply.github.com> Date: Thu, 14 May 2026 13:22:00 -0700 Subject: [PATCH 2/5] feat(threaded-annotations): Wire edit, copy link, reply permissions - PopupV2 now dispatches updateAnnotationAction via ThreadedAnnotationsV2's onEdit prop when the edited id matches the root annotation. Reply edits stay no-op because the corresponding endpoint isn't exposed. - replyToTextMessage reads reply.permissions from the backend payload instead of hardcoding false/true. canEdit stays false on replies to keep the Edit menu item hidden. Reply and description canReply default to false, matching the other permission defaults. - New optional onCopyLink callback on AnnotationsOptions/BaseAnnotator threaded through a React context to PopupV2 and passed to ThreadedAnnotationsV2. When omitted, the thread UI hides the Copy link menu item. --- src/@types/model.ts | 8 ++ src/BoxAnnotations.ts | 1 + .../threadedAnnotationsAdapters-test.ts | 79 +++++++------------ src/adapters/threadedAnnotationsAdapters.ts | 35 ++++---- src/common/AnnotationCallbacksContext.ts | 7 ++ src/common/BaseAnnotator.ts | 10 +++ src/common/BaseManager.ts | 2 + src/common/__tests__/BaseAnnotator-test.ts | 13 +++ src/common/withProviders.tsx | 8 +- src/components/Popups/PopupV2.tsx | 14 ++++ .../Popups/__tests__/PopupV2-test.tsx | 60 ++++++++++++-- src/document/DocumentAnnotator.ts | 1 + .../__tests__/DocumentAnnotator-test.ts | 1 + src/image/ImageAnnotator.ts | 1 + src/image/__tests__/ImageAnnotator-test.ts | 1 + src/media/MediaAnnotator.ts | 1 + src/media/__tests__/MediaAnnotator-test.ts | 1 + src/popup/PopupManager.tsx | 6 +- src/popup/__tests__/PopupManager-test.tsx | 11 +++ 19 files changed, 178 insertions(+), 82 deletions(-) create mode 100644 src/common/AnnotationCallbacksContext.ts diff --git a/src/@types/model.ts b/src/@types/model.ts index 49ca4caa2..1bfaece72 100644 --- a/src/@types/model.ts +++ b/src/@types/model.ts @@ -86,6 +86,13 @@ export interface Rect extends Position { width: number; } +export interface ReplyPermissions { + can_delete?: boolean; + can_edit?: boolean; + can_reply?: boolean; + can_resolve?: boolean; +} + export interface Reply { created_at: string; created_by: User; @@ -95,6 +102,7 @@ export interface Reply { id: string; type: string; }; + permissions?: ReplyPermissions; type: 'reply'; } diff --git a/src/BoxAnnotations.ts b/src/BoxAnnotations.ts index d5770dea9..ccbce3a46 100644 --- a/src/BoxAnnotations.ts +++ b/src/BoxAnnotations.ts @@ -16,6 +16,7 @@ type Annotator = { type AnnotationsOptions = { features: Features; intl: IntlOptions; + onCopyLink?: (id: string) => void; }; export type Features = { diff --git a/src/adapters/__tests__/threadedAnnotationsAdapters-test.ts b/src/adapters/__tests__/threadedAnnotationsAdapters-test.ts index ea0f3de07..08cc0a047 100644 --- a/src/adapters/__tests__/threadedAnnotationsAdapters-test.ts +++ b/src/adapters/__tests__/threadedAnnotationsAdapters-test.ts @@ -133,16 +133,31 @@ describe('threadedAnnotationsAdapters', () => { }); }); - test('should set default permissions for replies', () => { + test('should default permissions to false when reply has no permissions field', () => { const result = replyToTextMessage(mockReply); expect(result.permissions).toEqual({ canDelete: false, canEdit: false, - canReply: true, + canReply: false, canResolve: false, }); }); + + test('should map reply permissions from backend payload, forcing canEdit false', () => { + const reply: Reply = { + ...mockReply, + permissions: { can_delete: true, can_edit: true, can_reply: true, can_resolve: true }, + }; + const result = replyToTextMessage(reply); + + expect(result.permissions).toEqual({ + canDelete: true, + canEdit: false, + canReply: true, + canResolve: true, + }); + }); }); describe('annotationToMessages', () => { @@ -162,22 +177,19 @@ describe('threadedAnnotationsAdapters', () => { expect(result).toEqual([]); }); - test('should include description as first message', () => { + test('should include description as first message using annotation-level author and permissions', () => { const annotation: Annotation = { ...baseAnnotation, - description: { - created_at: '2026-01-01T00:00:00Z', - created_by: { id: '1', login: 'user@box.com', name: 'User', type: 'user' }, - id: 'desc-1', - message: 'Root message', - parent: { id: 'ann-1', type: 'annotation' }, - type: 'reply', - }, + description: { message: 'Root message' } as unknown as Reply, }; const result = annotationToMessages(annotation); expect(result).toHaveLength(1); - expect(result[0].id).toBe('desc-1'); + expect(result[0].id).toBe('ann-1'); + expect(result[0].author.name).toBe('User'); + expect(result[0].author.email).toBe('user@box.com'); + expect(result[0].author.id).toBe(1); + expect(result[0].createdAt).toBe(new Date('2026-01-01T00:00:00Z').getTime()); expect(result[0].permissions.canDelete).toBe(true); expect(result[0].permissions.canEdit).toBe(true); }); @@ -185,14 +197,7 @@ describe('threadedAnnotationsAdapters', () => { test('should include description and replies in order', () => { const annotation: Annotation = { ...baseAnnotation, - description: { - created_at: '2026-01-01T00:00:00Z', - created_by: { id: '1', login: 'user@box.com', name: 'User', type: 'user' }, - id: 'desc-1', - message: 'Root', - parent: { id: 'ann-1', type: 'annotation' }, - type: 'reply', - }, + description: { message: 'Root' } as unknown as Reply, replies: [ { created_at: '2026-01-02T00:00:00Z', @@ -207,42 +212,12 @@ describe('threadedAnnotationsAdapters', () => { const result = annotationToMessages(annotation); expect(result).toHaveLength(2); - expect(result[0].id).toBe('desc-1'); - expect(result[1].id).toBe('reply-1'); - }); - - test('should fall back to annotation fields when description is sparse', () => { - const annotation: Annotation = { - ...baseAnnotation, - description: { - message: 'Sparse description', - } as unknown as Reply, - }; - const result = annotationToMessages(annotation); - - expect(result).toHaveLength(1); expect(result[0].id).toBe('ann-1'); + expect(result[1].id).toBe('reply-1'); expect(result[0].author.name).toBe('User'); - expect(result[0].author.email).toBe('user@box.com'); - expect(result[0].author.id).toBe(1); - expect(result[0].createdAt).toBe(new Date('2026-01-01T00:00:00Z').getTime()); + expect(result[1].author.name).toBe('Other'); }); - test('should handle description with missing created_by gracefully', () => { - const annotation: Annotation = { - ...baseAnnotation, - description: { - id: 'desc-1', - message: 'Test', - created_at: '2026-01-01T00:00:00Z', - } as unknown as Reply, - }; - const result = annotationToMessages(annotation); - - expect(result).toHaveLength(1); - expect(result[0].author.name).toBe('User'); - expect(result[0].author.email).toBe('user@box.com'); - }); }); describe('collaboratorToUserContact', () => { diff --git a/src/adapters/threadedAnnotationsAdapters.ts b/src/adapters/threadedAnnotationsAdapters.ts index 5cd1e7c6b..47e3a0434 100644 --- a/src/adapters/threadedAnnotationsAdapters.ts +++ b/src/adapters/threadedAnnotationsAdapters.ts @@ -82,35 +82,28 @@ export const replyToTextMessage = (reply: Reply): TextMessageTypeV2 => ({ id: reply.id, message: deserializeMentionMarkup(reply.message), permissions: { - canDelete: false, + canDelete: reply.permissions?.can_delete ?? false, canEdit: false, - canReply: true, - canResolve: false, + canReply: reply.permissions?.can_reply ?? false, + canResolve: reply.permissions?.can_resolve ?? false, }, }); -/** - * Converts an Annotation's description (root message) to a TextMessageType. - * The root message gets resolve permission from the annotation's permissions. - * Falls back to annotation-level fields when the description object is sparse - * (the list endpoint returns description with only { message }). - */ -const descriptionToTextMessage = ( - annotation: Annotation, - reply: Reply, -): TextMessageTypeV2 => ({ +// The root message shares the annotation's author and permissions; description +// comes back sparse ({ message } only) from the list endpoint. +const descriptionToTextMessage = (annotation: Annotation): TextMessageTypeV2 => ({ author: { - email: (reply.created_by ?? annotation.created_by)?.login ?? '', - id: parseInt((reply.created_by ?? annotation.created_by)?.id ?? '0', 10), - name: (reply.created_by ?? annotation.created_by)?.name ?? '', + email: annotation.created_by?.login ?? '', + id: parseInt(annotation.created_by?.id ?? '0', 10), + name: annotation.created_by?.name ?? '', }, - createdAt: new Date(reply.created_at ?? annotation.created_at).getTime(), - id: reply.id ?? annotation.id, - message: deserializeMentionMarkup(reply.message), + createdAt: new Date(annotation.created_at).getTime(), + id: annotation.id, + message: deserializeMentionMarkup(annotation.description?.message ?? ''), permissions: { canDelete: annotation.permissions?.can_delete ?? false, canEdit: annotation.permissions?.can_edit ?? false, - canReply: true, + canReply: annotation.permissions?.can_reply ?? false, canResolve: annotation.permissions?.can_resolve ?? false, }, }); @@ -123,7 +116,7 @@ export const annotationToMessages = (annotation: Annotation): TextMessageTypeV2[ const messages: TextMessageTypeV2[] = []; if (annotation.description) { - messages.push(descriptionToTextMessage(annotation, annotation.description)); + messages.push(descriptionToTextMessage(annotation)); } if (annotation.replies) { diff --git a/src/common/AnnotationCallbacksContext.ts b/src/common/AnnotationCallbacksContext.ts new file mode 100644 index 000000000..1e1d2b870 --- /dev/null +++ b/src/common/AnnotationCallbacksContext.ts @@ -0,0 +1,7 @@ +import { createContext } from 'react'; + +export type AnnotationCallbacks = { + onCopyLink?: (id: string) => void; +}; + +export default createContext({}); diff --git a/src/common/BaseAnnotator.ts b/src/common/BaseAnnotator.ts index 4e1a31325..b1db78296 100644 --- a/src/common/BaseAnnotator.ts +++ b/src/common/BaseAnnotator.ts @@ -44,6 +44,12 @@ export type Options = { initialViewMode?: ViewMode; intl: IntlOptions; locale?: string; + /** + * Called when the user clicks Copy link in a thread message's options menu. + * The consumer owns URL construction, clipboard writes, and any user feedback. + * When omitted, the Copy link menu item is hidden. + */ + onCopyLink?: (id: string) => void; token: string; }; @@ -70,6 +76,8 @@ export default class BaseAnnotator extends EventEmitter { intl: IntlShape; + onCopyLink?: (id: string) => void; + store: store.AppStore; constructor({ @@ -82,6 +90,7 @@ export default class BaseAnnotator extends EventEmitter { initialMode, initialViewMode = 'annotations', intl, + onCopyLink, token, }: Options) { super(); @@ -111,6 +120,7 @@ export default class BaseAnnotator extends EventEmitter { this.container = container; this.features = features; this.intl = i18n.createIntlProvider(intl); + this.onCopyLink = onCopyLink; this.store = store.createStore(initialState, { api: new API({ apiHost, token }), }); diff --git a/src/common/BaseManager.ts b/src/common/BaseManager.ts index 9f31669c4..2ffcf02c8 100644 --- a/src/common/BaseManager.ts +++ b/src/common/BaseManager.ts @@ -1,6 +1,7 @@ import * as ReactDOM from 'react-dom/client'; import { IntlShape } from 'react-intl'; import { Store } from 'redux'; +import { AnnotationCallbacks } from './AnnotationCallbacksContext'; import { applyResinTags } from '../utils/resin'; import { TARGET_TYPE } from '../constants'; @@ -14,6 +15,7 @@ export type Options = { }; export type Props = { + callbacks?: AnnotationCallbacks; intl: IntlShape; store: Store; }; diff --git a/src/common/__tests__/BaseAnnotator-test.ts b/src/common/__tests__/BaseAnnotator-test.ts index fcf77949a..408bedd4a 100644 --- a/src/common/__tests__/BaseAnnotator-test.ts +++ b/src/common/__tests__/BaseAnnotator-test.ts @@ -153,6 +153,19 @@ describe('BaseAnnotator', () => { expect(annotator.features).toEqual(features); }); + test('should store onCopyLink callback when provided', () => { + const onCopyLink = jest.fn(); + initAnnotator({ onCopyLink }); + + expect(annotator.onCopyLink).toBe(onCopyLink); + }); + + test('should leave onCopyLink undefined when not provided', () => { + initAnnotator(); + + expect(annotator.onCopyLink).toBeUndefined(); + }); + test('should set initial mode and color', () => { initAnnotator({ initialColor: '#000', initialMode: Mode.REGION }); diff --git a/src/common/withProviders.tsx b/src/common/withProviders.tsx index f59975017..5a78cd972 100644 --- a/src/common/withProviders.tsx +++ b/src/common/withProviders.tsx @@ -2,8 +2,10 @@ import * as React from 'react'; import { IntlShape, RawIntlProvider } from 'react-intl'; import { Provider as StoreProvider } from 'react-redux'; import { Store } from 'redux'; +import AnnotationCallbacksContext, { AnnotationCallbacks } from './AnnotationCallbacksContext'; type WrapperProps = { + callbacks?: AnnotationCallbacks; intl: IntlShape; store: Store; }; @@ -11,11 +13,13 @@ type WrapperProps = { type WrapperReturn

= React.FC

; export default function withProviders

(WrappedComponent: React.ComponentType

): WrapperReturn

{ - return function RootProvider({ intl, store, ...rest }: P & WrapperProps): JSX.Element { + return function RootProvider({ callbacks, intl, store, ...rest }: P & WrapperProps): JSX.Element { return ( - + + + ); diff --git a/src/components/Popups/PopupV2.tsx b/src/components/Popups/PopupV2.tsx index e5c38ab72..cbea1fbaa 100644 --- a/src/components/Popups/PopupV2.tsx +++ b/src/components/Popups/PopupV2.tsx @@ -17,6 +17,7 @@ import type { JSONContent } from '@tiptap/core'; import FocusTrap from 'box-ui-elements/es/components/focus-trap/FocusTrap'; import { annotationToMessages, collaboratorToUserContact } from '../../adapters/threadedAnnotationsAdapters'; +import AnnotationCallbacksContext from '../../common/AnnotationCallbacksContext'; import { createReplyAction, deleteAnnotationAction, @@ -80,6 +81,7 @@ const fetchAvatarBlob = async (apiHost: string, token: string, userId: string): const PopupV2 = ({ annotationId, onSubmit, popupPortalEl, reference }: Props): JSX.Element | null => { const intl = useIntl(); const dispatch = useDispatch(); + const { onCopyLink } = React.useContext(AnnotationCallbacksContext); const popupRef = React.useRef(null); const popperRef = React.useRef(); const optionsRef = React.useRef>(getPopupOptions()); @@ -235,6 +237,16 @@ const PopupV2 = ({ annotationId, onSubmit, popupPortalEl, reference }: Props): J [annotationId, dispatch, onSubmit], ); + const handleEdit = React.useCallback( + async (id: string, content: JSONContent | null): Promise => { + if (!annotationId || id !== annotationId) return; + const doc = createDocumentNode(content); + const { text } = serializeMentionMarkup(doc); + await dispatch(updateAnnotationAction({ annotationId, payload: { message: text } })); + }, + [annotationId, dispatch], + ); + const handleThreadDelete = React.useCallback( async (): Promise => { if (annotationId) { @@ -293,7 +305,9 @@ const PopupV2 = ({ annotationId, onSubmit, popupPortalEl, reference }: Props): J isResolved={isResolved} messages={threadMessages} onAvatarClick={noop} + onCopyLink={onCopyLink} onDelete={noop} + onEdit={handleEdit} onPost={handlePost} onResolve={handleResolve} onThreadDelete={handleThreadDelete} diff --git a/src/components/Popups/__tests__/PopupV2-test.tsx b/src/components/Popups/__tests__/PopupV2-test.tsx index cb070e294..86339b0cf 100644 --- a/src/components/Popups/__tests__/PopupV2-test.tsx +++ b/src/components/Popups/__tests__/PopupV2-test.tsx @@ -1,7 +1,10 @@ import React from 'react'; import { act, render, screen } from '@testing-library/react'; import { useDispatch, useSelector } from 'react-redux'; +import type { ThreadedAnnotationsPropsV2 } from '@box/threaded-annotations'; +import AnnotationCallbacksContext from '../../../common/AnnotationCallbacksContext'; import PopupV2, { Props } from '../PopupV2'; +import { updateAnnotationAction } from '../../../store/annotations/actions'; import { getApiHost, getToken } from '../../../store/options'; jest.mock('react-redux', () => ({ @@ -27,6 +30,8 @@ jest.mock('@box/blueprint-web', () => ({ TooltipProvider: ({ children }: { children: React.ReactNode }) => children, })); +let lastThreadedAnnotationsProps: Partial = {}; + jest.mock('@box/threaded-annotations', () => { const ReactMock = jest.requireActual('react'); return { @@ -37,17 +42,20 @@ jest.mock('@box/threaded-annotations', () => { 'data-testid': 'message-editor-v2', 'data-is-first-annotation': String(props.isFirstAnnotation), }), - ThreadedAnnotationsV2: (props: Record) => - ReactMock.createElement('div', { + ThreadedAnnotationsV2: (props: Partial) => { + lastThreadedAnnotationsProps = props; + return ReactMock.createElement('div', { 'data-testid': 'threaded-annotations-v2', 'data-is-annotations': String(props.isAnnotations), - 'data-messages-count': String((props.messages as unknown[])?.length ?? 0), + 'data-messages-count': String(props.messages?.length ?? 0), + 'data-has-on-edit': String(typeof props.onEdit === 'function'), 'data-has-on-post': String(typeof props.onPost === 'function'), 'data-has-on-resolve': String(typeof props.onResolve === 'function'), 'data-has-on-thread-delete': String(typeof props.onThreadDelete === 'function'), 'data-has-on-unresolve': String(typeof props.onUnresolve === 'function'), - }), - serializeMentionMarkup: jest.fn().mockReturnValue({ hasMention: false, text: '' }), + }); + }, + serializeMentionMarkup: jest.fn().mockReturnValue({ hasMention: false, text: 'serialized text' }), }; }); @@ -119,6 +127,7 @@ describe('PopupV2', () => { }; beforeEach(() => { + lastThreadedAnnotationsProps = {}; mockUseDispatch.mockReturnValue(mockDispatch); mockFetch.mockResolvedValue({ blob: () => Promise.resolve(new Blob(['avatar'])), @@ -214,12 +223,53 @@ describe('PopupV2', () => { await flushPromises(); const thread = screen.getByTestId('threaded-annotations-v2'); + expect(thread.getAttribute('data-has-on-edit')).toBe('true'); expect(thread.getAttribute('data-has-on-post')).toBe('true'); expect(thread.getAttribute('data-has-on-resolve')).toBe('true'); expect(thread.getAttribute('data-has-on-thread-delete')).toBe('true'); expect(thread.getAttribute('data-has-on-unresolve')).toBe('true'); }); + test('should dispatch updateAnnotationAction when editing the root message', async () => { + render(); + await flushPromises(); + + await lastThreadedAnnotationsProps.onEdit?.('annotation-1', { type: 'doc', content: [] }); + + expect(updateAnnotationAction).toHaveBeenCalledWith({ + annotationId: 'annotation-1', + payload: { message: 'serialized text' }, + }); + }); + + test('should not dispatch updateAnnotationAction when editing a reply', async () => { + render(); + await flushPromises(); + + await lastThreadedAnnotationsProps.onEdit?.('reply-1', { type: 'doc', content: [] }); + + expect(updateAnnotationAction).not.toHaveBeenCalled(); + }); + + test('should forward onCopyLink from AnnotationCallbacksContext to ThreadedAnnotationsV2', async () => { + const onCopyLink = jest.fn(); + render( + + + , + ); + await flushPromises(); + + expect(lastThreadedAnnotationsProps.onCopyLink).toBe(onCopyLink); + }); + + test('should leave onCopyLink undefined when no context value is provided', async () => { + render(); + await flushPromises(); + + expect(lastThreadedAnnotationsProps.onCopyLink).toBeUndefined(); + }); + test('should set popupThreadV2 as resin component', async () => { render(); await flushPromises(); diff --git a/src/document/DocumentAnnotator.ts b/src/document/DocumentAnnotator.ts index 5402a2259..5e96fd68c 100644 --- a/src/document/DocumentAnnotator.ts +++ b/src/document/DocumentAnnotator.ts @@ -224,6 +224,7 @@ export default class DocumentAnnotator extends BaseAnnotator { } manager.render({ + callbacks: { onCopyLink: this.onCopyLink }, intl: this.intl, store: this.store, }); diff --git a/src/document/__tests__/DocumentAnnotator-test.ts b/src/document/__tests__/DocumentAnnotator-test.ts index e3830893e..0099ee69e 100644 --- a/src/document/__tests__/DocumentAnnotator-test.ts +++ b/src/document/__tests__/DocumentAnnotator-test.ts @@ -327,6 +327,7 @@ describe('DocumentAnnotator', () => { expect(annotator.getPageManagers).toHaveBeenCalledWith(pageEl); expect(annotator.getPageNumber).toHaveBeenCalledWith(pageEl); expect(mockManager.render).toHaveBeenCalledWith({ + callbacks: { onCopyLink: undefined }, intl: annotator.intl, store: expect.any(Object), }); diff --git a/src/image/ImageAnnotator.ts b/src/image/ImageAnnotator.ts index 12d785655..a8e60b624 100644 --- a/src/image/ImageAnnotator.ts +++ b/src/image/ImageAnnotator.ts @@ -124,6 +124,7 @@ export default class ImageAnnotator extends BaseAnnotator { }); manager.render({ + callbacks: { onCopyLink: this.onCopyLink }, intl: this.intl, store: this.store, }); diff --git a/src/image/__tests__/ImageAnnotator-test.ts b/src/image/__tests__/ImageAnnotator-test.ts index 94f23d1e9..dacf87948 100644 --- a/src/image/__tests__/ImageAnnotator-test.ts +++ b/src/image/__tests__/ImageAnnotator-test.ts @@ -202,6 +202,7 @@ describe('ImageAnnotator', () => { expect(annotator.getManagers).toHaveBeenCalled(); expect(mockManager.render).toHaveBeenCalledWith({ + callbacks: { onCopyLink: undefined }, intl: annotator.intl, store: expect.any(Object), }); diff --git a/src/media/MediaAnnotator.ts b/src/media/MediaAnnotator.ts index 7d74c98ac..60207a330 100644 --- a/src/media/MediaAnnotator.ts +++ b/src/media/MediaAnnotator.ts @@ -108,6 +108,7 @@ export default class MediaAnnotator extends BaseAnnotator { width: `${referenceEl.offsetWidth}px`, }); manager.render({ + callbacks: { onCopyLink: this.onCopyLink }, intl: this.intl, store: this.store, }); diff --git a/src/media/__tests__/MediaAnnotator-test.ts b/src/media/__tests__/MediaAnnotator-test.ts index dda9c18cd..4c34484a8 100644 --- a/src/media/__tests__/MediaAnnotator-test.ts +++ b/src/media/__tests__/MediaAnnotator-test.ts @@ -114,6 +114,7 @@ describe('MediaAnnotator', () => { expect(annotator.getManagers).toHaveBeenCalled(); expect(mockManager.render).toHaveBeenCalledWith({ + callbacks: { onCopyLink: undefined }, intl: annotator.intl, store: expect.any(Object), }); diff --git a/src/popup/PopupManager.tsx b/src/popup/PopupManager.tsx index 322c739af..09ba4e3f2 100644 --- a/src/popup/PopupManager.tsx +++ b/src/popup/PopupManager.tsx @@ -20,16 +20,18 @@ export default class PopupManager extends BaseManager { this.reactEl.dataset.testid = 'ba-Layer--popup'; } - render(props: Props): void { + render({ callbacks, intl, store }: Props): void { if (!this.root) { this.root = ReactDOM.createRoot(this.reactEl); } this.root.render( , ); diff --git a/src/popup/__tests__/PopupManager-test.tsx b/src/popup/__tests__/PopupManager-test.tsx index 29048b58f..3933a0766 100644 --- a/src/popup/__tests__/PopupManager-test.tsx +++ b/src/popup/__tests__/PopupManager-test.tsx @@ -67,5 +67,16 @@ describe('PopupManager', () => { expect(root.render).toHaveBeenCalled(); }); + + test('should forward callbacks to PopupContainer', () => { + const wrapper = getWrapper(); + const root = createRoot(rootEl); + const onCopyLink = jest.fn(); + + wrapper.render({ callbacks: { onCopyLink }, intl, store: createStore() }); + + const element = (root.render as jest.Mock).mock.calls[0][0]; + expect(element.props.callbacks).toEqual({ onCopyLink }); + }); }); }); From bb65a0210cc0554b8ca78b86005eba3d449f3e67 Mon Sep 17 00:00:00 2001 From: jackiejou <21050234+jackiejou@users.noreply.github.com> Date: Thu, 14 May 2026 13:30:47 -0700 Subject: [PATCH 3/5] chore(threaded-annotations): Remove redundant onCopyLink JSDoc --- src/common/BaseAnnotator.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/common/BaseAnnotator.ts b/src/common/BaseAnnotator.ts index b1db78296..9c5c916ea 100644 --- a/src/common/BaseAnnotator.ts +++ b/src/common/BaseAnnotator.ts @@ -44,11 +44,6 @@ export type Options = { initialViewMode?: ViewMode; intl: IntlOptions; locale?: string; - /** - * Called when the user clicks Copy link in a thread message's options menu. - * The consumer owns URL construction, clipboard writes, and any user feedback. - * When omitted, the Copy link menu item is hidden. - */ onCopyLink?: (id: string) => void; token: string; }; From 9dca0eabade46e9a5b29600909223193c0fb1ace Mon Sep 17 00:00:00 2001 From: jackiejou <21050234+jackiejou@users.noreply.github.com> Date: Thu, 14 May 2026 15:42:56 -0700 Subject: [PATCH 4/5] feat(threaded-annotations): Pass fileVersionId to onCopyLink callback Threaded-annotations renders Copy link on every message and passes the clicked message id. PopupV2 now wraps the consumer callback so any click forwards the root annotation id together with the file version id read from store options. Lets the consumer build a versioned deep link without any branching. --- src/BoxAnnotations.ts | 2 +- src/common/AnnotationCallbacksContext.ts | 2 +- src/common/BaseAnnotator.ts | 4 ++-- src/components/Popups/PopupV2.tsx | 12 ++++++++++-- src/components/Popups/__tests__/PopupV2-test.tsx | 9 ++++++--- 5 files changed, 20 insertions(+), 9 deletions(-) diff --git a/src/BoxAnnotations.ts b/src/BoxAnnotations.ts index ccbce3a46..236e546be 100644 --- a/src/BoxAnnotations.ts +++ b/src/BoxAnnotations.ts @@ -16,7 +16,7 @@ type Annotator = { type AnnotationsOptions = { features: Features; intl: IntlOptions; - onCopyLink?: (id: string) => void; + onCopyLink?: (annotationId: string, fileVersionId: string) => void; }; export type Features = { diff --git a/src/common/AnnotationCallbacksContext.ts b/src/common/AnnotationCallbacksContext.ts index 1e1d2b870..d6eb0c2b0 100644 --- a/src/common/AnnotationCallbacksContext.ts +++ b/src/common/AnnotationCallbacksContext.ts @@ -1,7 +1,7 @@ import { createContext } from 'react'; export type AnnotationCallbacks = { - onCopyLink?: (id: string) => void; + onCopyLink?: (annotationId: string, fileVersionId: string) => void; }; export default createContext({}); diff --git a/src/common/BaseAnnotator.ts b/src/common/BaseAnnotator.ts index 9c5c916ea..49d35cb8e 100644 --- a/src/common/BaseAnnotator.ts +++ b/src/common/BaseAnnotator.ts @@ -44,7 +44,7 @@ export type Options = { initialViewMode?: ViewMode; intl: IntlOptions; locale?: string; - onCopyLink?: (id: string) => void; + onCopyLink?: (annotationId: string, fileVersionId: string) => void; token: string; }; @@ -71,7 +71,7 @@ export default class BaseAnnotator extends EventEmitter { intl: IntlShape; - onCopyLink?: (id: string) => void; + onCopyLink?: (annotationId: string, fileVersionId: string) => void; store: store.AppStore; diff --git a/src/components/Popups/PopupV2.tsx b/src/components/Popups/PopupV2.tsx index cbea1fbaa..9de9641c9 100644 --- a/src/components/Popups/PopupV2.tsx +++ b/src/components/Popups/PopupV2.tsx @@ -25,7 +25,7 @@ import { updateAnnotationAction, } from '../../store/annotations/actions'; import { getAnnotation } from '../../store/annotations/selectors'; -import { getApiHost, getToken } from '../../store/options'; +import { getApiHost, getFileVersionId, getToken } from '../../store/options'; import { fetchCollaboratorsAction } from '../../store/users/actions'; import type { AppState, AppThunkDispatch } from '../../store/types'; @@ -81,13 +81,21 @@ const fetchAvatarBlob = async (apiHost: string, token: string, userId: string): const PopupV2 = ({ annotationId, onSubmit, popupPortalEl, reference }: Props): JSX.Element | null => { const intl = useIntl(); const dispatch = useDispatch(); - const { onCopyLink } = React.useContext(AnnotationCallbacksContext); + const { onCopyLink: consumerOnCopyLink } = React.useContext(AnnotationCallbacksContext); const popupRef = React.useRef(null); const popperRef = React.useRef(); const optionsRef = React.useRef>(getPopupOptions()); const apiHost = useSelector(getApiHost); + const fileVersionId = useSelector(getFileVersionId); const token = useSelector(getToken); + const onCopyLink = React.useMemo( + () => + consumerOnCopyLink && annotationId && fileVersionId + ? () => consumerOnCopyLink(annotationId, fileVersionId) + : undefined, + [consumerOnCopyLink, annotationId, fileVersionId], + ); const annotation = useSelector((state: AppState) => annotationId ? getAnnotation(state, annotationId) : undefined, ); diff --git a/src/components/Popups/__tests__/PopupV2-test.tsx b/src/components/Popups/__tests__/PopupV2-test.tsx index 86339b0cf..6792dfc76 100644 --- a/src/components/Popups/__tests__/PopupV2-test.tsx +++ b/src/components/Popups/__tests__/PopupV2-test.tsx @@ -5,7 +5,7 @@ import type { ThreadedAnnotationsPropsV2 } from '@box/threaded-annotations'; import AnnotationCallbacksContext from '../../../common/AnnotationCallbacksContext'; import PopupV2, { Props } from '../PopupV2'; import { updateAnnotationAction } from '../../../store/annotations/actions'; -import { getApiHost, getToken } from '../../../store/options'; +import { getApiHost, getFileVersionId, getToken } from '../../../store/options'; jest.mock('react-redux', () => ({ useDispatch: jest.fn(), @@ -80,6 +80,7 @@ const mockUseSelector = useSelector as jest.MockedFunction; const mockSelectorValues = (annotation?: unknown): void => { mockUseSelector.mockImplementation(selector => { if (selector === getApiHost) return 'https://api.box.com'; + if (selector === getFileVersionId) return 'fv-1'; if (selector === getToken) return 'test-token'; return annotation; }); @@ -251,7 +252,7 @@ describe('PopupV2', () => { expect(updateAnnotationAction).not.toHaveBeenCalled(); }); - test('should forward onCopyLink from AnnotationCallbacksContext to ThreadedAnnotationsV2', async () => { + test('should invoke context onCopyLink with the root annotationId and fileVersionId regardless of clicked message id', async () => { const onCopyLink = jest.fn(); render( @@ -260,7 +261,9 @@ describe('PopupV2', () => { ); await flushPromises(); - expect(lastThreadedAnnotationsProps.onCopyLink).toBe(onCopyLink); + (lastThreadedAnnotationsProps.onCopyLink as (id: string) => void)('reply-1'); + + expect(onCopyLink).toHaveBeenCalledWith('annotation-1', 'fv-1'); }); test('should leave onCopyLink undefined when no context value is provided', async () => { From 5b99d0ddb333d7aa20624fb893713a25994ba2d5 Mon Sep 17 00:00:00 2001 From: jackiejou <21050234+jackiejou@users.noreply.github.com> Date: Thu, 14 May 2026 16:04:10 -0700 Subject: [PATCH 5/5] refactor(threaded-annotations): Use object arg for onCopyLink callback Two same-typed positional strings let consumers transpose annotationId and fileVersionId without a compile-time signal. Switching to an object arg removes the swap risk and lets future fields land additively. Also adds a test covering the path where fileVersionId is absent so the Copy link entry stays hidden. --- src/BoxAnnotations.ts | 2 +- src/common/AnnotationCallbacksContext.ts | 2 +- src/common/BaseAnnotator.ts | 4 ++-- src/components/Popups/PopupV2.tsx | 2 +- .../Popups/__tests__/PopupV2-test.tsx | 20 ++++++++++++++++++- 5 files changed, 24 insertions(+), 6 deletions(-) diff --git a/src/BoxAnnotations.ts b/src/BoxAnnotations.ts index 236e546be..0cfe8e792 100644 --- a/src/BoxAnnotations.ts +++ b/src/BoxAnnotations.ts @@ -16,7 +16,7 @@ type Annotator = { type AnnotationsOptions = { features: Features; intl: IntlOptions; - onCopyLink?: (annotationId: string, fileVersionId: string) => void; + onCopyLink?: (params: { annotationId: string; fileVersionId: string }) => void; }; export type Features = { diff --git a/src/common/AnnotationCallbacksContext.ts b/src/common/AnnotationCallbacksContext.ts index d6eb0c2b0..637c2a2e8 100644 --- a/src/common/AnnotationCallbacksContext.ts +++ b/src/common/AnnotationCallbacksContext.ts @@ -1,7 +1,7 @@ import { createContext } from 'react'; export type AnnotationCallbacks = { - onCopyLink?: (annotationId: string, fileVersionId: string) => void; + onCopyLink?: (params: { annotationId: string; fileVersionId: string }) => void; }; export default createContext({}); diff --git a/src/common/BaseAnnotator.ts b/src/common/BaseAnnotator.ts index 49d35cb8e..f5e74fb1f 100644 --- a/src/common/BaseAnnotator.ts +++ b/src/common/BaseAnnotator.ts @@ -44,7 +44,7 @@ export type Options = { initialViewMode?: ViewMode; intl: IntlOptions; locale?: string; - onCopyLink?: (annotationId: string, fileVersionId: string) => void; + onCopyLink?: (params: { annotationId: string; fileVersionId: string }) => void; token: string; }; @@ -71,7 +71,7 @@ export default class BaseAnnotator extends EventEmitter { intl: IntlShape; - onCopyLink?: (annotationId: string, fileVersionId: string) => void; + onCopyLink?: (params: { annotationId: string; fileVersionId: string }) => void; store: store.AppStore; diff --git a/src/components/Popups/PopupV2.tsx b/src/components/Popups/PopupV2.tsx index 9de9641c9..81b3a3d31 100644 --- a/src/components/Popups/PopupV2.tsx +++ b/src/components/Popups/PopupV2.tsx @@ -92,7 +92,7 @@ const PopupV2 = ({ annotationId, onSubmit, popupPortalEl, reference }: Props): J const onCopyLink = React.useMemo( () => consumerOnCopyLink && annotationId && fileVersionId - ? () => consumerOnCopyLink(annotationId, fileVersionId) + ? () => consumerOnCopyLink({ annotationId, fileVersionId }) : undefined, [consumerOnCopyLink, annotationId, fileVersionId], ); diff --git a/src/components/Popups/__tests__/PopupV2-test.tsx b/src/components/Popups/__tests__/PopupV2-test.tsx index 6792dfc76..57e19eab2 100644 --- a/src/components/Popups/__tests__/PopupV2-test.tsx +++ b/src/components/Popups/__tests__/PopupV2-test.tsx @@ -263,7 +263,25 @@ describe('PopupV2', () => { (lastThreadedAnnotationsProps.onCopyLink as (id: string) => void)('reply-1'); - expect(onCopyLink).toHaveBeenCalledWith('annotation-1', 'fv-1'); + expect(onCopyLink).toHaveBeenCalledWith({ annotationId: 'annotation-1', fileVersionId: 'fv-1' }); + }); + + test('should leave onCopyLink undefined when fileVersionId is missing from the store', async () => { + const onCopyLink = jest.fn(); + mockUseSelector.mockImplementation(selector => { + if (selector === getApiHost) return 'https://api.box.com'; + if (selector === getFileVersionId) return null; + if (selector === getToken) return 'test-token'; + return mockAnnotation; + }); + render( + + + , + ); + await flushPromises(); + + expect(lastThreadedAnnotationsProps.onCopyLink).toBeUndefined(); }); test('should leave onCopyLink undefined when no context value is provided', async () => {