diff --git a/src/elements/content-sidebar/activity-feed-v2/task-modal-v2/utils/__tests__/contactMapping.test.ts b/src/elements/content-sidebar/activity-feed-v2/task-modal-v2/utils/__tests__/contactMapping.test.ts new file mode 100644 index 0000000000..8057343ab1 --- /dev/null +++ b/src/elements/content-sidebar/activity-feed-v2/task-modal-v2/utils/__tests__/contactMapping.test.ts @@ -0,0 +1,202 @@ +import type { GroupMini, SelectorItem, UserMini } from '../../../../../../common/types/core'; + +import { + mapAssigneeToUserContact, + mapCollaboratorToUserContact, + mapUserContactToAssignee, + type RuntimeAssignee, +} from '../contactMapping'; + +const userItem: UserMini = { + email: 'alice@example.com', + id: '12345', + name: 'Alice Anderson', + type: 'user', +}; + +const groupItem: GroupMini = { + id: '99', + name: 'Engineering', + type: 'group', +}; + +const userCollaborator: SelectorItem = { + id: '12345', + item: userItem, + name: 'Alice Anderson', +}; + +const groupCollaborator: SelectorItem = { + id: '99', + item: groupItem, + name: 'Engineering', +}; + +const makeAssignee = (target: UserMini | GroupMini): RuntimeAssignee => ({ + id: 'task_collab_1', + permissions: { can_delete: true, can_update: true }, + role: 'ASSIGNEE', + status: 'NOT_STARTED', + target, + type: 'task_collaborator', +}); + +describe('contactMapping', () => { + describe('mapCollaboratorToUserContact', () => { + test('maps a user collaborator with email, numeric display id, and raw-id value', () => { + expect(mapCollaboratorToUserContact(userCollaborator)).toEqual({ + email: 'alice@example.com', + id: 12345, + name: 'Alice Anderson', + type: 'user', + value: '12345', + }); + }); + + test('maps a group collaborator with empty email and group type', () => { + expect(mapCollaboratorToUserContact(groupCollaborator)).toEqual({ + email: '', + id: 99, + name: 'Engineering', + type: 'group', + value: '99', + }); + }); + + test('falls back to display id 0 but preserves the raw id in value when id is non-numeric', () => { + const nonNumericGroup: SelectorItem = { + id: 'group_abc', + item: { id: 'group_abc', name: 'Beta Group', type: 'group' }, + name: 'Beta Group', + }; + const contact = mapCollaboratorToUserContact(nonNumericGroup); + expect(contact.id).toBe(0); + expect(contact.value).toBe('group_abc'); + }); + + test('returns display id 0 for empty and whitespace-only ids', () => { + const emptyIdGroup: SelectorItem = { + id: '', + item: { id: '', name: 'Empty', type: 'group' }, + name: 'Empty', + }; + const whitespaceIdGroup: SelectorItem = { + id: ' ', + item: { id: ' ', name: 'Whitespace', type: 'group' }, + name: 'Whitespace', + }; + + expect(mapCollaboratorToUserContact(emptyIdGroup).id).toBe(0); + expect(mapCollaboratorToUserContact(emptyIdGroup).value).toBe(''); + expect(mapCollaboratorToUserContact(whitespaceIdGroup).id).toBe(0); + expect(mapCollaboratorToUserContact(whitespaceIdGroup).value).toBe(' '); + }); + + test('throws when SelectorItem.item is missing', () => { + const orphan: SelectorItem = { + id: '12345', + name: 'Orphan', + }; + expect(() => mapCollaboratorToUserContact(orphan)).toThrow(/SelectorItem\.item is required/); + }); + }); + + describe('mapUserContactToAssignee', () => { + test('produces a user assignee whose target.id is the raw value, not the display id', () => { + const contact = mapCollaboratorToUserContact(userCollaborator); + const assignee = mapUserContactToAssignee(contact); + + expect(assignee).toEqual({ + id: '', + permissions: { can_delete: false, can_update: false }, + role: 'ASSIGNEE', + status: 'NOT_STARTED', + target: { email: 'alice@example.com', id: '12345', name: 'Alice Anderson', type: 'user' }, + type: 'task_collaborator', + }); + }); + + test('produces a group assignee with target.type = group and raw id', () => { + const contact = mapCollaboratorToUserContact(groupCollaborator); + const assignee = mapUserContactToAssignee(contact); + + expect(assignee.target).toEqual({ id: '99', name: 'Engineering', type: 'group' }); + }); + }); + + describe('mapAssigneeToUserContact', () => { + test('seeds a user contact from an existing user assignee', () => { + expect(mapAssigneeToUserContact(makeAssignee(userItem))).toEqual({ + email: 'alice@example.com', + id: 12345, + name: 'Alice Anderson', + type: 'user', + value: '12345', + }); + }); + + test('seeds a group contact (no email) from an existing group assignee', () => { + expect(mapAssigneeToUserContact(makeAssignee(groupItem))).toEqual({ + email: '', + id: 99, + name: 'Engineering', + type: 'group', + value: '99', + }); + }); + + test('treats a target missing email as empty string', () => { + const target: UserMini = { id: '7', name: 'Charlie', type: 'user' }; + expect(mapAssigneeToUserContact(makeAssignee(target)).email).toBe(''); + }); + }); + + describe('round-trip equivalence', () => { + test('user with numeric id: SelectorItem -> UserContact -> assignee -> UserContact stays stable', () => { + const contact = mapCollaboratorToUserContact(userCollaborator); + const assignee = mapUserContactToAssignee(contact); + const rebuilt = mapAssigneeToUserContact(assignee); + expect(rebuilt).toEqual(contact); + }); + + test('group with numeric id: SelectorItem -> UserContact -> assignee -> UserContact stays stable', () => { + const contact = mapCollaboratorToUserContact(groupCollaborator); + const assignee = mapUserContactToAssignee(contact); + const rebuilt = mapAssigneeToUserContact(assignee); + expect(rebuilt).toEqual(contact); + }); + + test('non-numeric id is preserved end-to-end through the assignee payload', () => { + const nonNumericGroup: SelectorItem = { + id: 'group_abc', + item: { id: 'group_abc', name: 'Beta Group', type: 'group' }, + name: 'Beta Group', + }; + const contact = mapCollaboratorToUserContact(nonNumericGroup); + const assignee = mapUserContactToAssignee(contact); + const rebuilt = mapAssigneeToUserContact(assignee); + + expect(assignee.target.id).toBe('group_abc'); + expect(rebuilt.value).toBe('group_abc'); + expect(rebuilt).toEqual(contact); + }); + + test('two distinct non-numeric ids share display id 0 but stay distinguishable by value', () => { + const groupA: SelectorItem = { + id: 'group_abc', + item: { id: 'group_abc', name: 'Alpha', type: 'group' }, + name: 'Alpha', + }; + const groupB: SelectorItem = { + id: 'group_xyz', + item: { id: 'group_xyz', name: 'Bravo', type: 'group' }, + name: 'Bravo', + }; + const contactA = mapCollaboratorToUserContact(groupA); + const contactB = mapCollaboratorToUserContact(groupB); + + expect(contactA.id).toBe(contactB.id); + expect(contactA.value).not.toBe(contactB.value); + }); + }); +}); diff --git a/src/elements/content-sidebar/activity-feed-v2/task-modal-v2/utils/contactMapping.ts b/src/elements/content-sidebar/activity-feed-v2/task-modal-v2/utils/contactMapping.ts new file mode 100644 index 0000000000..5932bd9e8f --- /dev/null +++ b/src/elements/content-sidebar/activity-feed-v2/task-modal-v2/utils/contactMapping.ts @@ -0,0 +1,63 @@ +import type { UserContactType } from '@box/user-selector'; + +import type { GroupMini, SelectorItem, UserMini } from '../../../../../common/types/core'; +import type { TaskCollabAssignee } from '../../../../../common/types/tasks'; + +type AssigneeTarget = UserMini | GroupMini; + +// Widens TaskCollab.target to admit groups, which the collaborators endpoint returns at runtime. +export type RuntimeAssignee = Omit & { target: AssigneeTarget }; + +// Non-numeric ids collapse to 0; the raw string is preserved in UserContactType.value. +const toDisplayId = (rawId: string): number => { + if (rawId.trim() === '') { + return 0; + } + const parsed = Number(rawId); + return Number.isFinite(parsed) ? parsed : 0; +}; + +export const mapCollaboratorToUserContact = (collab: SelectorItem): UserContactType => { + const { item } = collab; + if (!item) { + throw new Error('mapCollaboratorToUserContact: SelectorItem.item is required'); + } + const email = item.type === 'user' ? item.email ?? '' : ''; + + return { + email, + id: toDisplayId(item.id), + name: item.name, + type: item.type, + value: item.id, + }; +}; + +export const mapUserContactToAssignee = (contact: UserContactType): RuntimeAssignee => { + const target: AssigneeTarget = + contact.type === 'group' + ? { id: contact.value, name: contact.name, type: 'group' } + : { email: contact.email, id: contact.value, name: contact.name, type: 'user' }; + + return { + id: '', + permissions: { can_delete: false, can_update: false }, + role: 'ASSIGNEE', + status: 'NOT_STARTED', + target, + type: 'task_collaborator', + }; +}; + +export const mapAssigneeToUserContact = (assignee: RuntimeAssignee): UserContactType => { + const { target } = assignee; + const email = target.type === 'user' ? target.email ?? '' : ''; + + return { + email, + id: toDisplayId(target.id), + name: target.name, + type: target.type, + value: target.id, + }; +};