From 8e5954d347506a9818e45e5a78243659b67ac778 Mon Sep 17 00:00:00 2001 From: Jose Gaston Date: Thu, 27 Aug 2026 21:02:47 -0700 Subject: [PATCH 1/3] feat(popup-v2): gate rich text on feature flag Hosts pass isRichTextEnabled so the popup can share markdown authoring with sidebar comments without changing the default plain-text mention path. --- src/BoxAnnotations.ts | 1 + .../threadedAnnotationsAdapters-test.ts | 52 ++++++++ src/adapters/threadedAnnotationsAdapters.ts | 18 +-- src/components/Popups/PopupV2.tsx | 25 ++-- .../Popups/__tests__/PopupV2-test.tsx | 116 +++++++++++++++++- src/store/options/__tests__/selectors-test.ts | 13 ++ src/store/options/selectors.ts | 1 + 7 files changed, 206 insertions(+), 20 deletions(-) diff --git a/src/BoxAnnotations.ts b/src/BoxAnnotations.ts index 0cfe8e792..4053a310d 100644 --- a/src/BoxAnnotations.ts +++ b/src/BoxAnnotations.ts @@ -20,6 +20,7 @@ type AnnotationsOptions = { }; export type Features = { + isRichTextEnabled?: boolean; isThreadedAnnotation?: boolean; [key: string]: boolean | undefined; }; diff --git a/src/adapters/__tests__/threadedAnnotationsAdapters-test.ts b/src/adapters/__tests__/threadedAnnotationsAdapters-test.ts index 7811d7602..55abfff82 100644 --- a/src/adapters/__tests__/threadedAnnotationsAdapters-test.ts +++ b/src/adapters/__tests__/threadedAnnotationsAdapters-test.ts @@ -1,3 +1,5 @@ +import { parseMessageMarkdown } from '@box/threaded-annotations'; + import { annotationToMessages, collaboratorToUserContact, @@ -7,6 +9,12 @@ import { import type { Annotation, Collaborator, Reply } from '../../@types'; import { TARGET_TYPE } from '../../constants'; +jest.mock('@box/threaded-annotations', () => ({ + parseMessageMarkdown: jest.fn((text: string | null | undefined) => + text ? { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text }] }] } : { type: 'doc', content: [] }, + ), +})); + describe('threadedAnnotationsAdapters', () => { describe('deserializeMentionMarkup', () => { test('should return empty doc for empty string', () => { @@ -109,6 +117,10 @@ describe('threadedAnnotationsAdapters', () => { type: 'reply', }; + beforeEach(() => { + (parseMessageMarkdown as jest.Mock).mockClear(); + }); + test('should map reply fields to TextMessageType', () => { const result = replyToTextMessage(mockReply); @@ -126,6 +138,7 @@ describe('threadedAnnotationsAdapters', () => { }; const result = replyToTextMessage(reply); + expect(parseMessageMarkdown).not.toHaveBeenCalled(); expect(result.message.content[0].content).toHaveLength(3); expect(result.message.content[0].content?.[1]).toMatchObject({ type: 'mention', @@ -174,6 +187,18 @@ describe('threadedAnnotationsAdapters', () => { }); }); + test('should parse markdown when isRichTextEnabled is true', () => { + const reply: Reply = { ...mockReply, message: '**bold**' }; + + const result = replyToTextMessage(reply, true); + + expect(parseMessageMarkdown).toHaveBeenCalledWith('**bold**'); + expect(result.message).toEqual({ + type: 'doc', + content: [{ type: 'paragraph', content: [{ type: 'text', text: '**bold**' }] }], + }); + }); + test('should leave updatedAt undefined when reply has no modified_at', () => { const result = replyToTextMessage(mockReply); @@ -207,6 +232,10 @@ describe('threadedAnnotationsAdapters', () => { type: 'annotation', }; + beforeEach(() => { + (parseMessageMarkdown as jest.Mock).mockClear(); + }); + test('should return empty array when no description or replies', () => { const result = annotationToMessages(baseAnnotation); expect(result).toEqual([]); @@ -268,6 +297,29 @@ describe('threadedAnnotationsAdapters', () => { expect(result[0].author.name).toBe('User'); expect(result[1].author.name).toBe('Other'); }); + + test('should parse description and replies with parseMessageMarkdown when enabled', () => { + const annotation: Annotation = { + ...baseAnnotation, + description: { message: 'Root' } as unknown as Reply, + replies: [ + { + created_at: '2026-01-02T00:00:00Z', + created_by: { id: '2', login: 'other@box.com', name: 'Other', type: 'user' }, + id: 'reply-1', + message: 'First reply', + parent: { id: 'ann-1', type: 'annotation' }, + type: 'reply', + }, + ], + }; + + annotationToMessages(annotation, true); + + expect(parseMessageMarkdown).toHaveBeenCalledWith('Root'); + expect(parseMessageMarkdown).toHaveBeenCalledWith('First reply'); + expect(parseMessageMarkdown).toHaveBeenCalledTimes(2); + }); }); describe('collaboratorToUserContact', () => { diff --git a/src/adapters/threadedAnnotationsAdapters.ts b/src/adapters/threadedAnnotationsAdapters.ts index 7157fcb08..8be7f287e 100644 --- a/src/adapters/threadedAnnotationsAdapters.ts +++ b/src/adapters/threadedAnnotationsAdapters.ts @@ -1,4 +1,5 @@ import type { DocumentNodeV2, MentionNodeV2, ParagraphNodeV2, TextMessageTypeV2, TextNodeV2 } from '@box/threaded-annotations'; +import { parseMessageMarkdown } from '@box/threaded-annotations'; import type { Annotation, Collaborator, Reply, UserMini } from '../@types'; @@ -69,6 +70,9 @@ export const deserializeMentionMarkup = (text: string): DocumentNodeV2 => { return { type: 'doc', content }; }; +const toDocumentNode = (text: string, isRichTextEnabled = false): DocumentNodeV2 => + isRichTextEnabled ? (parseMessageMarkdown(text) as DocumentNodeV2) : deserializeMentionMarkup(text); + /** * Returns the edit timestamp consumers use to render an edited indicator. * Compares parsed instants, not raw strings, so equivalent ISO formats @@ -86,7 +90,7 @@ const toUpdatedAt = (createdAt: string, modifiedAt: string | undefined): number /** * Converts a box-annotations Reply to a threaded-annotations TextMessageType. */ -export const replyToTextMessage = (reply: Reply): TextMessageTypeV2 => ({ +export const replyToTextMessage = (reply: Reply, isRichTextEnabled = false): TextMessageTypeV2 => ({ author: { email: reply.created_by?.login ?? '', id: parseInt(reply.created_by?.id ?? '0', 10), @@ -94,7 +98,7 @@ export const replyToTextMessage = (reply: Reply): TextMessageTypeV2 => ({ }, createdAt: new Date(reply.created_at).getTime(), id: reply.id, - message: deserializeMentionMarkup(reply.message), + message: toDocumentNode(reply.message, isRichTextEnabled), permissions: { canDelete: reply.permissions?.can_delete ?? false, canEdit: reply.permissions?.can_edit ?? false, @@ -106,7 +110,7 @@ export const replyToTextMessage = (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 => ({ +const descriptionToTextMessage = (annotation: Annotation, isRichTextEnabled = false): TextMessageTypeV2 => ({ author: { email: annotation.created_by?.login ?? '', id: parseInt(annotation.created_by?.id ?? '0', 10), @@ -114,7 +118,7 @@ const descriptionToTextMessage = (annotation: Annotation): TextMessageTypeV2 => }, createdAt: new Date(annotation.created_at).getTime(), id: annotation.id, - message: deserializeMentionMarkup(annotation.description?.message ?? ''), + message: toDocumentNode(annotation.description?.message ?? '', isRichTextEnabled), permissions: { canDelete: annotation.permissions?.can_delete ?? false, canEdit: annotation.permissions?.can_edit ?? false, @@ -128,16 +132,16 @@ const descriptionToTextMessage = (annotation: Annotation): TextMessageTypeV2 => * Maps a full Annotation (description + replies) to an array of TextMessageType * suitable for ThreadedAnnotationsV2. */ -export const annotationToMessages = (annotation: Annotation): TextMessageTypeV2[] => { +export const annotationToMessages = (annotation: Annotation, isRichTextEnabled = false): TextMessageTypeV2[] => { const messages: TextMessageTypeV2[] = []; if (annotation.description) { - messages.push(descriptionToTextMessage(annotation)); + messages.push(descriptionToTextMessage(annotation, isRichTextEnabled)); } if (annotation.replies) { annotation.replies.forEach(reply => { - messages.push(replyToTextMessage(reply)); + messages.push(replyToTextMessage(reply, isRichTextEnabled)); }); } diff --git a/src/components/Popups/PopupV2.tsx b/src/components/Popups/PopupV2.tsx index cf182233f..04990917c 100644 --- a/src/components/Popups/PopupV2.tsx +++ b/src/components/Popups/PopupV2.tsx @@ -10,6 +10,7 @@ import { MessageEditorV2, ThreadedAnnotationsV2, serializeMentionMarkup, + serializeMessageToMarkdown, } from '@box/threaded-annotations'; import type { DocumentNodeV2, TextMessageTypeV2 } from '@box/threaded-annotations'; import type { FetchedAvatarUrls, UserContactType } from '@box/user-selector'; @@ -27,7 +28,7 @@ import { updateReplyAction, } from '../../store/annotations/actions'; import { getAnnotation } from '../../store/annotations/selectors'; -import { getApiHost, getFileId, getFileVersionId, getToken } from '../../store/options'; +import { getApiHost, getFileId, getFileVersionId, getIsRichTextEnabled, getToken } from '../../store/options'; import { fetchCollaboratorsAction } from '../../store/users/actions'; import type { Token, TokenLiteral, TokenMap } from '../../@types'; @@ -66,6 +67,9 @@ const createDocumentNode = (content: JSONContent | null): DocumentNodeV2 => { return { type: 'doc', content: [content] } as DocumentNodeV2; }; +const serializePopupMessage = (doc: DocumentNodeV2, isRichTextEnabled: boolean): string => + isRichTextEnabled ? serializeMessageToMarkdown(doc) : serializeMentionMarkup(doc).text; + const literalToString = (literal: TokenLiteral): string | null => { if (!literal) return null; if (typeof literal === 'string') return literal; @@ -112,6 +116,7 @@ const PopupV2 = ({ annotationId, onSubmit, popupPortalEl, reference }: Props): J const apiHost = useSelector(getApiHost); const fileId = useSelector(getFileId); const fileVersionId = useSelector(getFileVersionId); + const isRichTextEnabled = useSelector(getIsRichTextEnabled); const token = useSelector(getToken); const onCopyLink = React.useMemo( () => @@ -187,7 +192,7 @@ const PopupV2 = ({ annotationId, onSubmit, popupPortalEl, reference }: Props): J if (!annotation) return undefined; const userIds = Array.from( - new Set(annotationToMessages(annotation).map(msg => String(msg.author.id))), + new Set(annotationToMessages(annotation, isRichTextEnabled).map(msg => String(msg.author.id))), ); let cancelled = false; @@ -205,18 +210,18 @@ const PopupV2 = ({ annotationId, onSubmit, popupPortalEl, reference }: Props): J return () => { cancelled = true; }; - }, [annotation, getOrFetchAvatarBlob]); + }, [annotation, getOrFetchAvatarBlob, isRichTextEnabled]); const threadMessages: TextMessageTypeV2[] = React.useMemo(() => { if (!annotation) return []; - return annotationToMessages(annotation).map(msg => ({ + return annotationToMessages(annotation, isRichTextEnabled).map(msg => ({ ...msg, author: { ...msg.author, avatarUrl: avatarBlobs[String(msg.author.id)], }, })); - }, [annotation, avatarBlobs]); + }, [annotation, avatarBlobs, isRichTextEnabled]); const isResolved = annotation?.status === 'resolved'; const resolvedBy = isResolved @@ -259,28 +264,28 @@ const PopupV2 = ({ annotationId, onSubmit, popupPortalEl, reference }: Props): J const handlePost = React.useCallback( async (content: JSONContent | null): Promise => { const doc = createDocumentNode(content); - const { text } = serializeMentionMarkup(doc); + const text = serializePopupMessage(doc, isRichTextEnabled); if (annotationId) { await dispatch(createReplyAction({ annotationId, message: text })); } else { onSubmit(text); } }, - [annotationId, dispatch, onSubmit], + [annotationId, dispatch, isRichTextEnabled, onSubmit], ); const handleEdit = React.useCallback( async (id: string, content: JSONContent | null): Promise => { if (!annotationId) return; const doc = createDocumentNode(content); - const { text } = serializeMentionMarkup(doc); + const text = serializePopupMessage(doc, isRichTextEnabled); if (id === annotationId) { await dispatch(updateAnnotationAction({ annotationId, payload: { message: text } })); return; } await dispatch(updateReplyAction({ annotationId, replyId: id, payload: { message: text } })); }, - [annotationId, dispatch], + [annotationId, dispatch, isRichTextEnabled], ); const handleThreadDelete = React.useCallback( @@ -349,6 +354,7 @@ const PopupV2 = ({ annotationId, onSubmit, popupPortalEl, reference }: Props): J diff --git a/src/components/Popups/__tests__/PopupV2-test.tsx b/src/components/Popups/__tests__/PopupV2-test.tsx index 9c0bb0a97..8d8347d79 100644 --- a/src/components/Popups/__tests__/PopupV2-test.tsx +++ b/src/components/Popups/__tests__/PopupV2-test.tsx @@ -1,15 +1,20 @@ import React from 'react'; import { act, render, screen } from '@testing-library/react'; import { useDispatch, useSelector } from 'react-redux'; +import { + serializeMentionMarkup, + serializeMessageToMarkdown, +} from '@box/threaded-annotations'; import type { MentionContextData, ThreadedAnnotationsPropsV2 } from '@box/threaded-annotations'; import AnnotationCallbacksContext from '../../../common/AnnotationCallbacksContext'; import PopupV2, { Props } from '../PopupV2'; import { + createReplyAction, deleteReplyAction, updateAnnotationAction, updateReplyAction, } from '../../../store/annotations/actions'; -import { getApiHost, getFileId, getFileVersionId, getToken } from '../../../store/options'; +import { getApiHost, getFileId, getFileVersionId, getIsRichTextEnabled, getToken } from '../../../store/options'; jest.mock('react-redux', () => ({ useDispatch: jest.fn(), @@ -35,6 +40,11 @@ jest.mock('@box/blueprint-web', () => ({ })); let lastMentionContextValue: MentionContextData = {}; +let lastMessageEditorProps: { + isFirstAnnotation?: boolean; + isRichTextEnabled?: boolean; + onPost?: (content: unknown) => Promise; +} = {}; let lastThreadedAnnotationsProps: Partial = {}; jest.mock('@box/threaded-annotations', () => { @@ -44,16 +54,24 @@ jest.mock('@box/threaded-annotations', () => { lastMentionContextValue = value; return ReactMock.createElement('div', { 'data-testid': 'mention-context' }, children); }, - MessageEditorV2: (props: Record) => - ReactMock.createElement('div', { + MessageEditorV2: (props: { + isFirstAnnotation?: boolean; + isRichTextEnabled?: boolean; + onPost?: (content: unknown) => Promise; + }) => { + lastMessageEditorProps = props; + return ReactMock.createElement('div', { 'data-testid': 'message-editor-v2', 'data-is-first-annotation': String(props.isFirstAnnotation), - }), + 'data-is-rich-text-enabled': String(props.isRichTextEnabled), + }); + }, ThreadedAnnotationsV2: (props: Partial) => { lastThreadedAnnotationsProps = props; return ReactMock.createElement('div', { 'data-testid': 'threaded-annotations-v2', 'data-is-annotations': String(props.isAnnotations), + 'data-is-rich-text-enabled': String(props.isRichTextEnabled), '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'), @@ -63,6 +81,8 @@ jest.mock('@box/threaded-annotations', () => { }); }, serializeMentionMarkup: jest.fn().mockReturnValue({ hasMention: false, text: 'serialized text' }), + serializeMessageToMarkdown: jest.fn().mockReturnValue('markdown text'), + parseMessageMarkdown: jest.fn().mockReturnValue({ type: 'doc', content: [] }), }; }); @@ -91,6 +111,7 @@ type SelectorOverrides = { apiHost?: string; fileId?: string | null; fileVersionId?: string | null; + isRichTextEnabled?: boolean; token?: unknown; }; @@ -99,12 +120,14 @@ const mockSelectorValues = ({ apiHost = 'https://api.box.com', fileId = '12345', fileVersionId = 'fv-1', + isRichTextEnabled = false, token = 'test-token', }: SelectorOverrides = {}): void => { mockUseSelector.mockImplementation(selector => { if (selector === getApiHost) return apiHost; if (selector === getFileId) return fileId; if (selector === getFileVersionId) return fileVersionId; + if (selector === getIsRichTextEnabled) return isRichTextEnabled; if (selector === getToken) return token; return annotation; }); @@ -153,6 +176,7 @@ describe('PopupV2', () => { beforeEach(() => { lastMentionContextValue = {}; + lastMessageEditorProps = {}; lastThreadedAnnotationsProps = {}; mockUseDispatch.mockReturnValue(mockDispatch); mockFetch.mockResolvedValue({ @@ -195,6 +219,35 @@ describe('PopupV2', () => { render(); expect(screen.getByTestId('message-editor-v2').getAttribute('data-is-first-annotation')).toBe('true'); + expect(screen.getByTestId('message-editor-v2').getAttribute('data-is-rich-text-enabled')).toBe('false'); + }); + + test('should pass isRichTextEnabled to MessageEditorV2 when the feature is on', () => { + mockSelectorValues({ isRichTextEnabled: true }); + render(); + + expect(screen.getByTestId('message-editor-v2').getAttribute('data-is-rich-text-enabled')).toBe('true'); + }); + + test('should serialize mention markup when posting a new annotation with rich text disabled', async () => { + render(); + + await lastMessageEditorProps.onPost?.({ type: 'doc', content: [] }); + + expect(serializeMentionMarkup).toHaveBeenCalled(); + expect(serializeMessageToMarkdown).not.toHaveBeenCalled(); + expect(defaults.onSubmit).toHaveBeenCalledWith('serialized text'); + }); + + test('should serialize markdown when posting a new annotation with rich text enabled', async () => { + mockSelectorValues({ isRichTextEnabled: true }); + render(); + + await lastMessageEditorProps.onPost?.({ type: 'doc', content: [] }); + + expect(serializeMessageToMarkdown).toHaveBeenCalled(); + expect(serializeMentionMarkup).not.toHaveBeenCalled(); + expect(defaults.onSubmit).toHaveBeenCalledWith('markdown text'); }); test('should set popupReplyV2 as resin component', () => { @@ -248,9 +301,20 @@ describe('PopupV2', () => { const thread = screen.getByTestId('threaded-annotations-v2'); expect(thread.getAttribute('data-is-annotations')).toBe('true'); + expect(thread.getAttribute('data-is-rich-text-enabled')).toBe('false'); expect(thread.getAttribute('data-messages-count')).toBe('1'); }); + test('should pass isRichTextEnabled to ThreadedAnnotationsV2 when the feature is on', async () => { + mockSelectorValues({ annotation: mockAnnotation, isRichTextEnabled: true }); + render(); + await flushPromises(); + + expect(screen.getByTestId('threaded-annotations-v2').getAttribute('data-is-rich-text-enabled')).toBe( + 'true', + ); + }); + test('should render empty messages when annotation is not found', async () => { mockSelectorValues(); render(); @@ -297,6 +361,50 @@ describe('PopupV2', () => { }); }); + test('should serialize markdown when editing the root message with rich text enabled', async () => { + mockSelectorValues({ annotation: mockAnnotation, isRichTextEnabled: true }); + render(); + await flushPromises(); + + await lastThreadedAnnotationsProps.onEdit?.('annotation-1', { type: 'doc', content: [] }); + + expect(serializeMessageToMarkdown).toHaveBeenCalled(); + expect(serializeMentionMarkup).not.toHaveBeenCalled(); + expect(updateAnnotationAction).toHaveBeenCalledWith({ + annotationId: 'annotation-1', + payload: { message: 'markdown text' }, + }); + }); + + test('should dispatch createReplyAction with mention markup when posting a reply', async () => { + render(); + await flushPromises(); + + await lastThreadedAnnotationsProps.onPost?.({ type: 'doc', content: [] }); + + expect(serializeMentionMarkup).toHaveBeenCalled(); + expect(serializeMessageToMarkdown).not.toHaveBeenCalled(); + expect(createReplyAction).toHaveBeenCalledWith({ + annotationId: 'annotation-1', + message: 'serialized text', + }); + }); + + test('should serialize markdown when posting a reply with rich text enabled', async () => { + mockSelectorValues({ annotation: mockAnnotation, isRichTextEnabled: true }); + render(); + await flushPromises(); + + await lastThreadedAnnotationsProps.onPost?.({ type: 'doc', content: [] }); + + expect(serializeMessageToMarkdown).toHaveBeenCalled(); + expect(serializeMentionMarkup).not.toHaveBeenCalled(); + expect(createReplyAction).toHaveBeenCalledWith({ + annotationId: 'annotation-1', + message: 'markdown text', + }); + }); + test('should dispatch deleteReplyAction when a reply is deleted', async () => { render(); await flushPromises(); diff --git a/src/store/options/__tests__/selectors-test.ts b/src/store/options/__tests__/selectors-test.ts index f6501925e..abe0eea61 100644 --- a/src/store/options/__tests__/selectors-test.ts +++ b/src/store/options/__tests__/selectors-test.ts @@ -2,6 +2,7 @@ import { getFeatures, getFileId, getFileVersionId, + getIsRichTextEnabled, getPermissions, getRotation, getScale, @@ -98,4 +99,16 @@ describe('store/options/selectors', () => { expect(isFeatureEnabled({ options: optionsState }, 'nonExistentFeature')).toBe(false); }); }); + + describe('getIsRichTextEnabled', () => { + test('should return true when the feature is set', () => { + expect( + getIsRichTextEnabled({ options: { ...optionsState, features: { isRichTextEnabled: true } } }), + ).toBe(true); + }); + + test('should return false when the feature is absent', () => { + expect(getIsRichTextEnabled({ options: optionsState })).toBe(false); + }); + }); }); diff --git a/src/store/options/selectors.ts b/src/store/options/selectors.ts index af568ba7d..074746003 100644 --- a/src/store/options/selectors.ts +++ b/src/store/options/selectors.ts @@ -18,3 +18,4 @@ export const getScale = (state: State): number => state.options.scale; export const getToken = (state: State): Token => state.options.token; export const isFeatureEnabled = (state: State, featurename: string): boolean => getProp(getFeatures(state), featurename, false); +export const getIsRichTextEnabled = (state: State): boolean => isFeatureEnabled(state, 'isRichTextEnabled'); From ac602cb5dc81e6ea92984c08afad814cec6b69a0 Mon Sep 17 00:00:00 2001 From: Jose Gaston Date: Mon, 31 Aug 2026 09:47:32 -0700 Subject: [PATCH 2/3] chore: bump @box/threaded-annotations to 4.11.0 Popup markdown serialize/parse lives in this range. Prior lock resolved to 4.1.7, which does not export those helpers. --- package.json | 10 +++----- yarn.lock | 69 ++++++++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 67 insertions(+), 12 deletions(-) diff --git a/package.json b/package.json index 6bc472ca5..3947edc2c 100644 --- a/package.json +++ b/package.json @@ -4,11 +4,7 @@ "description": "Box Annotations", "author": "Box (https://www.box.com/)", "license": "SEE LICENSE IN LICENSE", - "sideEffects": [ - "./dist/annotations.js", - "**/*.css", - "**/*.scss" - ], + "sideEffects": ["./dist/annotations.js", "**/*.css", "**/*.scss"], "repository": { "type": "git", "url": "git@github.com:box/box-annotations.git" @@ -38,7 +34,7 @@ "@box/collaboration-popover": "^2.1.25", "@box/languages": "^1.1.0", "@box/readable-time": "^2.1.25", - "@box/threaded-annotations": "^4.0.6", + "@box/threaded-annotations": "^4.11.0", "@box/user-selector": "^2.1.27", "@cfaester/enzyme-adapter-react-18": "^0.8.0", "@commitlint/cli": "^8.3.5", @@ -137,7 +133,7 @@ "@box/blueprint-web-assets": "^5.5.0", "@box/collaboration-popover": "^2.1.25", "@box/readable-time": "^2.1.25", - "@box/threaded-annotations": "^4.0.6", + "@box/threaded-annotations": "^4.11.0", "@box/user-selector": "^2.1.27" }, "scripts": { diff --git a/yarn.lock b/yarn.lock index adebf5a89..90a781cf7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1091,22 +1091,27 @@ resolved "https://registry.yarnpkg.com/@box/readable-time/-/readable-time-2.1.32.tgz#805ef989de8d2893a3a873eebefc0d62379c1566" integrity sha512-PYgSpolFg0eXvURc0Gl46FlRsd2IQB57MbJQ0PfiDRj/o2GaSJvR9gTvisbkbupBEmqAnsWcJ+D/zyRbE5P8dw== -"@box/threaded-annotations@^4.0.6": - version "4.1.7" - resolved "https://registry.yarnpkg.com/@box/threaded-annotations/-/threaded-annotations-4.1.7.tgz#cc87194a4fbb44d5f0bd024f088111de33fa37a7" - integrity sha512-19Ds/Ulbc+SnPtsbDSr9+/VHkTouFPISxUMci3hvP0DRbDiPWKaDZRy4eFvZwFw049e0jqEws3YuZ2W9b6xnhQ== +"@box/threaded-annotations@^4.11.0": + version "4.11.2" + resolved "https://registry.yarnpkg.com/@box/threaded-annotations/-/threaded-annotations-4.11.2.tgz#767cceb45b85ab4921349b0318ff88b95bd5be21" + integrity sha512-XRgmuv85IGrcptVsVXEEnS3+uAbvZBStna6STITJKtHQ+NDJKgaX32u38Gs5Pd8qDC5m3386cbBrgoQEZo0zZw== dependencies: "@floating-ui/dom" "^1.6.0" "@tanstack/react-virtual" "^3.10.8" "@tiptap/core" "3.26.1" + "@tiptap/extension-bold" "3.26.1" "@tiptap/extension-document" "3.26.1" + "@tiptap/extension-italic" "3.26.1" + "@tiptap/extension-list" "3.26.1" "@tiptap/extension-mention" "3.26.1" "@tiptap/extension-paragraph" "3.26.1" "@tiptap/extension-text" "3.26.1" + "@tiptap/extension-underline" "3.26.1" "@tiptap/extensions" "3.26.1" "@tiptap/pm" "3.26.1" "@tiptap/react" "3.26.1" "@tiptap/suggestion" "3.26.1" + markdown-it "^14.2.0" uuid "^9.0.1" "@box/user-selector@^2.1.27": @@ -3522,6 +3527,11 @@ resolved "https://registry.yarnpkg.com/@tiptap/core/-/core-3.26.1.tgz#a4d3222248af7af01aede146806a5757465cc654" integrity sha512-TX9PyPqBoix0qDLjtok/bddtdSy54QhzLVha405C07V+WySOpH3s/pWYkywehZQY0SQtcrcY4MNSCeQjCbA28A== +"@tiptap/extension-bold@3.26.1": + version "3.26.1" + resolved "https://registry.yarnpkg.com/@tiptap/extension-bold/-/extension-bold-3.26.1.tgz#5e6b1caca15e90a45324e960a597e6bedef15491" + integrity sha512-VIlF2sAiV6K009pcIDotfY8mvsPaq90dxeG9Q0ZIqfMD958TUCqjHw4MGYZf0/FgP12xksBfmcR7W312xgUf9Q== + "@tiptap/extension-bubble-menu@^3.26.1": version "3.28.0" resolved "https://registry.yarnpkg.com/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.28.0.tgz#51fc147b0f35d57f802204dd063831f67e538122" @@ -3539,6 +3549,16 @@ resolved "https://registry.yarnpkg.com/@tiptap/extension-floating-menu/-/extension-floating-menu-3.28.0.tgz#30b9c5d4c52687f53375f5396d92701ce3b75bde" integrity sha512-59SECvJq3pQfeJBuydEdhQqrpl0oDlk8N1Ovs1Si3/fJa6rEQAJB2zIvcpBHky9Z+5JodI2eueDnv8eimiyGbg== +"@tiptap/extension-italic@3.26.1": + version "3.26.1" + resolved "https://registry.yarnpkg.com/@tiptap/extension-italic/-/extension-italic-3.26.1.tgz#656ff4d6cac83c8cdd1fd030cd1c0fc368803283" + integrity sha512-cLKYvOLToWEkJkAPspgIZ/PYDzAxacLm1VWcAq1tO1QDQCDe2Kw+y/zsGlyYEq/aKsAgpp4JNopBwAXRXxt2/A== + +"@tiptap/extension-list@3.26.1": + version "3.26.1" + resolved "https://registry.yarnpkg.com/@tiptap/extension-list/-/extension-list-3.26.1.tgz#532804fda623a25ed10bcacad55ccf5ab347969e" + integrity sha512-06nOjnyXpzMO8Ys5k3IbYsDsKib1mv2OtaxBYX1/1uvRyOKwUX5tqDLb/qigic0LIANNL73lkNC8Z8XPeG4Tkg== + "@tiptap/extension-mention@3.26.1": version "3.26.1" resolved "https://registry.yarnpkg.com/@tiptap/extension-mention/-/extension-mention-3.26.1.tgz#9ac3147291d343b8b4edccfb4ac2753d0d503216" @@ -3554,6 +3574,11 @@ resolved "https://registry.yarnpkg.com/@tiptap/extension-text/-/extension-text-3.26.1.tgz#4b693434477a8fed89f964974634dbe5ae23d742" integrity sha512-Gocui5WvcCCJJIX17gdOVCSdYi5H4fDwaR0qkMAUZPq5kJCdrfl+vNpt8BTt53Bk+/QumiUW21fhQ184w7RoeQ== +"@tiptap/extension-underline@3.26.1": + version "3.26.1" + resolved "https://registry.yarnpkg.com/@tiptap/extension-underline/-/extension-underline-3.26.1.tgz#bf83cdc3119422d1401416438497ecfb1f0bc3e3" + integrity sha512-HUHtQ+DRWDM0opW7Nk3YQwrLzw876hMU7cr1X/ZTG+8Bp+AKHihlwU+bqrPgG5St0mqASyUEhHQ/vK5PlnUYOQ== + "@tiptap/extensions@3.26.1": version "3.26.1" resolved "https://registry.yarnpkg.com/@tiptap/extensions/-/extensions-3.26.1.tgz#9f5d9ea5eb64d5888b35d4ea91907610e1d51844" @@ -6883,7 +6908,7 @@ entities@^1.1.1, entities@~1.1.1: resolved "https://registry.yarnpkg.com/entities/-/entities-1.1.1.tgz#6e5c2d0a5621b5dadaecef80b90edfb5cd7772f0" integrity sha1-blwtClYhtdra7O+AuQ7ftc13cvA= -entities@^4.2.0: +entities@^4.2.0, entities@^4.5.0: version "4.5.0" resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48" integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw== @@ -10252,6 +10277,13 @@ lines-and-columns@^1.1.6: resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.1.6.tgz#1c00c743b433cd0a4e80758f7b64a57440d9ff00" integrity sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA= +linkify-it@^5.0.2: + version "5.0.2" + resolved "https://registry.yarnpkg.com/linkify-it/-/linkify-it-5.0.2.tgz#d3be0a693af3da9df3883f1e346a0e97461a8c19" + integrity sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q== + dependencies: + uc.micro "^2.0.0" + lint-staged@^9.5.0: version "9.5.0" resolved "https://registry.yarnpkg.com/lint-staged/-/lint-staged-9.5.0.tgz#290ec605252af646d9b74d73a0fa118362b05a33" @@ -10629,6 +10661,18 @@ markdown-escapes@^1.0.0: resolved "https://registry.yarnpkg.com/markdown-escapes/-/markdown-escapes-1.0.2.tgz#e639cbde7b99c841c0bacc8a07982873b46d2122" integrity sha512-lbRZ2mE3Q9RtLjxZBZ9+IMl68DKIXaVAhwvwn9pmjnPLS0h/6kyBMgNhqi1xFJ/2yv6cSyv0jbiZavZv93JkkA== +markdown-it@^14.2.0: + version "14.3.1" + resolved "https://registry.yarnpkg.com/markdown-it/-/markdown-it-14.3.1.tgz#8974e2473779363ed5682ea377f31564ee51e292" + integrity sha512-4Ej49aYTDFIQ+uBkfX8GBvJGccoARxxPep+7aWTs55ozbjQJpW9M26Fe53vnGgvLeVzva/amzjQQaQu9w0vMhA== + dependencies: + argparse "^2.0.1" + entities "^4.5.0" + linkify-it "^5.0.2" + mdurl "^2.0.0" + punycode.js "^2.3.1" + uc.micro "^2.1.0" + markdown-table@^1.1.0: version "1.1.2" resolved "https://registry.yarnpkg.com/markdown-table/-/markdown-table-1.1.2.tgz#c78db948fa879903a41bce522e3b96f801c63786" @@ -10661,6 +10705,11 @@ mdn-data@2.27.1: resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.27.1.tgz#e37b9c50880b75366c4d40ac63d9bbcacdb61f0e" integrity sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ== +mdurl@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/mdurl/-/mdurl-2.1.0.tgz#d711d3f7bce7f22c487c91be78545f356fa96573" + integrity sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg== + media-typer@0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" @@ -12380,6 +12429,11 @@ pump@^3.0.0: end-of-stream "^1.1.0" once "^1.3.1" +punycode.js@^2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/punycode.js/-/punycode.js-2.3.1.tgz#6b53e56ad75588234e79f4affa90972c7dd8cdb7" + integrity sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA== + punycode@1.3.2: version "1.3.2" resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.3.2.tgz#9653a036fb7c1ee42342f2325cceefea3926c48d" @@ -14766,6 +14820,11 @@ ua-parser-js@^0.7.18: resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.21.tgz#853cf9ce93f642f67174273cc34565ae6f308777" integrity sha512-+O8/qh/Qj8CgC6eYBVBykMrNtp5Gebn4dlGD/kKXVkJNDwyrAwSIqwz8CDf+tsAIWVycKcku6gIXJ0qwx/ZXaQ== +uc.micro@^2.0.0, uc.micro@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-2.1.0.tgz#f8d3f7d0ec4c3dea35a7e3c8efa4cb8b45c9e7ee" + integrity sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A== + uglify-js@^3.1.4: version "3.4.9" resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.4.9.tgz#af02f180c1207d76432e473ed24a28f4a782bae3" From 6c418feade3383ddfdeae4746ed979e6c630b8a8 Mon Sep 17 00:00:00 2001 From: Jose Gaston Date: Mon, 31 Aug 2026 11:19:30 -0700 Subject: [PATCH 3/3] refactor(popup-v2): drop per-flag feature selector Other flags already call isFeatureEnabled at the use site. A named wrapper was the only exception. --- src/components/Popups/PopupV2.tsx | 4 ++-- src/components/Popups/__tests__/PopupV2-test.tsx | 9 ++++++--- src/store/options/__tests__/selectors-test.ts | 13 ------------- src/store/options/selectors.ts | 1 - 4 files changed, 8 insertions(+), 19 deletions(-) diff --git a/src/components/Popups/PopupV2.tsx b/src/components/Popups/PopupV2.tsx index 04990917c..54358006b 100644 --- a/src/components/Popups/PopupV2.tsx +++ b/src/components/Popups/PopupV2.tsx @@ -28,7 +28,7 @@ import { updateReplyAction, } from '../../store/annotations/actions'; import { getAnnotation } from '../../store/annotations/selectors'; -import { getApiHost, getFileId, getFileVersionId, getIsRichTextEnabled, getToken } from '../../store/options'; +import { getApiHost, getFileId, getFileVersionId, getToken, isFeatureEnabled } from '../../store/options'; import { fetchCollaboratorsAction } from '../../store/users/actions'; import type { Token, TokenLiteral, TokenMap } from '../../@types'; @@ -116,7 +116,7 @@ const PopupV2 = ({ annotationId, onSubmit, popupPortalEl, reference }: Props): J const apiHost = useSelector(getApiHost); const fileId = useSelector(getFileId); const fileVersionId = useSelector(getFileVersionId); - const isRichTextEnabled = useSelector(getIsRichTextEnabled); + const isRichTextEnabled = useSelector((state: AppState) => isFeatureEnabled(state, 'isRichTextEnabled')); const token = useSelector(getToken); const onCopyLink = React.useMemo( () => diff --git a/src/components/Popups/__tests__/PopupV2-test.tsx b/src/components/Popups/__tests__/PopupV2-test.tsx index 8d8347d79..51e963fc1 100644 --- a/src/components/Popups/__tests__/PopupV2-test.tsx +++ b/src/components/Popups/__tests__/PopupV2-test.tsx @@ -14,7 +14,7 @@ import { updateAnnotationAction, updateReplyAction, } from '../../../store/annotations/actions'; -import { getApiHost, getFileId, getFileVersionId, getIsRichTextEnabled, getToken } from '../../../store/options'; +import { getApiHost, getFileId, getFileVersionId, getToken } from '../../../store/options'; jest.mock('react-redux', () => ({ useDispatch: jest.fn(), @@ -127,9 +127,12 @@ const mockSelectorValues = ({ if (selector === getApiHost) return apiHost; if (selector === getFileId) return fileId; if (selector === getFileVersionId) return fileVersionId; - if (selector === getIsRichTextEnabled) return isRichTextEnabled; if (selector === getToken) return token; - return annotation; + const result = selector({ + annotations: { byId: {} }, + options: { features: { isRichTextEnabled } }, + }); + return typeof result === 'boolean' ? result : annotation; }); }; diff --git a/src/store/options/__tests__/selectors-test.ts b/src/store/options/__tests__/selectors-test.ts index abe0eea61..f6501925e 100644 --- a/src/store/options/__tests__/selectors-test.ts +++ b/src/store/options/__tests__/selectors-test.ts @@ -2,7 +2,6 @@ import { getFeatures, getFileId, getFileVersionId, - getIsRichTextEnabled, getPermissions, getRotation, getScale, @@ -99,16 +98,4 @@ describe('store/options/selectors', () => { expect(isFeatureEnabled({ options: optionsState }, 'nonExistentFeature')).toBe(false); }); }); - - describe('getIsRichTextEnabled', () => { - test('should return true when the feature is set', () => { - expect( - getIsRichTextEnabled({ options: { ...optionsState, features: { isRichTextEnabled: true } } }), - ).toBe(true); - }); - - test('should return false when the feature is absent', () => { - expect(getIsRichTextEnabled({ options: optionsState })).toBe(false); - }); - }); }); diff --git a/src/store/options/selectors.ts b/src/store/options/selectors.ts index 074746003..af568ba7d 100644 --- a/src/store/options/selectors.ts +++ b/src/store/options/selectors.ts @@ -18,4 +18,3 @@ export const getScale = (state: State): number => state.options.scale; export const getToken = (state: State): Token => state.options.token; export const isFeatureEnabled = (state: State, featurename: string): boolean => getProp(getFeatures(state), featurename, false); -export const getIsRichTextEnabled = (state: State): boolean => isFeatureEnabled(state, 'isRichTextEnabled');