From 1b1b171e8881fa9e3041ca401fb271960237e195 Mon Sep 17 00:00:00 2001 From: reneshen0328 Date: Wed, 1 Oct 2025 18:00:25 -0700 Subject: [PATCH 1/8] feat(content-sharing): Convert api response for collaborators --- .../content-sharing/ContentSharingV2.tsx | 109 ++++---- .../content-sharing/apis/fetchAvatars.ts | 22 ++ .../apis/fetchCollaborators.ts | 21 ++ .../content-sharing/apis/fetchCurrentUser.ts | 15 ++ .../content-sharing/apis/fetchItem.ts | 22 ++ src/elements/content-sharing/apis/index.ts | 4 + src/elements/content-sharing/constants.js | 35 +++ .../tests/ContentSharingV2-visual.stories.tsx | 49 +++- src/elements/content-sharing/types.js | 14 + .../utils/__mocks__/ContentSharingV2Mocks.js | 108 +++++++- .../__tests__/convertCollaborators.test.ts | 248 ++++++++++++++++++ .../__tests__/getAllowedAccessLevels.test.ts | 20 ++ .../content-sharing/utils/constants.ts | 31 --- .../utils/convertCollaborators.ts | 63 +++++ .../utils/convertItemResponse.ts | 8 +- .../utils/getAllowedAccessLevels.ts | 35 ++- src/elements/content-sharing/utils/index.ts | 1 + 17 files changed, 709 insertions(+), 96 deletions(-) create mode 100644 src/elements/content-sharing/apis/fetchAvatars.ts create mode 100644 src/elements/content-sharing/apis/fetchCollaborators.ts create mode 100644 src/elements/content-sharing/apis/fetchCurrentUser.ts create mode 100644 src/elements/content-sharing/apis/fetchItem.ts create mode 100644 src/elements/content-sharing/apis/index.ts create mode 100644 src/elements/content-sharing/utils/__tests__/convertCollaborators.test.ts delete mode 100644 src/elements/content-sharing/utils/constants.ts create mode 100644 src/elements/content-sharing/utils/convertCollaborators.ts diff --git a/src/elements/content-sharing/ContentSharingV2.tsx b/src/elements/content-sharing/ContentSharingV2.tsx index 925352c544..df106de28d 100644 --- a/src/elements/content-sharing/ContentSharingV2.tsx +++ b/src/elements/content-sharing/ContentSharingV2.tsx @@ -2,16 +2,16 @@ import * as React from 'react'; import isEmpty from 'lodash/isEmpty'; import { UnifiedShareModal } from '@box/unified-share-modal'; -import type { CollaborationRole, Item, SharedLink, User } from '@box/unified-share-modal'; +import type { CollaborationRole, Collaborator, Item, SharedLink, User } from '@box/unified-share-modal'; import API from '../../api'; -import { FIELD_ENTERPRISE, FIELD_HOSTNAME, TYPE_FILE, TYPE_FOLDER } from '../../constants'; import Internationalize from '../common/Internationalize'; import Providers from '../common/Providers'; -import { CONTENT_SHARING_ITEM_FIELDS } from './constants'; -import { convertItemResponse } from './utils'; +import { fetchAvatars, fetchCollaborators, fetchCurrentUser, fetchItem } from './apis'; +import { convertCollabsResponse, convertItemResponse } from './utils'; -import type { ItemType, StringMap } from '../../common/types/core'; +import type { Collaborations, ItemType, StringMap } from '../../common/types/core'; +import type { AvatarURLMap } from './types'; export interface ContentSharingV2Props { /** api - API instance */ @@ -39,13 +39,17 @@ function ContentSharingV2({ language, messages, }: ContentSharingV2Props) { + const [avatarURLMap, setAvatarURLMap] = React.useState(null); const [item, setItem] = React.useState(null); const [sharedLink, setSharedLink] = React.useState(null); const [currentUser, setCurrentUser] = React.useState(null); const [collaborationRoles, setCollaborationRoles] = React.useState(null); + const [collaborators, setCollaborators] = React.useState(null); + const [collaboratorsData, setCollaboratorsData] = React.useState(null); // Handle successful GET requests to /files or /folders const handleGetItemSuccess = React.useCallback(itemData => { + console.log('handleGetItemSuccess itemData', itemData); const { collaborationRoles: collaborationRolesFromAPI, item: itemFromAPI, @@ -62,70 +66,72 @@ function ContentSharingV2({ setSharedLink(null); setCurrentUser(null); setCollaborationRoles(null); + setAvatarURLMap(null); + setCollaborators(null); + setCollaboratorsData(null); }, [api]); // Get initial data for the item React.useEffect(() => { - const getItem = () => { - if (itemType === TYPE_FILE) { - api.getFileAPI().getFile( - itemID, - handleGetItemSuccess, - {}, - { - fields: CONTENT_SHARING_ITEM_FIELDS, - }, - ); - } else if (itemType === TYPE_FOLDER) { - api.getFolderAPI().getFolderFields( - itemID, - handleGetItemSuccess, - {}, - { - fields: CONTENT_SHARING_ITEM_FIELDS, - }, - ); - } - }; + if (!api || isEmpty(api) || item || sharedLink) return; - if (api && !isEmpty(api) && !item && !sharedLink) { - getItem(); - } + (async () => { + const itemData = await fetchItem({ api, itemID, itemType }); + handleGetItemSuccess(itemData); + })(); }, [api, item, itemID, itemType, sharedLink, handleGetItemSuccess]); - // Get initial data for the user + // Get current user React.useEffect(() => { + if (!api || isEmpty(api) || item || sharedLink || currentUser) return; + const getUserSuccess = userData => { const { enterprise, id } = userData; setCurrentUser({ id, - enterprise: { - name: enterprise ? enterprise.name : '', - }, + enterprise: { name: enterprise ? enterprise.name : '' }, }); }; - const getUserData = () => { - api.getUsersAPI(false).getUser( - itemID, - getUserSuccess, - {}, - { - params: { - fields: [FIELD_ENTERPRISE, FIELD_HOSTNAME].toString(), - }, - }, - ); - }; + (async () => { + const userData = await fetchCurrentUser({ api, itemID }); + getUserSuccess(userData); + })(); + }, [api, currentUser, item, itemID, itemType, sharedLink]); + + // Get collaborators + React.useEffect(() => { + if (!api || isEmpty(api) || item || collaboratorsData) return; - if (api && !isEmpty(api) && item && sharedLink && !currentUser) { - getUserData(); + (async () => { + try { + const response = await fetchCollaborators({ api, itemID, itemType }); + setCollaboratorsData(response); + } catch { + setCollaboratorsData({ entries: [], next_marker: null }); + } + })(); + }, [api, collaboratorsData, item, itemID, itemType]); + + // Get avatars when collaborators are available + React.useEffect(() => { + if (avatarURLMap || !collaboratorsData || !collaboratorsData.entries) return; + + (async () => { + const response = await fetchAvatars({ api, itemID, collaborators: collaboratorsData.entries }); + setAvatarURLMap(response); + })(); + }, [api, avatarURLMap, collaboratorsData, itemID]); + + // Return processed data when both are ready + React.useEffect(() => { + if (collaboratorsData && avatarURLMap) { + const collaboratorsWithAvatars = convertCollabsResponse(collaboratorsData, avatarURLMap); + setCollaborators(collaboratorsWithAvatars); } - }, [api, currentUser, item, itemID, itemType, sharedLink]); + }, [collaboratorsData, avatarURLMap]); - const config = { - sharedLinkEmail: false, - }; + const config = { sharedLinkEmail: false }; return ( @@ -134,6 +140,7 @@ function ContentSharingV2({ => { + const usersAPI = api.getUsersAPI(false); + const avatarURLMap: AvatarURLMap = {}; + + const avatarPromises = collaborators.map(async collab => { + if (!collab?.accessible_by) return; + const { + accessible_by: { id: userID }, + } = collab; + try { + const url = await usersAPI.getAvatarUrlWithAccessToken(userID.toString(), itemID); + avatarURLMap[userID] = url; + } catch { + avatarURLMap[userID] = null; + } + }); + + await Promise.all(avatarPromises); + return avatarURLMap; +}; diff --git a/src/elements/content-sharing/apis/fetchCollaborators.ts b/src/elements/content-sharing/apis/fetchCollaborators.ts new file mode 100644 index 0000000000..972352ff0c --- /dev/null +++ b/src/elements/content-sharing/apis/fetchCollaborators.ts @@ -0,0 +1,21 @@ +import { TYPE_FILE, TYPE_FOLDER } from '../../../constants'; + +import type { Collaborations } from '../../../common/types/core'; +import type { FetchItemProps } from '../types'; + +export const fetchCollaborators = async ({ api, itemID, itemType }: FetchItemProps): Promise => { + let collabAPIInstance; + if (itemType === TYPE_FILE) { + collabAPIInstance = api.getFileCollaborationsAPI(false); + } else if (itemType === TYPE_FOLDER) { + collabAPIInstance = api.getFolderCollaborationsAPI(false); + } + + if (!collabAPIInstance) { + return null; + } + + return new Promise((resolve, reject) => { + collabAPIInstance.getCollaborations(itemID, resolve, reject); + }); +}; diff --git a/src/elements/content-sharing/apis/fetchCurrentUser.ts b/src/elements/content-sharing/apis/fetchCurrentUser.ts new file mode 100644 index 0000000000..116e2a3846 --- /dev/null +++ b/src/elements/content-sharing/apis/fetchCurrentUser.ts @@ -0,0 +1,15 @@ +import type { User } from '@box/unified-share-modal'; + +import { FIELD_ENTERPRISE, FIELD_HOSTNAME } from '../../../constants'; + +import type { BaseFetchProps } from '../types'; + +export const fetchCurrentUser = async ({ api, itemID }: BaseFetchProps): Promise => { + return new Promise((resolve, reject) => { + api.getUsersAPI(false).getUser(itemID, resolve, reject, { + params: { + fields: [FIELD_ENTERPRISE, FIELD_HOSTNAME].toString(), + }, + }); + }); +}; diff --git a/src/elements/content-sharing/apis/fetchItem.ts b/src/elements/content-sharing/apis/fetchItem.ts new file mode 100644 index 0000000000..ddbe776deb --- /dev/null +++ b/src/elements/content-sharing/apis/fetchItem.ts @@ -0,0 +1,22 @@ +import { Item } from '@box/unified-share-modal'; + +import { TYPE_FILE, TYPE_FOLDER } from '../../../constants'; +import { CONTENT_SHARING_ITEM_FIELDS } from '../constants'; + +import type { FetchItemProps } from '../types'; + +export const fetchItem = async ({ api, itemID, itemType }: FetchItemProps): Promise => { + if (itemType === TYPE_FILE) { + return new Promise((resolve, reject) => { + api.getFileAPI().getFile(itemID, resolve, reject, { fields: CONTENT_SHARING_ITEM_FIELDS }); + }); + } + + if (itemType === TYPE_FOLDER) { + return new Promise((resolve, reject) => { + api.getFolderAPI().getFolderFields(itemID, resolve, reject, { fields: CONTENT_SHARING_ITEM_FIELDS }); + }); + } + + return null; +}; diff --git a/src/elements/content-sharing/apis/index.ts b/src/elements/content-sharing/apis/index.ts new file mode 100644 index 0000000000..1a60730a40 --- /dev/null +++ b/src/elements/content-sharing/apis/index.ts @@ -0,0 +1,4 @@ +export { fetchAvatars } from './fetchAvatars'; +export { fetchCollaborators } from './fetchCollaborators'; +export { fetchCurrentUser } from './fetchCurrentUser'; +export { fetchItem } from './fetchItem'; diff --git a/src/elements/content-sharing/constants.js b/src/elements/content-sharing/constants.js index e6250c3ba9..6589024dfb 100644 --- a/src/elements/content-sharing/constants.js +++ b/src/elements/content-sharing/constants.js @@ -1,3 +1,23 @@ +import { + CLASSIFICATION_COLOR_ID_0, + CLASSIFICATION_COLOR_ID_1, + CLASSIFICATION_COLOR_ID_2, + CLASSIFICATION_COLOR_ID_3, + CLASSIFICATION_COLOR_ID_4, + CLASSIFICATION_COLOR_ID_5, + CLASSIFICATION_COLOR_ID_6, + CLASSIFICATION_COLOR_ID_7, +} from '../../features/classification/constants'; +import { + bdlDarkBlue50, + bdlGray20, + bdlGreenLight50, + bdlLightBlue50, + bdlOrange50, + bdlPurpleRain50, + bdlWatermelonRed50, + bdlYellow50, +} from '../../styles/variables'; import { FIELD_ALLOWED_INVITEE_ROLES, FIELD_ALLOWED_SHARED_LINK_ACCESS_LEVELS, @@ -46,3 +66,18 @@ export const CONTENT_SHARING_VIEWS = { SHARED_LINK_SETTINGS: 'SHARED_LINK_SETTINGS', UNIFIED_SHARE_MODAL: 'UNIFIED_SHARE_MODAL', }; + +export const API_TO_USM_CLASSIFICATION_COLORS_MAP = { + [bdlYellow50]: CLASSIFICATION_COLOR_ID_0, + [bdlOrange50]: CLASSIFICATION_COLOR_ID_1, + [bdlWatermelonRed50]: CLASSIFICATION_COLOR_ID_2, + [bdlPurpleRain50]: CLASSIFICATION_COLOR_ID_3, + [bdlLightBlue50]: CLASSIFICATION_COLOR_ID_4, + [bdlDarkBlue50]: CLASSIFICATION_COLOR_ID_5, + [bdlGreenLight50]: CLASSIFICATION_COLOR_ID_6, + [bdlGray20]: CLASSIFICATION_COLOR_ID_7, +}; + +export const ANYONE_WITH_LINK = 'peopleWithTheLink'; +export const ANYONE_IN_COMPANY = 'peopleInYourCompany'; +export const PEOPLE_IN_ITEM = 'peopleInThisItem'; diff --git a/src/elements/content-sharing/stories/tests/ContentSharingV2-visual.stories.tsx b/src/elements/content-sharing/stories/tests/ContentSharingV2-visual.stories.tsx index 3558c60975..1af370684c 100644 --- a/src/elements/content-sharing/stories/tests/ContentSharingV2-visual.stories.tsx +++ b/src/elements/content-sharing/stories/tests/ContentSharingV2-visual.stories.tsx @@ -1,6 +1,12 @@ import * as React from 'react'; +import { expect, screen, userEvent, within } from 'storybook/test'; + import { TYPE_FILE } from '../../../../constants'; -import { mockAPIWithSharedLink, mockAPIWithoutSharedLink } from '../../utils/__mocks__/ContentSharingV2Mocks'; +import { + mockAPIWithCollaborators, + mockAPIWithSharedLink, + mockAPIWithoutSharedLink, +} from '../../utils/__mocks__/ContentSharingV2Mocks'; import ContentSharingV2 from '../../ContentSharingV2'; export const withModernization = { @@ -8,12 +14,53 @@ export const withModernization = { api: mockAPIWithoutSharedLink, enableModernizedComponents: true, }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const button = await canvas.findByRole('button', { name: 'Open Unified Share Modal' }); + expect(button).toBeInTheDocument(); + await userEvent.click(button); + + expect(screen.getByRole('heading', { name: 'Share ‘Box Development Guide.pdf’' })).toBeVisible(); + expect(screen.getByRole('combobox', { name: 'Invite People' })).toBeVisible(); + expect(screen.getByRole('switch', { name: 'Shared link' })).toBeVisible(); + }, }; export const withSharedLink = { args: { api: mockAPIWithSharedLink, }, + play: async context => { + await withModernization.play(context); + expect(screen.getByLabelText('Shared link URL')).toBeVisible(); + expect(screen.getByRole('button', { name: 'Link Settings' })).toBeVisible(); + const peopleWithTheLinkButton = screen.getByRole('button', { name: 'People with the link' }); + expect(peopleWithTheLinkButton).toBeVisible(); + expect(screen.getByRole('button', { name: 'Can view and download' })).toBeVisible(); + + await userEvent.click(peopleWithTheLinkButton); + expect(screen.getByRole('menuitemcheckbox', { name: /Invited people only/ })).toHaveAttribute( + 'aria-disabled', + 'true', + ); + }, +}; + +export const withCollaborators = { + args: { + api: mockAPIWithCollaborators, + }, + play: async context => { + await withModernization.play(context); + const sharedWithAvatars = screen.getByRole('button', { name: 'Shared with D R D' }); + expect(sharedWithAvatars).toBeVisible(); + await userEvent.click(sharedWithAvatars); + + expect(screen.getByRole('link', { name: 'Manage All' })).toBeVisible(); + expect(screen.getByRole('grid', { name: 'Collaborators' })).toBeVisible(); + expect(screen.getByRole('row', { name: /Detective Parrot/ })).toBeVisible(); + expect(screen.getByRole('button', { name: 'Done' })).toBeVisible(); + }, }; export default { diff --git a/src/elements/content-sharing/types.js b/src/elements/content-sharing/types.js index 130328a442..bee8ac12fe 100644 --- a/src/elements/content-sharing/types.js +++ b/src/elements/content-sharing/types.js @@ -1,6 +1,7 @@ // @flow import type { CollaborationRole, Item, SharedLink } from '@box/unified-share-modal'; +import API from '../../api'; import type { Access, BoxItemClassification, @@ -160,3 +161,16 @@ export interface ItemData { item: Item; sharedLink: SharedLink; } + +export interface BaseFetchProps { + api: API; + itemID: string; +} + +export interface FetchItemProps extends BaseFetchProps { + itemType: ItemType | Collaboration[]; +} + +export interface FetchCollaboratorsProps extends BaseFetchProps { + collaborators: Collaboration[]; +} diff --git a/src/elements/content-sharing/utils/__mocks__/ContentSharingV2Mocks.js b/src/elements/content-sharing/utils/__mocks__/ContentSharingV2Mocks.js index fd0eeb6717..93c465a373 100644 --- a/src/elements/content-sharing/utils/__mocks__/ContentSharingV2Mocks.js +++ b/src/elements/content-sharing/utils/__mocks__/ContentSharingV2Mocks.js @@ -27,16 +27,71 @@ export const MOCK_SHARED_LINK = { vanity_url: 'https://example.com/vanity-url', }; +export const mockAvatarURLMap = { + 456: 'https://example.com/avatar.jpg', +}; + +export const mockOwnerEmail = 'aotter@example.com'; + +export const mockCurrentUserID = 789; + +export const collabUser1 = { + id: 456, + login: 'dparrot@example.com', + name: 'Detective Parrot', + type: 'user', +}; + +export const collabUser2 = { + id: 457, + login: 'rqueen@example.com', + name: 'Raccoon Queen', + type: 'user', +}; + +export const collabUser3 = { + id: 458, + login: 'dpenguin@example.com', + name: 'Dancing Penguin', + type: 'user', +}; + +export const MOCK_COLLABORATORS = [collabUser1, collabUser2, collabUser3]; + +export const MOCK_COLLABORATIONS_RESPONSE = { + entries: MOCK_COLLABORATORS.map(user => ({ + id: `record_${user.id}`, + accessible_by: user, + expires_at: user.expires_at, + created_by: { + id: mockCurrentUserID, + login: mockOwnerEmail, + name: 'Astronaut Otter', + type: 'user', + }, + role: 'editor', + status: 'accepted', + type: user.type, + })), +}; + +export const EMPTY_COLLABORATIONS_RESPONSE = { + entries: [], +}; + export const DEFAULT_USER_API_RESPONSE = { - id: '123', + id: '789', enterprise: { - name: 'Parrot Enterprise', + name: 'Otter Enterprise', }, }; export const DEFAULT_ITEM_API_RESPONSE = { allowed_invitee_roles: ['editor', 'viewer'], allowed_shared_link_access_levels: ['open', 'company', 'collaborators'], + allowed_shared_link_access_levels_disabled_reasons: { + peopleInThisItem: 'access_policy', + }, classification: null, id: MOCK_ITEM.id, name: MOCK_ITEM.name, @@ -51,16 +106,24 @@ export const MOCK_ITEM_API_RESPONSE_WITH_SHARED_LINK = { shared_link: MOCK_SHARED_LINK, }; +export const MOCK_ITEM_API_RESPONSE_WITH_COLLABORATORS = { + ...DEFAULT_ITEM_API_RESPONSE, + collaborators: MOCK_COLLABORATORS, +}; + export const MOCK_ITEM_API_RESPONSE_WITH_CLASSIFICATION = { ...DEFAULT_ITEM_API_RESPONSE, classification: MOCK_CLASSIFICATION, }; // Mock API class for ContentSharingV2 storybook -export const createMockAPI = (itemResponse = DEFAULT_ITEM_API_RESPONSE, userResponse = DEFAULT_USER_API_RESPONSE) => { +export const createMockAPI = ( + itemResponse = DEFAULT_ITEM_API_RESPONSE, + userResponse = DEFAULT_USER_API_RESPONSE, + collaboratorsResponse = MOCK_COLLABORATIONS_RESPONSE, +) => { const mockFileAPI = { getFile: (itemID, successCallback) => { - // Simulate async behavior setTimeout(() => { successCallback(itemResponse); }, 100); @@ -69,7 +132,6 @@ export const createMockAPI = (itemResponse = DEFAULT_ITEM_API_RESPONSE, userResp const mockFolderAPI = { getFolderFields: (itemID, successCallback) => { - // Simulate async behavior setTimeout(() => { successCallback(itemResponse); }, 100); @@ -78,20 +140,50 @@ export const createMockAPI = (itemResponse = DEFAULT_ITEM_API_RESPONSE, userResp const mockUsersAPI = { getUser: (itemID, successCallback) => { - // Simulate async behavior setTimeout(() => { successCallback(userResponse); }, 100); }, }; + const getFileCollaborationsAPI = () => ({ + getCollaborations: (itemID, successCallback) => { + setTimeout(() => { + successCallback(collaboratorsResponse); + }, 100); + }, + }); + + const getFolderCollaborationsAPI = () => ({ + getCollaborations: (itemID, successCallback) => { + setTimeout(() => { + successCallback(collaboratorsResponse); + }, 100); + }, + }); + return { getFileAPI: () => mockFileAPI, getFolderAPI: () => mockFolderAPI, getUsersAPI: () => mockUsersAPI, + getFileCollaborationsAPI, + getFolderCollaborationsAPI, }; }; // Pre-configured mock APIs for different scenarios -export const mockAPIWithSharedLink = createMockAPI(MOCK_ITEM_API_RESPONSE_WITH_SHARED_LINK); -export const mockAPIWithoutSharedLink = createMockAPI(DEFAULT_ITEM_API_RESPONSE); +export const mockAPIWithSharedLink = createMockAPI( + MOCK_ITEM_API_RESPONSE_WITH_SHARED_LINK, + DEFAULT_USER_API_RESPONSE, + EMPTY_COLLABORATIONS_RESPONSE, +); +export const mockAPIWithoutSharedLink = createMockAPI( + DEFAULT_ITEM_API_RESPONSE, + DEFAULT_USER_API_RESPONSE, + EMPTY_COLLABORATIONS_RESPONSE, +); +export const mockAPIWithCollaborators = createMockAPI( + MOCK_ITEM_API_RESPONSE_WITH_COLLABORATORS, + DEFAULT_USER_API_RESPONSE, + MOCK_COLLABORATIONS_RESPONSE, +); diff --git a/src/elements/content-sharing/utils/__tests__/convertCollaborators.test.ts b/src/elements/content-sharing/utils/__tests__/convertCollaborators.test.ts new file mode 100644 index 0000000000..c478bd5f08 --- /dev/null +++ b/src/elements/content-sharing/utils/__tests__/convertCollaborators.test.ts @@ -0,0 +1,248 @@ +import { STATUS_ACCEPTED } from '../../../../constants'; +import { convertCollab, convertCollabsResponse } from '../convertCollaborators'; +import { + collabUser1, + collabUser2, + collabUser3, + mockAvatarURLMap, + mockCurrentUserID, + mockOwnerEmail, +} from '../__mocks__/ContentSharingV2Mocks'; + +import type { Collaboration, Collaborations } from '../../../../common/types/core'; + +describe('convertCollaborators', () => { + describe('convertCollab', () => { + const mockCollab: Collaboration = { + id: '123', // collab record id + role: 'editor', + status: STATUS_ACCEPTED, + expires_at: '2024-12-31T23:59:59Z', + accessible_by: collabUser1, + }; + + test('should convert a valid collaboration to Collaborator format', () => { + const result = convertCollab({ + collab: mockCollab, + avatarURLMap: mockAvatarURLMap, + ownerEmail: mockOwnerEmail, + currentUserID: mockCurrentUserID, + }); + + expect(result).toEqual({ + avatarUrl: 'https://example.com/avatar.jpg', + email: 'dparrot@example.com', + expiresAt: '2024-12-31T23:59:59Z', + hasCustomAvatar: true, + hasCustomRole: true, + id: '123', + isCurrentUser: false, + isExternal: false, + isPending: false, + name: 'Detective Parrot', + role: 'Editor', + userId: '456', + }); + }); + + test('should return null for collaboration with non-accepted status', () => { + const pendingCollab = { + ...mockCollab, + status: 'pending', + }; + + const result = convertCollab({ + collab: pendingCollab, + avatarURLMap: mockAvatarURLMap, + ownerEmail: mockOwnerEmail, + currentUserID: mockCurrentUserID, + }); + + expect(result).toBeNull(); + }); + + test.each([undefined, null])('should return null for %s collaboration', collab => { + const result = convertCollab({ + collab, + avatarURLMap: mockAvatarURLMap, + ownerEmail: mockOwnerEmail, + currentUserID: mockCurrentUserID, + }); + + expect(result).toBeNull(); + }); + + test('should identify current user correctly', () => { + const currentUserCollab = { + ...mockCollab, + role: 'owner', + accessible_by: { + ...mockCollab.accessible_by, + id: mockCurrentUserID, + login: mockOwnerEmail, + name: 'Astronaut Otter', + }, + }; + + const result = convertCollab({ + collab: currentUserCollab, + avatarURLMap: mockAvatarURLMap, + ownerEmail: mockOwnerEmail, + currentUserID: mockCurrentUserID, + }); + + expect(result).toEqual({ + avatarUrl: undefined, + email: 'aotter@example.com', + expiresAt: '2024-12-31T23:59:59Z', + hasCustomAvatar: false, + hasCustomRole: true, + id: '123', + isCurrentUser: true, + isExternal: false, + isPending: false, + name: 'Astronaut Otter', + role: 'Owner', + userId: '789', + }); + }); + + test('should identify external user correctly', () => { + const externalCollab = { + ...mockCollab, + accessible_by: { + ...mockCollab.accessible_by, + login: 'external@differentdomain.com', + }, + }; + + const result = convertCollab({ + collab: externalCollab, + avatarURLMap: mockAvatarURLMap, + ownerEmail: mockOwnerEmail, + currentUserID: mockCurrentUserID, + }); + + expect(result.isExternal).toBe(true); + }); + + test.each([null, undefined, {}, { 999: 'https://example.com/different-user-avatar.jpg' }])( + 'should handle %s avatar URL map', + avatarURLMap => { + const result = convertCollab({ + collab: mockCollab, + avatarURLMap, + ownerEmail: mockOwnerEmail, + currentUserID: mockCurrentUserID, + }); + + expect(result.avatarUrl).toBeUndefined(); + expect(result.hasCustomAvatar).toBe(false); + }, + ); + + test('should handle missing expiration date', () => { + const collabWithoutExpiration = { + ...mockCollab, + expires_at: null, + }; + + const result = convertCollab({ + collab: collabWithoutExpiration, + avatarURLMap: mockAvatarURLMap, + ownerEmail: mockOwnerEmail, + currentUserID: mockCurrentUserID, + }); + + expect(result.expiresAt).toBeNull(); + }); + }); + + describe('convertCollabsResponse', () => { + const created_by = { + id: mockCurrentUserID, + login: mockOwnerEmail, + }; + const mockCollaborations: Collaborations = { + entries: [ + { + id: '123', + role: 'editor', + status: STATUS_ACCEPTED, + expires_at: '2024-12-31T23:59:59Z', + accessible_by: collabUser1, + created_by, + }, + { + id: '124', + role: 'viewer', + status: STATUS_ACCEPTED, + expires_at: null, + accessible_by: collabUser2, + created_by, + }, + { + id: '125', + role: 'editor', + status: 'pending', + expires_at: '2024-12-31T23:59:59Z', + accessible_by: collabUser3, + created_by, + }, + ], + }; + + test('should convert valid collaborations data to Collaborator array', () => { + const result = convertCollabsResponse(mockCollaborations, mockAvatarURLMap); + + expect(result).toHaveLength(2); // Only accepted collaborations + expect(result).toEqual([ + { + avatarUrl: 'https://example.com/avatar.jpg', + email: 'dparrot@example.com', + expiresAt: '2024-12-31T23:59:59Z', + hasCustomAvatar: true, + hasCustomRole: true, + id: '123', + isCurrentUser: false, + isExternal: false, + isPending: false, + name: 'Detective Parrot', + role: 'Editor', + userId: '456', + }, + { + avatarUrl: undefined, // does not exist in the avatar URL map + email: 'rqueen@example.com', + expiresAt: null, + hasCustomAvatar: false, + hasCustomRole: true, + id: '124', + isCurrentUser: false, + isExternal: false, + isPending: false, + name: 'Raccoon Queen', + role: 'Viewer', + userId: '457', + }, + ]); + }); + + test('should return empty array for empty entries', () => { + const emptyCollaborations: Collaborations = { entries: [] }; + const result = convertCollabsResponse(emptyCollaborations, mockAvatarURLMap); + + expect(result).toEqual([]); + }); + + test('should handle null avatar URL map', () => { + const result = convertCollabsResponse(mockCollaborations, null); + + expect(result).toHaveLength(2); + expect(result[0].avatarUrl).toBeUndefined(); + expect(result[0].hasCustomAvatar).toBe(false); + expect(result[1].avatarUrl).toBeUndefined(); + expect(result[1].hasCustomAvatar).toBe(false); + }); + }); +}); diff --git a/src/elements/content-sharing/utils/__tests__/getAllowedAccessLevels.test.ts b/src/elements/content-sharing/utils/__tests__/getAllowedAccessLevels.test.ts index 169594487d..1086446111 100644 --- a/src/elements/content-sharing/utils/__tests__/getAllowedAccessLevels.test.ts +++ b/src/elements/content-sharing/utils/__tests__/getAllowedAccessLevels.test.ts @@ -21,4 +21,24 @@ describe('getAllowedAccessLevels', () => { const result = getAllowedAccessLevels(levels); expect(result).toEqual(levels); }); + + describe('when disabled reasons are provided', () => { + test('should return the disabled reasons', () => { + const result = getAllowedAccessLevels([ACCESS_OPEN], { + peopleWithTheLink: 'access_policy', + }); + expect(result).toEqual([{ id: ACCESS_OPEN, disabledReason: 'access_policy' }]); + }); + + test('should return the disabled reason for the associated level', () => { + const result = getAllowedAccessLevels([ACCESS_OPEN, ACCESS_COMPANY, ACCESS_COLLAB], { + peopleInThisItem: 'access_policy', + }); + expect(result).toEqual([ + ACCESS_OPEN, + ACCESS_COMPANY, + { id: ACCESS_COLLAB, disabledReason: 'access_policy' }, + ]); + }); + }); }); diff --git a/src/elements/content-sharing/utils/constants.ts b/src/elements/content-sharing/utils/constants.ts deleted file mode 100644 index 71ee401eab..0000000000 --- a/src/elements/content-sharing/utils/constants.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { - CLASSIFICATION_COLOR_ID_0, - CLASSIFICATION_COLOR_ID_1, - CLASSIFICATION_COLOR_ID_2, - CLASSIFICATION_COLOR_ID_3, - CLASSIFICATION_COLOR_ID_4, - CLASSIFICATION_COLOR_ID_5, - CLASSIFICATION_COLOR_ID_6, - CLASSIFICATION_COLOR_ID_7, -} from '../../../features/classification/constants'; -import { - bdlDarkBlue50, - bdlGray20, - bdlGreenLight50, - bdlLightBlue50, - bdlOrange50, - bdlPurpleRain50, - bdlWatermelonRed50, - bdlYellow50, -} from '../../../styles/variables'; - -export const API_TO_USM_CLASSIFICATION_COLORS_MAP = { - [bdlYellow50]: CLASSIFICATION_COLOR_ID_0, - [bdlOrange50]: CLASSIFICATION_COLOR_ID_1, - [bdlWatermelonRed50]: CLASSIFICATION_COLOR_ID_2, - [bdlPurpleRain50]: CLASSIFICATION_COLOR_ID_3, - [bdlLightBlue50]: CLASSIFICATION_COLOR_ID_4, - [bdlDarkBlue50]: CLASSIFICATION_COLOR_ID_5, - [bdlGreenLight50]: CLASSIFICATION_COLOR_ID_6, - [bdlGray20]: CLASSIFICATION_COLOR_ID_7, -}; diff --git a/src/elements/content-sharing/utils/convertCollaborators.ts b/src/elements/content-sharing/utils/convertCollaborators.ts new file mode 100644 index 0000000000..c9437a1a6a --- /dev/null +++ b/src/elements/content-sharing/utils/convertCollaborators.ts @@ -0,0 +1,63 @@ +import { Collaborator } from '@box/unified-share-modal'; + +import { STATUS_ACCEPTED } from '../../../constants'; + +import type { Collaboration, Collaborations } from '../../../common/types/core'; +import type { AvatarURLMap } from '../types'; + +export interface ConvertCollabProps { + collab: Collaboration; + currentUserID: number; + ownerEmail: string; + avatarURLMap?: AvatarURLMap; +} + +export const convertCollab = ({ + collab, + currentUserID, + ownerEmail, + avatarURLMap, +}: ConvertCollabProps): Collaborator | null => { + if (!collab || collab.status !== STATUS_ACCEPTED) return null; + + const { + accessible_by: { id: collabId, login: collabEmail, name: collabName }, + id, + expires_at: executeAt, + role, + } = collab; + + const isCurrentUser = collabId === currentUserID; + const ownerEmailDomain = ownerEmail && /@/.test(ownerEmail) ? ownerEmail.split('@')[1] : null; + const isExternal = + !isCurrentUser && collabEmail && ownerEmailDomain && collabEmail.split('@')[1] !== ownerEmailDomain; + const avatarUrl = avatarURLMap ? avatarURLMap[collabId] : undefined; + + return { + avatarUrl, + email: collabEmail, + expiresAt: executeAt, + hasCustomAvatar: !!avatarUrl, + hasCustomRole: !!role, + id: id.toString(), + isCurrentUser, + isExternal, + isPending: false, + name: collabName, + role: `${role[0].toUpperCase()}${role.slice(1)}`, + userId: collabId.toString(), + }; +}; + +export const convertCollabsResponse = (collabsAPIData: Collaborations, avatarURLMap?: AvatarURLMap): Collaborator[] => { + const { entries = [] } = collabsAPIData; + if (!entries.length) return []; + + const { + created_by: { id: currentUserID, login: ownerEmail }, + } = entries[0]; + return entries.flatMap(collab => { + const converted = convertCollab({ collab, currentUserID, ownerEmail, avatarURLMap }); + return converted ? [converted] : []; + }); +}; diff --git a/src/elements/content-sharing/utils/convertItemResponse.ts b/src/elements/content-sharing/utils/convertItemResponse.ts index 060ee982b4..778f463f80 100644 --- a/src/elements/content-sharing/utils/convertItemResponse.ts +++ b/src/elements/content-sharing/utils/convertItemResponse.ts @@ -1,7 +1,7 @@ import { ACCESS_COLLAB, INVITEE_ROLE_EDITOR, PERMISSION_CAN_DOWNLOAD } from '../../../constants'; +import { API_TO_USM_CLASSIFICATION_COLORS_MAP } from '../constants'; import { getAllowedAccessLevels } from './getAllowedAccessLevels'; import { getAllowedPermissionLevels } from './getAllowedPermissionLevels'; -import { API_TO_USM_CLASSIFICATION_COLORS_MAP } from '../utils/constants'; import type { ContentSharingItemAPIResponse, ItemData } from '../types'; @@ -9,6 +9,7 @@ export const convertItemResponse = (itemAPIData: ContentSharingItemAPIResponse): const { allowed_invitee_roles, allowed_shared_link_access_levels, + allowed_shared_link_access_levels_disabled_reasons, classification, id, name, @@ -59,7 +60,10 @@ export const convertItemResponse = (itemAPIData: ContentSharingItemAPIResponse): sharedLink = { access, - accessLevels: getAllowedAccessLevels(allowed_shared_link_access_levels), + accessLevels: getAllowedAccessLevels( + allowed_shared_link_access_levels, + allowed_shared_link_access_levels_disabled_reasons, + ), expiresAt: expirationTimestamp, permission, permissionLevels: getAllowedPermissionLevels(canChangeAccessLevel, isDownloadSettingAvailable, permission), diff --git a/src/elements/content-sharing/utils/getAllowedAccessLevels.ts b/src/elements/content-sharing/utils/getAllowedAccessLevels.ts index 8e2602371f..342b318bbb 100644 --- a/src/elements/content-sharing/utils/getAllowedAccessLevels.ts +++ b/src/elements/content-sharing/utils/getAllowedAccessLevels.ts @@ -1,6 +1,35 @@ +import type { AccessLevel, AccessLevelType } from '@box/unified-share-modal'; + import { ACCESS_COLLAB, ACCESS_COMPANY, ACCESS_OPEN } from '../../../constants'; +import { ANYONE_IN_COMPANY, ANYONE_WITH_LINK, PEOPLE_IN_ITEM } from '../constants'; + +import type { accessLevelsDisabledReasonType } from '../../../features/unified-share-modal/flowTypes'; + +export const API_TO_USM_ACCESS_LEVEL_MAP = { + [ACCESS_COMPANY]: ANYONE_IN_COMPANY, + [ACCESS_OPEN]: ANYONE_WITH_LINK, + [ACCESS_COLLAB]: PEOPLE_IN_ITEM, +}; + +export const getAllowedAccessLevels = ( + levels?: AccessLevelType[], + disabledReasons?: accessLevelsDisabledReasonType, +): (AccessLevel | AccessLevelType)[] | null => { + if (!levels) { + levels = [ACCESS_OPEN, ACCESS_COMPANY, ACCESS_COLLAB]; + } + + return levels.map(level => { + const apiLevel = API_TO_USM_ACCESS_LEVEL_MAP[level]; + const disabledReason = disabledReasons?.[apiLevel]; + + if (disabledReason) { + return { + id: level as AccessLevelType, + disabledReason, + }; + } -export const getAllowedAccessLevels = (levels?: Array): Array | null => { - if (!levels) return [ACCESS_OPEN, ACCESS_COMPANY, ACCESS_COLLAB]; - return [...levels]; + return level; + }); }; diff --git a/src/elements/content-sharing/utils/index.ts b/src/elements/content-sharing/utils/index.ts index 38b674e241..0e191c227a 100644 --- a/src/elements/content-sharing/utils/index.ts +++ b/src/elements/content-sharing/utils/index.ts @@ -1,3 +1,4 @@ export { convertItemResponse } from './convertItemResponse'; +export { convertCollabsResponse } from './convertCollaborators'; export { getAllowedAccessLevels } from './getAllowedAccessLevels'; export { getAllowedPermissionLevels } from './getAllowedPermissionLevels'; From 9f49c4fe2f165b1a6bec767dbbbce29aa07b20d0 Mon Sep 17 00:00:00 2001 From: reneshen0328 Date: Wed, 1 Oct 2025 18:11:22 -0700 Subject: [PATCH 2/8] feat(content-sharing): Convert api response for collaborators --- .../__tests__/ContentSharingV2.test.tsx | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/src/elements/content-sharing/__tests__/ContentSharingV2.test.tsx b/src/elements/content-sharing/__tests__/ContentSharingV2.test.tsx index f8165d9064..5772825a2e 100644 --- a/src/elements/content-sharing/__tests__/ContentSharingV2.test.tsx +++ b/src/elements/content-sharing/__tests__/ContentSharingV2.test.tsx @@ -58,14 +58,9 @@ describe('elements/content-sharing/ContentSharingV2', () => { test('should see the correct elements for files', async () => { getWrapper({}); await waitFor(() => { - expect(getDefaultFileMock).toHaveBeenCalledWith( - MOCK_ITEM.id, - expect.any(Function), - {}, - { - fields: CONTENT_SHARING_ITEM_FIELDS, - }, - ); + expect(getDefaultFileMock).toHaveBeenCalledWith(MOCK_ITEM.id, expect.any(Function), expect.any(Function), { + fields: CONTENT_SHARING_ITEM_FIELDS, + }); }); expect(screen.getByRole('heading', { name: 'Share ‘Box Development Guide.pdf’' })).toBeVisible(); @@ -79,7 +74,7 @@ describe('elements/content-sharing/ContentSharingV2', () => { expect(getDefaultFolderMock).toHaveBeenCalledWith( MOCK_ITEM.id, expect.any(Function), - {}, + expect.any(Function), { fields: CONTENT_SHARING_ITEM_FIELDS, }, @@ -99,7 +94,7 @@ describe('elements/content-sharing/ContentSharingV2', () => { expect(getFileMockWithSharedLink).toHaveBeenCalledWith( MOCK_ITEM.id, expect.any(Function), - {}, + expect.any(Function), { fields: CONTENT_SHARING_ITEM_FIELDS, }, @@ -121,7 +116,7 @@ describe('elements/content-sharing/ContentSharingV2', () => { expect(getFileMockWithClassification).toHaveBeenCalledWith( MOCK_ITEM.id, expect.any(Function), - {}, + expect.any(Function), { fields: CONTENT_SHARING_ITEM_FIELDS, }, From a7ecc91b16a4784d2b073162a8775c09fd8dcbed Mon Sep 17 00:00:00 2001 From: reneshen0328 Date: Wed, 1 Oct 2025 18:49:00 -0700 Subject: [PATCH 3/8] fix: nits --- src/elements/content-sharing/ContentSharingV2.tsx | 1 - src/elements/content-sharing/apis/fetchItem.ts | 2 +- src/elements/content-sharing/types.js | 2 +- .../utils/__mocks__/ContentSharingV2Mocks.js | 1 + src/elements/content-sharing/utils/convertCollaborators.ts | 7 +++---- 5 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/elements/content-sharing/ContentSharingV2.tsx b/src/elements/content-sharing/ContentSharingV2.tsx index df106de28d..40dbe641d3 100644 --- a/src/elements/content-sharing/ContentSharingV2.tsx +++ b/src/elements/content-sharing/ContentSharingV2.tsx @@ -49,7 +49,6 @@ function ContentSharingV2({ // Handle successful GET requests to /files or /folders const handleGetItemSuccess = React.useCallback(itemData => { - console.log('handleGetItemSuccess itemData', itemData); const { collaborationRoles: collaborationRolesFromAPI, item: itemFromAPI, diff --git a/src/elements/content-sharing/apis/fetchItem.ts b/src/elements/content-sharing/apis/fetchItem.ts index ddbe776deb..d655e8b4cd 100644 --- a/src/elements/content-sharing/apis/fetchItem.ts +++ b/src/elements/content-sharing/apis/fetchItem.ts @@ -5,7 +5,7 @@ import { CONTENT_SHARING_ITEM_FIELDS } from '../constants'; import type { FetchItemProps } from '../types'; -export const fetchItem = async ({ api, itemID, itemType }: FetchItemProps): Promise => { +export const fetchItem = async ({ api, itemID, itemType }: FetchItemProps): Promise => { if (itemType === TYPE_FILE) { return new Promise((resolve, reject) => { api.getFileAPI().getFile(itemID, resolve, reject, { fields: CONTENT_SHARING_ITEM_FIELDS }); diff --git a/src/elements/content-sharing/types.js b/src/elements/content-sharing/types.js index bee8ac12fe..36ccb04004 100644 --- a/src/elements/content-sharing/types.js +++ b/src/elements/content-sharing/types.js @@ -168,7 +168,7 @@ export interface BaseFetchProps { } export interface FetchItemProps extends BaseFetchProps { - itemType: ItemType | Collaboration[]; + itemType: ItemType; } export interface FetchCollaboratorsProps extends BaseFetchProps { diff --git a/src/elements/content-sharing/utils/__mocks__/ContentSharingV2Mocks.js b/src/elements/content-sharing/utils/__mocks__/ContentSharingV2Mocks.js index 93c465a373..4faf552ea1 100644 --- a/src/elements/content-sharing/utils/__mocks__/ContentSharingV2Mocks.js +++ b/src/elements/content-sharing/utils/__mocks__/ContentSharingV2Mocks.js @@ -144,6 +144,7 @@ export const createMockAPI = ( successCallback(userResponse); }, 100); }, + getAvatarUrlWithAccessToken: userID => mockAvatarURLMap[userID] ?? null, }; const getFileCollaborationsAPI = () => ({ diff --git a/src/elements/content-sharing/utils/convertCollaborators.ts b/src/elements/content-sharing/utils/convertCollaborators.ts index c9437a1a6a..843f2e3bfd 100644 --- a/src/elements/content-sharing/utils/convertCollaborators.ts +++ b/src/elements/content-sharing/utils/convertCollaborators.ts @@ -44,7 +44,7 @@ export const convertCollab = ({ isExternal, isPending: false, name: collabName, - role: `${role[0].toUpperCase()}${role.slice(1)}`, + role: role ? `${role[0].toUpperCase()}${role.slice(1)}` : '', userId: collabId.toString(), }; }; @@ -53,9 +53,8 @@ export const convertCollabsResponse = (collabsAPIData: Collaborations, avatarURL const { entries = [] } = collabsAPIData; if (!entries.length) return []; - const { - created_by: { id: currentUserID, login: ownerEmail }, - } = entries[0]; + const { created_by } = entries[0]; + const { id: currentUserID, login: ownerEmail } = created_by || {}; return entries.flatMap(collab => { const converted = convertCollab({ collab, currentUserID, ownerEmail, avatarURLMap }); return converted ? [converted] : []; From f28bde09ad84dc8fc512b80778ddd1536900fbdf Mon Sep 17 00:00:00 2001 From: reneshen0328 Date: Thu, 2 Oct 2025 13:29:57 -0700 Subject: [PATCH 4/8] fix: add test coverage --- .../apis/__tests__/fetchAvatars.test.ts | 94 +++++++++++++++++++ .../apis/__tests__/fetchCollaborators.test.ts | 44 +++++++++ .../apis/__tests__/fetchCurrentUser.test.ts | 33 +++++++ .../apis/__tests__/fetchItem.test.ts | 50 ++++++++++ .../apis/__tests__/testUtils.ts | 23 +++++ 5 files changed, 244 insertions(+) create mode 100644 src/elements/content-sharing/apis/__tests__/fetchAvatars.test.ts create mode 100644 src/elements/content-sharing/apis/__tests__/fetchCollaborators.test.ts create mode 100644 src/elements/content-sharing/apis/__tests__/fetchCurrentUser.test.ts create mode 100644 src/elements/content-sharing/apis/__tests__/fetchItem.test.ts create mode 100644 src/elements/content-sharing/apis/__tests__/testUtils.ts diff --git a/src/elements/content-sharing/apis/__tests__/fetchAvatars.test.ts b/src/elements/content-sharing/apis/__tests__/fetchAvatars.test.ts new file mode 100644 index 0000000000..b9d33c4cec --- /dev/null +++ b/src/elements/content-sharing/apis/__tests__/fetchAvatars.test.ts @@ -0,0 +1,94 @@ +import { DEFAULT_USER_API_RESPONSE, MOCK_ITEM } from '../../utils/__mocks__/ContentSharingV2Mocks'; +import { fetchAvatars } from '..'; +import { createSuccessMock, createUsersAPIMock } from './testUtils'; + +const getAvatarUrlMock = jest.fn(); +const getDefaultUserMock = jest.fn().mockImplementation(createSuccessMock(DEFAULT_USER_API_RESPONSE)); +const defaultAPIMock = createUsersAPIMock({ + getUser: getDefaultUserMock, + getAvatarUrlWithAccessToken: getAvatarUrlMock, +}); + +const mockCollaborations = [ + { accessible_by: { id: 123 } }, + { accessible_by: { id: 456 } }, + { accessible_by: { id: 789 } }, +]; + +describe('content-sharing/apis/fetchAvatars', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('should fetch avatars successfully', async () => { + getAvatarUrlMock + .mockResolvedValueOnce('https://example.com/avatar1.jpg') + .mockResolvedValueOnce('https://example.com/avatar2.jpg') + .mockResolvedValueOnce('https://example.com/avatar3.jpg'); + + const result = await fetchAvatars({ + api: defaultAPIMock, + itemID: MOCK_ITEM.id, + collaborators: mockCollaborations, + }); + + expect(defaultAPIMock.getUsersAPI).toHaveBeenCalledWith(false); + expect(getAvatarUrlMock).toHaveBeenCalledTimes(3); + expect(getAvatarUrlMock).toHaveBeenCalledWith('123', MOCK_ITEM.id); + expect(getAvatarUrlMock).toHaveBeenCalledWith('456', MOCK_ITEM.id); + expect(getAvatarUrlMock).toHaveBeenCalledWith('789', MOCK_ITEM.id); + expect(result).toEqual({ + 123: 'https://example.com/avatar1.jpg', + 456: 'https://example.com/avatar2.jpg', + 789: 'https://example.com/avatar3.jpg', + }); + }); + + test('should handle avatar fetch errors gracefully', async () => { + getAvatarUrlMock + .mockResolvedValueOnce('https://example.com/avatar1.jpg') + .mockRejectedValueOnce(new Error('Avatar fetch failed')) + .mockResolvedValueOnce('https://example.com/avatar3.jpg'); + + const result = await fetchAvatars({ + api: defaultAPIMock, + itemID: MOCK_ITEM.id, + collaborators: mockCollaborations, + }); + + expect(result).toEqual({ + 123: 'https://example.com/avatar1.jpg', + 456: null, + 789: 'https://example.com/avatar3.jpg', + }); + }); + + test('should handle collaborators without accessible_by', async () => { + const collaboratorsWithMissingData = [{ accessible_by: { id: 123 } }, {}, { accessible_by: null }]; + + getAvatarUrlMock.mockResolvedValue('https://example.com/avatar.jpg'); + + const result = await fetchAvatars({ + api: defaultAPIMock, + itemID: MOCK_ITEM.id, + collaborators: collaboratorsWithMissingData, + }); + + expect(getAvatarUrlMock).toHaveBeenCalledTimes(1); + expect(getAvatarUrlMock).toHaveBeenCalledWith('123', MOCK_ITEM.id); + expect(result).toEqual({ + 123: 'https://example.com/avatar.jpg', + }); + }); + + test('should handle empty collaborators array', async () => { + const result = await fetchAvatars({ + api: defaultAPIMock, + itemID: MOCK_ITEM.id, + collaborators: [], + }); + + expect(getAvatarUrlMock).not.toHaveBeenCalled(); + expect(result).toEqual({}); + }); +}); diff --git a/src/elements/content-sharing/apis/__tests__/fetchCollaborators.test.ts b/src/elements/content-sharing/apis/__tests__/fetchCollaborators.test.ts new file mode 100644 index 0000000000..d2612b0028 --- /dev/null +++ b/src/elements/content-sharing/apis/__tests__/fetchCollaborators.test.ts @@ -0,0 +1,44 @@ +import { MOCK_COLLABORATIONS_RESPONSE, MOCK_ITEM } from '../../utils/__mocks__/ContentSharingV2Mocks'; +import { TYPE_FILE, TYPE_FOLDER } from '../../../../constants'; +import { fetchCollaborators } from '..'; +import { createSuccessMock, createCollabAPIMock } from './testUtils'; + +const getDefaultFileCollabMock = jest.fn().mockImplementation(createSuccessMock(MOCK_COLLABORATIONS_RESPONSE)); +const getDefaultFolderCollabMock = jest.fn().mockImplementation(createSuccessMock(MOCK_COLLABORATIONS_RESPONSE)); +const defaultAPIMock = createCollabAPIMock( + { getCollaborations: getDefaultFileCollabMock }, + { getCollaborations: getDefaultFolderCollabMock }, +); + +describe('content-sharing/apis/fetchCollaborators', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('should fetch file collaborators successfully', async () => { + const result = await fetchCollaborators({ api: defaultAPIMock, itemID: MOCK_ITEM.id, itemType: TYPE_FILE }); + + expect(defaultAPIMock.getFileCollaborationsAPI).toHaveBeenCalledWith(false); + expect(getDefaultFileCollabMock).toHaveBeenCalledWith(MOCK_ITEM.id, expect.any(Function), expect.any(Function)); + expect(result).toEqual(MOCK_COLLABORATIONS_RESPONSE); + }); + + test('should fetch folder collaborators successfully', async () => { + await fetchCollaborators({ api: defaultAPIMock, itemID: MOCK_ITEM.id, itemType: TYPE_FOLDER }); + + expect(defaultAPIMock.getFolderCollaborationsAPI).toHaveBeenCalledWith(false); + expect(getDefaultFolderCollabMock).toHaveBeenCalledWith( + MOCK_ITEM.id, + expect.any(Function), + expect.any(Function), + ); + }); + + test('should return null for non file or folder type', async () => { + const result = await fetchCollaborators({ api: defaultAPIMock, itemID: MOCK_ITEM.id, itemType: 'hubs' }); + + expect(result).toBeNull(); + expect(defaultAPIMock.getFileCollaborationsAPI).not.toHaveBeenCalled(); + expect(defaultAPIMock.getFolderCollaborationsAPI).not.toHaveBeenCalled(); + }); +}); diff --git a/src/elements/content-sharing/apis/__tests__/fetchCurrentUser.test.ts b/src/elements/content-sharing/apis/__tests__/fetchCurrentUser.test.ts new file mode 100644 index 0000000000..8f9a7dfa7f --- /dev/null +++ b/src/elements/content-sharing/apis/__tests__/fetchCurrentUser.test.ts @@ -0,0 +1,33 @@ +import { DEFAULT_USER_API_RESPONSE, MOCK_ITEM } from '../../utils/__mocks__/ContentSharingV2Mocks'; +import { FIELD_ENTERPRISE, FIELD_HOSTNAME } from '../../../../constants'; +import { fetchCurrentUser } from '..'; +import { createSuccessMock, createUsersAPIMock } from './testUtils'; + +const getDefaultUserMock = jest.fn().mockImplementation(createSuccessMock(DEFAULT_USER_API_RESPONSE)); +const defaultAPIMock = createUsersAPIMock({ getUser: getDefaultUserMock }); + +describe('content-sharing/apis/fetchCurrentUser', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('should fetch current user successfully', async () => { + const result = await fetchCurrentUser({ api: defaultAPIMock, itemID: MOCK_ITEM.id }); + + expect(defaultAPIMock.getUsersAPI).toHaveBeenCalledWith(false); + expect(getDefaultUserMock).toHaveBeenCalledWith(MOCK_ITEM.id, expect.any(Function), expect.any(Function), { + params: { + fields: [FIELD_ENTERPRISE, FIELD_HOSTNAME].toString(), + }, + }); + expect(result).toEqual(DEFAULT_USER_API_RESPONSE); + }); + + test('should handle API errors', async () => { + const errorMock = jest.fn().mockImplementation((userID, successCallback, errorCallback) => { + errorCallback(new Error('API Error')); + }); + const errorAPIMock = createUsersAPIMock({ getUser: errorMock }); + await expect(fetchCurrentUser({ api: errorAPIMock, itemID: MOCK_ITEM.id })).rejects.toThrow('API Error'); + }); +}); diff --git a/src/elements/content-sharing/apis/__tests__/fetchItem.test.ts b/src/elements/content-sharing/apis/__tests__/fetchItem.test.ts new file mode 100644 index 0000000000..787e438b09 --- /dev/null +++ b/src/elements/content-sharing/apis/__tests__/fetchItem.test.ts @@ -0,0 +1,50 @@ +import { TYPE_FILE, TYPE_FOLDER } from '../../../../constants'; +import { CONTENT_SHARING_ITEM_FIELDS } from '../../constants'; +import { DEFAULT_ITEM_API_RESPONSE, MOCK_ITEM } from '../../utils/__mocks__/ContentSharingV2Mocks'; +import { fetchItem } from '..'; +import { createSuccessMock, createItemAPIMock } from './testUtils'; + +const getDefaultFileMock = jest.fn().mockImplementation(createSuccessMock(DEFAULT_ITEM_API_RESPONSE)); +const getDefaultFolderMock = jest.fn().mockImplementation(createSuccessMock(DEFAULT_ITEM_API_RESPONSE)); +const defaultAPIMock = createItemAPIMock({ getFile: getDefaultFileMock }, { getFolderFields: getDefaultFolderMock }); + +describe('content-sharing/apis/fetchItem.ts', () => { + test('should fetch file item successfully', async () => { + const result = await fetchItem({ api: defaultAPIMock, itemID: MOCK_ITEM.id, itemType: TYPE_FILE }); + + expect(defaultAPIMock.getFileAPI).toHaveBeenCalled(); + expect(getDefaultFileMock).toHaveBeenCalledWith(MOCK_ITEM.id, expect.any(Function), expect.any(Function), { + fields: CONTENT_SHARING_ITEM_FIELDS, + }); + expect(result).toEqual(DEFAULT_ITEM_API_RESPONSE); + }); + + test('should fetch folder item successfully', async () => { + const result = await fetchItem({ api: defaultAPIMock, itemID: MOCK_ITEM.id, itemType: TYPE_FOLDER }); + + expect(defaultAPIMock.getFolderAPI).toHaveBeenCalled(); + expect(getDefaultFolderMock).toHaveBeenCalledWith(MOCK_ITEM.id, expect.any(Function), expect.any(Function), { + fields: CONTENT_SHARING_ITEM_FIELDS, + }); + expect(result).toEqual(DEFAULT_ITEM_API_RESPONSE); + }); + + test('should return null for non file or folder type', async () => { + const result = await fetchItem({ api: defaultAPIMock, itemID: MOCK_ITEM.id, itemType: 'hubs' }); + + expect(result).toBeNull(); + expect(defaultAPIMock.getFileAPI).not.toHaveBeenCalled(); + expect(defaultAPIMock.getFolderAPI).not.toHaveBeenCalled(); + }); + + test('should handle API errors', async () => { + const errorMock = jest.fn().mockImplementation((itemID, successCallback, errorCallback) => { + errorCallback(new Error('File API Error')); + }); + const errorAPIMock = createItemAPIMock({ getFile: errorMock }, { getFolderFields: getDefaultFolderMock }); + + await expect(fetchItem({ api: errorAPIMock, itemID: MOCK_ITEM.id, itemType: TYPE_FILE })).rejects.toThrow( + 'File API Error', + ); + }); +}); diff --git a/src/elements/content-sharing/apis/__tests__/testUtils.ts b/src/elements/content-sharing/apis/__tests__/testUtils.ts new file mode 100644 index 0000000000..d1a9ea7b92 --- /dev/null +++ b/src/elements/content-sharing/apis/__tests__/testUtils.ts @@ -0,0 +1,23 @@ +export const createSuccessMock = responseFromAPI => (itemID, successFn) => { + return new Promise(resolve => { + setTimeout(() => { + resolve(responseFromAPI); + }, 500); + }).then(response => { + successFn(response); + }); +}; + +export const createItemAPIMock = (fileAPI, folderAPI) => ({ + getFileAPI: jest.fn().mockReturnValue(fileAPI), + getFolderAPI: jest.fn().mockReturnValue(folderAPI), +}); + +export const createCollabAPIMock = (fileCollabAPI, folderCollabAPI) => ({ + getFileCollaborationsAPI: jest.fn().mockReturnValue(fileCollabAPI), + getFolderCollaborationsAPI: jest.fn().mockReturnValue(folderCollabAPI), +}); + +export const createUsersAPIMock = usersAPI => ({ + getUsersAPI: jest.fn().mockReturnValue(usersAPI), +}); From bbf2009216b26eb3f617a6ebbeb9af359525312a Mon Sep 17 00:00:00 2001 From: reneshen0328 Date: Thu, 2 Oct 2025 15:23:05 -0700 Subject: [PATCH 5/8] fix: test --- .../content-sharing/ContentSharingV2.tsx | 2 +- .../__tests__/ContentSharingV2.test.tsx | 58 +++++++++++++------ .../content-sharing/apis/fetchAvatars.ts | 8 +-- .../utils/__mocks__/ContentSharingV2Mocks.js | 14 +++-- 4 files changed, 55 insertions(+), 27 deletions(-) diff --git a/src/elements/content-sharing/ContentSharingV2.tsx b/src/elements/content-sharing/ContentSharingV2.tsx index 40dbe641d3..069899a47c 100644 --- a/src/elements/content-sharing/ContentSharingV2.tsx +++ b/src/elements/content-sharing/ContentSharingV2.tsx @@ -131,7 +131,7 @@ function ContentSharingV2({ }, [collaboratorsData, avatarURLMap]); const config = { sharedLinkEmail: false }; - + console.log('collaborators', collaborators); return ( diff --git a/src/elements/content-sharing/__tests__/ContentSharingV2.test.tsx b/src/elements/content-sharing/__tests__/ContentSharingV2.test.tsx index 5772825a2e..74daee3161 100644 --- a/src/elements/content-sharing/__tests__/ContentSharingV2.test.tsx +++ b/src/elements/content-sharing/__tests__/ContentSharingV2.test.tsx @@ -7,15 +7,18 @@ import { MOCK_ITEM, MOCK_ITEM_API_RESPONSE_WITH_SHARED_LINK, MOCK_ITEM_API_RESPONSE_WITH_CLASSIFICATION, + MOCK_COLLABORATIONS_RESPONSE, + mockAvatarURLMap, } from '../utils/__mocks__/ContentSharingV2Mocks'; import { CONTENT_SHARING_ITEM_FIELDS } from '../constants'; import ContentSharingV2 from '../ContentSharingV2'; -const createAPIMock = (fileAPI, folderAPI, usersAPI) => ({ +const createAPIMock = (fileAPI, folderAPI, usersAPI, collaborationsAPI) => ({ getFileAPI: jest.fn().mockReturnValue(fileAPI), getFolderAPI: jest.fn().mockReturnValue(folderAPI), getUsersAPI: jest.fn().mockReturnValue(usersAPI), + getFileCollaborationsAPI: jest.fn().mockReturnValue(collaborationsAPI), }); const createSuccessMock = responseFromAPI => (id, successFn) => { @@ -33,10 +36,14 @@ const getFileMockWithClassification = jest .fn() .mockImplementation(createSuccessMock(MOCK_ITEM_API_RESPONSE_WITH_CLASSIFICATION)); const getDefaultFolderMock = jest.fn().mockImplementation(createSuccessMock(DEFAULT_ITEM_API_RESPONSE)); +const getCollaborationsMock = jest.fn().mockImplementation(createSuccessMock(MOCK_COLLABORATIONS_RESPONSE)); +const getAvatarUrlMock = jest.fn().mockImplementation(userID => mockAvatarURLMap[userID] ?? null); + const defaultAPIMock = createAPIMock( { getFile: getDefaultFileMock }, { getFolderFields: getDefaultFolderMock }, - { getUser: getDefaultUserMock }, + { getUser: getDefaultUserMock, getAvatarUrlWithAccessToken: getAvatarUrlMock }, + { getCollaborations: getCollaborationsMock }, ); const getWrapper = (props): RenderResult => @@ -61,11 +68,10 @@ describe('elements/content-sharing/ContentSharingV2', () => { expect(getDefaultFileMock).toHaveBeenCalledWith(MOCK_ITEM.id, expect.any(Function), expect.any(Function), { fields: CONTENT_SHARING_ITEM_FIELDS, }); + expect(screen.getByRole('heading', { name: /Box Development Guide.pdf/i })).toBeVisible(); + expect(screen.getByRole('combobox', { name: 'Invite People' })).toBeVisible(); + expect(screen.getByRole('switch', { name: 'Shared link' })).toBeVisible(); }); - - expect(screen.getByRole('heading', { name: 'Share ‘Box Development Guide.pdf’' })).toBeVisible(); - expect(screen.getByRole('combobox', { name: 'Invite People' })).toBeVisible(); - expect(screen.getByRole('switch', { name: 'Shared link' })).toBeVisible(); }); test('should see the correct elements for folders', async () => { @@ -79,17 +85,18 @@ describe('elements/content-sharing/ContentSharingV2', () => { fields: CONTENT_SHARING_ITEM_FIELDS, }, ); + expect(screen.getByRole('heading', { name: 'Share ‘Box Development Guide.pdf’' })).toBeVisible(); + expect(screen.getByRole('combobox', { name: 'Invite People' })).toBeVisible(); + expect(screen.getByRole('switch', { name: 'Shared link' })).toBeVisible(); }); - - expect(screen.getByRole('heading', { name: 'Share ‘Box Development Guide.pdf’' })).toBeVisible(); - expect(screen.getByRole('combobox', { name: 'Invite People' })).toBeVisible(); - expect(screen.getByRole('switch', { name: 'Shared link' })).toBeVisible(); }); test('should see the shared link elements if shared link is present', async () => { - getWrapper({ - api: createAPIMock({ getFile: getFileMockWithSharedLink }, null, { getUser: getDefaultUserMock }), - }); + const apiWithSharedLink = { + ...defaultAPIMock, + getFileAPI: jest.fn().mockReturnValue({ getFile: getFileMockWithSharedLink }), + }; + getWrapper({ api: apiWithSharedLink }); await waitFor(() => { expect(getFileMockWithSharedLink).toHaveBeenCalledWith( MOCK_ITEM.id, @@ -109,9 +116,11 @@ describe('elements/content-sharing/ContentSharingV2', () => { }); test('should see the classification elements if classification is present', async () => { - getWrapper({ - api: createAPIMock({ getFile: getFileMockWithClassification }, null, { getUser: getDefaultUserMock }), - }); + const apiWithClassification = { + ...defaultAPIMock, + getFileAPI: jest.fn().mockReturnValue({ getFile: getFileMockWithClassification }), + }; + getWrapper({ api: apiWithClassification }); await waitFor(() => { expect(getFileMockWithClassification).toHaveBeenCalledWith( MOCK_ITEM.id, @@ -121,7 +130,22 @@ describe('elements/content-sharing/ContentSharingV2', () => { fields: CONTENT_SHARING_ITEM_FIELDS, }, ); + expect(screen.getByText('BLUE')).toBeVisible(); + }); + }); + + test('should process collaborators with avatars correctly', async () => { + getWrapper({}); + + await waitFor(() => { + expect(getCollaborationsMock).toHaveBeenCalledWith( + MOCK_ITEM.id, + expect.any(Function), + expect.any(Function), + ); + expect(getAvatarUrlMock).toHaveBeenCalledWith('456', MOCK_ITEM.id); + expect(getAvatarUrlMock).toHaveBeenCalledWith('457', MOCK_ITEM.id); + expect(getAvatarUrlMock).toHaveBeenCalledWith('458', MOCK_ITEM.id); }); - expect(screen.getByText('BLUE')).toBeVisible(); }); }); diff --git a/src/elements/content-sharing/apis/fetchAvatars.ts b/src/elements/content-sharing/apis/fetchAvatars.ts index ee35b67405..e2006d3163 100644 --- a/src/elements/content-sharing/apis/fetchAvatars.ts +++ b/src/elements/content-sharing/apis/fetchAvatars.ts @@ -7,13 +7,13 @@ export const fetchAvatars = async ({ api, itemID, collaborators }: FetchCollabor const avatarPromises = collaborators.map(async collab => { if (!collab?.accessible_by) return; const { - accessible_by: { id: userID }, + accessible_by: { id: userId }, } = collab; try { - const url = await usersAPI.getAvatarUrlWithAccessToken(userID.toString(), itemID); - avatarURLMap[userID] = url; + const url = await usersAPI.getAvatarUrlWithAccessToken(userId.toString(), itemID); + avatarURLMap[userId] = url; } catch { - avatarURLMap[userID] = null; + avatarURLMap[userId] = null; } }); diff --git a/src/elements/content-sharing/utils/__mocks__/ContentSharingV2Mocks.js b/src/elements/content-sharing/utils/__mocks__/ContentSharingV2Mocks.js index 4faf552ea1..1559f74e33 100644 --- a/src/elements/content-sharing/utils/__mocks__/ContentSharingV2Mocks.js +++ b/src/elements/content-sharing/utils/__mocks__/ContentSharingV2Mocks.js @@ -39,24 +39,28 @@ export const collabUser1 = { id: 456, login: 'dparrot@example.com', name: 'Detective Parrot', - type: 'user', + role: 'editor', }; export const collabUser2 = { id: 457, login: 'rqueen@example.com', name: 'Raccoon Queen', - type: 'user', }; export const collabUser3 = { id: 458, login: 'dpenguin@example.com', name: 'Dancing Penguin', - type: 'user', }; -export const MOCK_COLLABORATORS = [collabUser1, collabUser2, collabUser3]; +export const collabUser4 = { + id: mockCurrentUserID, + login: mockOwnerEmail, + name: 'Astronaut Otter', +}; + +export const MOCK_COLLABORATORS = [collabUser4, collabUser1, collabUser2, collabUser3]; export const MOCK_COLLABORATIONS_RESPONSE = { entries: MOCK_COLLABORATORS.map(user => ({ @@ -69,7 +73,7 @@ export const MOCK_COLLABORATIONS_RESPONSE = { name: 'Astronaut Otter', type: 'user', }, - role: 'editor', + role: user.id === mockCurrentUserID ? 'owner' : 'editor', status: 'accepted', type: user.type, })), From 9c2f036c201206e541a21da477344bf4e96bf84f Mon Sep 17 00:00:00 2001 From: reneshen0328 Date: Thu, 2 Oct 2025 15:38:42 -0700 Subject: [PATCH 6/8] fix: remove console --- src/elements/content-sharing/ContentSharingV2.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/elements/content-sharing/ContentSharingV2.tsx b/src/elements/content-sharing/ContentSharingV2.tsx index 069899a47c..40dbe641d3 100644 --- a/src/elements/content-sharing/ContentSharingV2.tsx +++ b/src/elements/content-sharing/ContentSharingV2.tsx @@ -131,7 +131,7 @@ function ContentSharingV2({ }, [collaboratorsData, avatarURLMap]); const config = { sharedLinkEmail: false }; - console.log('collaborators', collaborators); + return ( From ec96144e7e969728c459d28ac180612ddc010469 Mon Sep 17 00:00:00 2001 From: reneshen0328 Date: Thu, 2 Oct 2025 17:08:46 -0700 Subject: [PATCH 7/8] fix: update contentSharingV2 useEffect logic --- src/elements/content-sharing/ContentSharingV2.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/elements/content-sharing/ContentSharingV2.tsx b/src/elements/content-sharing/ContentSharingV2.tsx index 40dbe641d3..adcc4d255c 100644 --- a/src/elements/content-sharing/ContentSharingV2.tsx +++ b/src/elements/content-sharing/ContentSharingV2.tsx @@ -72,7 +72,7 @@ function ContentSharingV2({ // Get initial data for the item React.useEffect(() => { - if (!api || isEmpty(api) || item || sharedLink) return; + if (!api || isEmpty(api) || item) return; (async () => { const itemData = await fetchItem({ api, itemID, itemType }); @@ -82,7 +82,7 @@ function ContentSharingV2({ // Get current user React.useEffect(() => { - if (!api || isEmpty(api) || item || sharedLink || currentUser) return; + if (!api || isEmpty(api) || !item || currentUser) return; const getUserSuccess = userData => { const { enterprise, id } = userData; @@ -100,7 +100,7 @@ function ContentSharingV2({ // Get collaborators React.useEffect(() => { - if (!api || isEmpty(api) || item || collaboratorsData) return; + if (!api || isEmpty(api) || !item || collaboratorsData) return; (async () => { try { From f6bd77ca1ad350eb414eeeccf318e5748c3457ec Mon Sep 17 00:00:00 2001 From: reneshen0328 Date: Fri, 3 Oct 2025 08:26:54 -0700 Subject: [PATCH 8/8] fix: add await for tests --- .../tests/ContentSharingV2-visual.stories.tsx | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/elements/content-sharing/stories/tests/ContentSharingV2-visual.stories.tsx b/src/elements/content-sharing/stories/tests/ContentSharingV2-visual.stories.tsx index 1af370684c..41f2c7ceb5 100644 --- a/src/elements/content-sharing/stories/tests/ContentSharingV2-visual.stories.tsx +++ b/src/elements/content-sharing/stories/tests/ContentSharingV2-visual.stories.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { expect, screen, userEvent, within } from 'storybook/test'; +import { expect, screen, userEvent, waitFor, within } from 'storybook/test'; import { TYPE_FILE } from '../../../../constants'; import { @@ -52,14 +52,16 @@ export const withCollaborators = { }, play: async context => { await withModernization.play(context); - const sharedWithAvatars = screen.getByRole('button', { name: 'Shared with D R D' }); - expect(sharedWithAvatars).toBeVisible(); - await userEvent.click(sharedWithAvatars); + await waitFor(async () => { + const sharedWithAvatars = screen.getByRole('button', { name: 'Shared with D R D' }); + expect(sharedWithAvatars).toBeVisible(); + await userEvent.click(sharedWithAvatars); - expect(screen.getByRole('link', { name: 'Manage All' })).toBeVisible(); - expect(screen.getByRole('grid', { name: 'Collaborators' })).toBeVisible(); - expect(screen.getByRole('row', { name: /Detective Parrot/ })).toBeVisible(); - expect(screen.getByRole('button', { name: 'Done' })).toBeVisible(); + expect(screen.getByRole('link', { name: 'Manage All' })).toBeVisible(); + expect(screen.getByRole('grid', { name: 'Collaborators' })).toBeVisible(); + expect(screen.getByRole('row', { name: /Detective Parrot/ })).toBeVisible(); + expect(screen.getByRole('button', { name: 'Done' })).toBeVisible(); + }); }, };