diff --git a/src/elements/content-sidebar/activity-feed-v2/__tests__/transformers.test.ts b/src/elements/content-sidebar/activity-feed-v2/__tests__/transformers.test.ts index f173dbdfbd..730aa91cfb 100644 --- a/src/elements/content-sidebar/activity-feed-v2/__tests__/transformers.test.ts +++ b/src/elements/content-sidebar/activity-feed-v2/__tests__/transformers.test.ts @@ -17,7 +17,7 @@ import { describe('elements/content-sidebar/activity-feed-v2/transformers', () => { describe('textToDocumentNode()', () => { test('should convert single-line text to a document node', () => { - const result = textToDocumentNode('Hello world'); + const result = textToDocumentNode('Hello world', ''); expect(result).toEqual({ type: 'doc', content: [ @@ -30,7 +30,7 @@ describe('elements/content-sidebar/activity-feed-v2/transformers', () => { }); test('should convert multi-line text to multiple paragraphs', () => { - const result = textToDocumentNode('Line 1\nLine 2\nLine 3'); + const result = textToDocumentNode('Line 1\nLine 2\nLine 3', ''); expect(result.content).toHaveLength(3); expect(result.content[0].content).toEqual([{ type: 'text', text: 'Line 1' }]); expect(result.content[1].content).toEqual([{ type: 'text', text: 'Line 2' }]); @@ -38,18 +38,18 @@ describe('elements/content-sidebar/activity-feed-v2/transformers', () => { }); test('should handle empty lines as paragraphs without content key', () => { - const result = textToDocumentNode('Before\n\nAfter'); + const result = textToDocumentNode('Before\n\nAfter', ''); expect(result.content).toHaveLength(3); expect(result.content[1]).toEqual({ type: 'paragraph' }); }); test('should handle empty string', () => { - const result = textToDocumentNode(''); + const result = textToDocumentNode('', ''); expect(result).toEqual({ type: 'doc', content: [] }); }); test('should parse @[id:name] mentions into mention nodes', () => { - const result = textToDocumentNode('Hello @[123:Jane Doe] how are you?'); + const result = textToDocumentNode('Hello @[123:Jane Doe] how are you?', ''); expect(result.content).toHaveLength(1); expect(result.content[0].content).toEqual([ { type: 'text', text: 'Hello ' }, @@ -67,7 +67,7 @@ describe('elements/content-sidebar/activity-feed-v2/transformers', () => { }); test('should parse multiple mentions in one line', () => { - const result = textToDocumentNode('@[1:Alice] and @[2:Bob]'); + const result = textToDocumentNode('@[1:Alice] and @[2:Bob]', ''); expect(result.content[0].content).toHaveLength(3); expect(result.content[0].content[0]).toEqual({ type: 'mention', @@ -81,11 +81,23 @@ describe('elements/content-sidebar/activity-feed-v2/transformers', () => { }); test('should handle mentions across multiple lines', () => { - const result = textToDocumentNode('Hi @[1:Alice]\nBye @[2:Bob]'); + const result = textToDocumentNode('Hi @[1:Alice]\nBye @[2:Bob]', ''); expect(result.content).toHaveLength(2); expect(result.content[0].content[1].type).toBe('mention'); expect(result.content[1].content[1].type).toBe('mention'); }); + + test('should pass authorId through to each mention node', () => { + const result = textToDocumentNode('@[1:Alice] and @[2:Bob]', '99'); + expect(result.content[0].content[0]).toEqual({ + type: 'mention', + attrs: { authorId: '99', mentionId: '1', mentionedUserId: '1', mentionedUserName: 'Alice' }, + }); + expect(result.content[0].content[2]).toEqual({ + type: 'mention', + attrs: { authorId: '99', mentionId: '2', mentionedUserId: '2', mentionedUserName: 'Bob' }, + }); + }); }); describe('transformCommentToMessages()', () => { @@ -141,13 +153,13 @@ describe('elements/content-sidebar/activity-feed-v2/transformers', () => { expect(messages[1].author.name).toBe('Reply User'); }); - test('should use tagged_message over message and parse mentions', () => { + test('should use tagged_message over message and parse mentions with author id', () => { const messages = transformCommentToMessages(mockComment as unknown as Comment); expect(messages[0].message.content[0].content).toEqual([ { type: 'text', text: 'Hello ' }, { type: 'mention', - attrs: { authorId: '', mentionId: '456', mentionedUserId: '456', mentionedUserName: 'Jane' }, + attrs: { authorId: '123', mentionId: '456', mentionedUserId: '456', mentionedUserName: 'Jane' }, }, ]); }); @@ -160,6 +172,17 @@ describe('elements/content-sidebar/activity-feed-v2/transformers', () => { text: 'fallback message', }); }); + + test('should leave updatedAt undefined when comment has not been edited', () => { + const messages = transformCommentToMessages(mockComment as unknown as Comment); + expect(messages[0].updatedAt).toBeUndefined(); + }); + + test('should set updatedAt to modified_at when comment has been edited', () => { + const editedComment = { ...mockComment, modified_at: '2024-01-02T00:00:00Z' }; + const messages = transformCommentToMessages(editedComment as unknown as Comment); + expect(messages[0].updatedAt).toBe(new Date('2024-01-02T00:00:00Z').getTime()); + }); }); describe('transformAnnotationToMessages()', () => { @@ -224,6 +247,26 @@ describe('elements/content-sidebar/activity-feed-v2/transformers', () => { const messages = transformAnnotationToMessages(noDescription as unknown as Annotation); expect(messages[0].message).toEqual({ type: 'doc', content: [] }); }); + + test('should leave updatedAt undefined when annotation has not been edited', () => { + const messages = transformAnnotationToMessages(mockAnnotation as unknown as Annotation); + expect(messages[0].updatedAt).toBeUndefined(); + }); + + test('should set updatedAt to modified_at when annotation has been edited', () => { + const editedAnnotation = { ...mockAnnotation, modified_at: '2024-02-05T00:00:00Z' }; + const messages = transformAnnotationToMessages(editedAnnotation as unknown as Annotation); + expect(messages[0].updatedAt).toBe(new Date('2024-02-05T00:00:00Z').getTime()); + }); + + test('should set authorId on mentions to the annotation creator id', () => { + const annotationWithMention = { ...mockAnnotation, description: { message: 'cc @[789:Replier]' } }; + const messages = transformAnnotationToMessages(annotationWithMention as unknown as Annotation); + const [, mentionNode] = messages[0].message.content[0].content as Array<{ + attrs?: { authorId: string }; + }>; + expect(mentionNode.attrs?.authorId).toBe('456'); + }); }); describe('transformTaskToProps()', () => { @@ -380,6 +423,36 @@ describe('elements/content-sidebar/activity-feed-v2/transformers', () => { const result = transformVersionToProps(version as unknown as BoxItemVersion); expect(result.authorName).toBe('Trasher'); }); + + test('should prefer the action-specific user over modified_by for promotion', () => { + const version = { + ...mockVersion, + action_type: 'promoted', + modified_by: { id: '300', name: 'Uploader', type: 'user' }, + promoted_by: { id: '500', name: 'Promoter', type: 'user' }, + version_promoted: 'promoted', + }; + const result = transformVersionToProps(version as unknown as BoxItemVersion); + expect(result.authorName).toBe('Promoter'); + expect(result.actionType).toBe('promote'); + }); + + test('should derive action from version flags when action_type is missing', () => { + const trashedVersion = { ...mockVersion, action_type: undefined, trashed_at: '2024-04-02T00:00:00Z' }; + expect(transformVersionToProps(trashedVersion as unknown as BoxItemVersion).actionType).toBe('delete'); + + const restoredVersion = { ...mockVersion, action_type: undefined, restored_at: '2024-04-02T00:00:00Z' }; + expect(transformVersionToProps(restoredVersion as unknown as BoxItemVersion).actionType).toBe('restore'); + + const promotedVersion = { ...mockVersion, action_type: undefined, version_promoted: 'promoted' }; + expect(transformVersionToProps(promotedVersion as unknown as BoxItemVersion).actionType).toBe('promote'); + }); + + test('should default to upload when action_type is unknown and no flags are set', () => { + const version = { ...mockVersion, action_type: 'unrecognized' }; + const result = transformVersionToProps(version as unknown as BoxItemVersion); + expect(result.actionType).toBe('upload'); + }); }); describe('transformAppActivityToProps()', () => { diff --git a/src/elements/content-sidebar/activity-feed-v2/transformers.ts b/src/elements/content-sidebar/activity-feed-v2/transformers.ts index 19c4c61a03..dcdcef7062 100644 --- a/src/elements/content-sidebar/activity-feed-v2/transformers.ts +++ b/src/elements/content-sidebar/activity-feed-v2/transformers.ts @@ -41,11 +41,7 @@ import { const MENTION_REGEX = /@\[(\d+):([^\]]+)\]/g; -/** - * Parses a line of text into TipTap content nodes, converting @[id:name] - * mention markup into MentionNode elements and plain text into TextNode elements. - */ -const parseLine = (line: string): (MentionNode | TextNode)[] => { +const parseLine = (line: string, authorId: string): (MentionNode | TextNode)[] => { const nodes: (MentionNode | TextNode)[] = []; let lastIndex = 0; @@ -63,7 +59,7 @@ const parseLine = (line: string): (MentionNode | TextNode)[] => { nodes.push({ type: 'mention', attrs: { - authorId: '', + authorId, mentionId: userId, mentionedUserId: userId, mentionedUserName: userName, @@ -81,18 +77,14 @@ const parseLine = (line: string): (MentionNode | TextNode)[] => { return nodes; }; -/** - * Converts a tagged_message string to a TipTap DocumentNode. - * Parses @[userId:userName] mention markup into MentionNode elements. - */ -export const textToDocumentNode = (text: string): DocumentNode => { +export const textToDocumentNode = (text: string, authorId: string): DocumentNode => { if (!text) { return { type: 'doc', content: [] }; } const lines = text.split('\n'); const content: ParagraphNode[] = lines.map(line => { - const nodes = parseLine(line); + const nodes = parseLine(line, authorId); return { type: 'paragraph' as const, ...(nodes.length > 0 ? { content: nodes } : {}), @@ -108,6 +100,11 @@ const toUnixMs = (isoDate?: string | null): number | undefined => { return Number.isNaN(ms) ? undefined : ms; }; +const toUpdatedAt = (createdAt: string, modifiedAt: string): number | undefined => { + if (modifiedAt === createdAt) return undefined; + return toUnixMs(modifiedAt); +}; + const toUserAuthor = (user?: User | null): TextMessageAuthorType => ({ avatarUrl: user?.avatar_url, email: user?.email ?? user?.login ?? '', @@ -128,8 +125,9 @@ const commentToTextMessage = (comment: Comment): TextMessageType => ({ author: toUserAuthor(comment.created_by), createdAt: toUnixMs(comment.created_at) ?? 0, id: comment.id, - message: textToDocumentNode(comment.tagged_message || comment.message || ''), + message: textToDocumentNode(comment.tagged_message || comment.message || '', comment.created_by?.id ?? ''), permissions: toPermissions(comment.permissions), + updatedAt: toUpdatedAt(comment.created_at, comment.modified_at), }); export const transformCommentToMessages = (comment: Comment): TextMessageType[] => { @@ -163,8 +161,9 @@ export const transformAnnotationToMessages = (annotation: Annotation): TextMessa author: toUserAuthor(annotation.created_by), createdAt: toUnixMs(annotation.created_at) ?? 0, id: annotation.id, - message: textToDocumentNode(messageText), + message: textToDocumentNode(messageText, annotation.created_by?.id ?? ''), permissions: toPermissions(annotation.permissions), + updatedAt: toUpdatedAt(annotation.created_at, annotation.modified_at), }; const replies = (annotation.replies ?? []).map(reply => commentToTextMessage(reply)); return [root, ...replies]; @@ -204,7 +203,7 @@ export const transformTaskToProps = (task: TaskNew, currentUserId?: string): Tas taskType: task.task_type === 'APPROVAL' ? TaskType.APPROVAL : TaskType.GENERAL, }); -const mapVersionActionType = (actionType?: string): VersionItemProps['actionType'] => { +const mapActionTypeString = (actionType?: string): VersionItemProps['actionType'] | undefined => { switch (actionType) { case 'delete': case 'trashed': @@ -217,15 +216,26 @@ const mapVersionActionType = (actionType?: string): VersionItemProps['actionType return 'restore'; case 'upload': case 'created': - default: return 'upload'; + default: + return undefined; } }; +const getVersionAction = (version: BoxItemVersion): VersionItemProps['actionType'] => { + if (version.version_promoted) return 'promote'; + if (version.restored_at) return 'restore'; + if (version.trashed_at) return 'delete'; + return mapActionTypeString(version.action_type) ?? 'upload'; +}; + +const getVersionUser = (version: BoxItemVersion): User | undefined => + version.restored_by || version.trashed_by || version.promoted_by || version.modified_by || undefined; + export const transformVersionToProps = (version: BoxItemVersion): VersionItemProps => { - const user = version.modified_by ?? version.trashed_by ?? version.restored_by ?? version.promoted_by; + const user = getVersionUser(version); return { - actionType: mapVersionActionType(version.action_type), + actionType: getVersionAction(version), authorName: user?.name ?? version.uploader_display_name, avatarUrl: user?.avatar_url, createdAt: toUnixMs(version.created_at),