From 5d87df1d19870ca075790a711bcb49210a71c548 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Musia=C5=82?= Date: Wed, 19 Aug 2026 14:50:13 +0200 Subject: [PATCH] =?UTF-8?q?ReportActionAvatars=20=E2=80=94=20WorkspaceAvat?= =?UTF-8?q?ar=20+=20policyID=20migration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Avatar/connected/PolicyAvatar.tsx | 70 ++++++ .../Avatar/connected/usePolicyIcons.ts | 45 ++++ .../Avatar/layouts/getAvatarLayout.ts | 2 + .../ListItem/UserListItemContent.tsx | 65 ++++-- ...DynamicNewReportWorkspaceSelectionPage.tsx | 22 +- .../BaseOnboardingWorkspaces.tsx | 15 +- ...omainGroupCreatePreferredWorkspacePage.tsx | 14 -- .../DomainGroupPreferredWorkspacePage.tsx | 14 -- .../request/step/BaseRequestStepWorkspace.tsx | 12 - .../step/DynamicIOURequestStepSendFrom.tsx | 16 +- ...CopyPolicySettingsSelectWorkspacesPage.tsx | 17 +- .../Avatar/connected/PolicyAvatarTest.tsx | 215 ++++++++++++++++++ .../Avatar/connected/usePolicyIconsTest.ts | 158 +++++++++++++ 13 files changed, 549 insertions(+), 116 deletions(-) create mode 100644 src/components/Avatar/connected/PolicyAvatar.tsx create mode 100644 src/components/Avatar/connected/usePolicyIcons.ts create mode 100644 tests/unit/components/Avatar/connected/PolicyAvatarTest.tsx create mode 100644 tests/unit/components/Avatar/connected/usePolicyIconsTest.ts diff --git a/src/components/Avatar/connected/PolicyAvatar.tsx b/src/components/Avatar/connected/PolicyAvatar.tsx new file mode 100644 index 000000000000..b609eb6d4f36 --- /dev/null +++ b/src/components/Avatar/connected/PolicyAvatar.tsx @@ -0,0 +1,70 @@ +import getAvatarLayout from '@components/Avatar/layouts/getAvatarLayout'; +import SingleAvatar from '@components/Avatar/layouts/SingleAvatar'; +import SubscriptAvatar from '@components/Avatar/layouts/SubscriptAvatar'; + +import useStyleUtils from '@hooks/useStyleUtils'; + +import CONST from '@src/CONST'; + +import type {ColorValue, StyleProp, ViewStyle} from 'react-native'; +import type {ValueOf} from 'type-fest'; + +import React from 'react'; + +import usePolicyIcons from './usePolicyIcons'; + +type PolicyAvatarProps = { + /** Policy whose workspace avatar to render */ + policyID: string; + + /** Account to render as the subscript on the workspace avatar. Omit to render the workspace avatar on its own. */ + accountID?: number; + + /** Size of the avatar */ + size?: ValueOf; + + /** Workspace name used to seed the default avatar, and as a fallback for the avatar tooltip */ + fallbackDisplayName?: string; + + /** Container styles for the avatar. Replaces the size-derived default container styles when provided. Only applies to the single-avatar layout. */ + containerStyle?: StyleProp; + + /** Border color for the subscript avatar */ + subscriptAvatarBorderColor?: ColorValue; +}; + +/** Renders a workspace avatar, resolving it from the policy's `avatarURL` and `name` alone. Pass an `accountID` to render that account as the subscript. */ +function PolicyAvatar({policyID, accountID, size = CONST.AVATAR_SIZE.DEFAULT, fallbackDisplayName, containerStyle, subscriptAvatarBorderColor}: PolicyAvatarProps) { + const StyleUtils = useStyleUtils(); + const icons = usePolicyIcons(policyID, accountID, fallbackDisplayName); + + const {layout, primaryIcon, secondaryIcon} = getAvatarLayout({ + icons, + avatarType: accountID ? CONST.REPORT_ACTION_AVATARS.TYPE.SUBSCRIPT : CONST.REPORT_ACTION_AVATARS.TYPE.SINGLE, + // The account icon is a nameless placeholder until personal details load, and a nameless subscript renders as an empty ring. + shouldRequireSecondaryIconName: true, + }); + + if (layout === CONST.REPORT_ACTION_AVATARS.TYPE.SUBSCRIPT) { + return ( + + ); + } + + return ( + + ); +} + +export default PolicyAvatar; diff --git a/src/components/Avatar/connected/usePolicyIcons.ts b/src/components/Avatar/connected/usePolicyIcons.ts new file mode 100644 index 000000000000..6df5a7c6d43f --- /dev/null +++ b/src/components/Avatar/connected/usePolicyIcons.ts @@ -0,0 +1,45 @@ +import useOnyx from '@hooks/useOnyx'; + +import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; +import {getDefaultWorkspaceAvatar} from '@libs/ReportUtils'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Policy} from '@src/types/onyx'; +import type {Icon} from '@src/types/onyx/OnyxCommon'; + +import type {OnyxEntry} from 'react-native-onyx'; + +import useAccountIcons from './useAccountIcons'; + +type PolicyAvatarFields = { + /** Remote URL of the uploaded workspace avatar. An empty string when the workspace has none. */ + avatarURL?: string; + + /** Workspace name. Seeds the default workspace avatar and the avatar tooltip. */ + name?: string; +}; + +/** Kept at module level so `useOnyx` keeps the same subscription across renders. */ +const policyAvatarSelector = (policy: OnyxEntry): PolicyAvatarFields => ({avatarURL: policy?.avatarURL, name: policy?.name}); + +/** Resolves a policy ID and account ID into avatars. */ +function usePolicyIcons(policyID?: string, accountID?: number, fallbackDisplayName = ''): [Icon, ...Icon[]] { + const [policyAvatar] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(policyID)}`, {selector: policyAvatarSelector}); + const accountIcons = useAccountIcons(accountID ? [accountID] : []); + + const name = policyAvatar?.name ?? fallbackDisplayName; + + const policyIcon: Icon = { + id: policyID, + type: CONST.ICON_TYPE_WORKSPACE, + name, + // A workspace with no uploaded avatar has `avatarURL: ''`, which has to fall through to the default avatar. + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + source: policyAvatar?.avatarURL || getDefaultWorkspaceAvatar(name), + }; + + return [policyIcon, ...accountIcons]; +} + +export default usePolicyIcons; diff --git a/src/components/Avatar/layouts/getAvatarLayout.ts b/src/components/Avatar/layouts/getAvatarLayout.ts index 46ef6b531429..28f644f2a13a 100644 --- a/src/components/Avatar/layouts/getAvatarLayout.ts +++ b/src/components/Avatar/layouts/getAvatarLayout.ts @@ -38,6 +38,8 @@ type AvatarLayoutResult = { }; /** Resolves the avatar layout primitive and primary/secondary icons from pre-computed icons and a requested layout type. */ +function getAvatarLayout(params: GetAvatarLayoutParams & {icons: [Icon, ...Icon[]]}): AvatarLayoutResult & {primaryIcon: Icon}; +function getAvatarLayout(params: GetAvatarLayoutParams): AvatarLayoutResult; function getAvatarLayout({icons, avatarType, shouldStackHorizontally = false, hasCardFeed = false, shouldRequireSecondaryIconName = false}: GetAvatarLayoutParams): AvatarLayoutResult { const primaryIcon = icons.at(0); const secondaryIcon = icons.at(1); diff --git a/src/components/SelectionList/ListItem/UserListItemContent.tsx b/src/components/SelectionList/ListItem/UserListItemContent.tsx index e126622eee91..e060fc733c1b 100644 --- a/src/components/SelectionList/ListItem/UserListItemContent.tsx +++ b/src/components/SelectionList/ListItem/UserListItemContent.tsx @@ -1,4 +1,5 @@ import AccountAvatar from '@components/Avatar/connected/AccountAvatar'; +import PolicyAvatar from '@components/Avatar/connected/PolicyAvatar'; import {AvatarTooltipsProvider} from '@components/Avatar/tooltips/AvatarTooltipContext'; import Icon from '@components/Icon'; import ReportActionAvatars from '@components/ReportActionAvatars'; @@ -82,6 +83,44 @@ function UserListItemContent({ const policyID = isThereOnlyWorkspaceIcon && shouldUseIconPolicyID ? String(item.icons?.at(0)?.id) : item.policyID; const isHovered = hovered && !shouldDisableHoverStyle; + const fallbackDisplayName = item.text ?? item.alternateText ?? undefined; + + // A report resolves its own avatars, so it keeps going through `ReportActionAvatars`, otherwise using Account/Policy. + let avatar: React.ReactNode; + if (reportExists) { + avatar = ( + + ); + } else if (policyID) { + avatar = ( + + ); + } else if (itemAccountID) { + avatar = ( + + ); + } + const baseAccessibilityLabel = getAccessibilityLabel(item); const accessibilityLabel = shouldDisableAccessibleGrouping && item.isSelected !== undefined @@ -95,31 +134,7 @@ function UserListItemContent({ role={shouldDisableAccessibleGrouping ? CONST.ROLE.BUTTON : undefined} style={[styles.flex1, styles.flexRow, styles.alignItemsCenter]} > - {(!!reportExists || !!itemAccountID || !!policyID) && ( - - {!reportExists && !policyID && !!itemAccountID ? ( - - ) : ( - - )} - - )} + {!!avatar && {avatar}} ({ text: policyInfo.policyName, alternateText: translate('onboarding.workspaceMemberList', policyInfo.employeeCount, policyInfo.policyOwner), + // The user is not a member of these workspaces yet, so they are absent from Onyx and the avatar falls back + // to the default one seeded from `text` - the same icon this list used to build by hand. + policyID: policyInfo.policyID, keyForList: policyInfo.policyID, isDisabled: true, rightElement: ( @@ -138,15 +140,6 @@ function BaseOnboardingWorkspaces({route, shouldUseNativeStyles}: BaseOnboarding {policyInfo.automaticJoiningEnabled ? translate('workspace.workspaceList.joinNow') : translate('workspace.workspaceList.askToJoin')} ), - icons: [ - { - id: policyInfo.policyID, - source: getDefaultWorkspaceAvatar(policyInfo.policyName), - fallbackIcon: icons.FallbackWorkspaceAvatar, - name: policyInfo.policyName, - type: CONST.ICON_TYPE_WORKSPACE, - }, - ], })); const hasMoreThanLimit = allPolicyIDItems.length > CONST.ONBOARDING_JOINABLE_WORKSPACES_LIMIT; diff --git a/src/pages/domain/Groups/DomainGroupCreatePreferredWorkspacePage.tsx b/src/pages/domain/Groups/DomainGroupCreatePreferredWorkspacePage.tsx index 8bb2185472b4..a6af6d95dd8d 100644 --- a/src/pages/domain/Groups/DomainGroupCreatePreferredWorkspacePage.tsx +++ b/src/pages/domain/Groups/DomainGroupCreatePreferredWorkspacePage.tsx @@ -5,13 +5,10 @@ import UserListItem from '@components/SelectionList/ListItem/UserListItem'; import type {ListItem} from '@components/SelectionList/types'; import Text from '@components/Text'; -import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; -import {getDefaultWorkspaceAvatar} from '@libs/ReportUtils'; - import Navigation from '@navigation/Navigation'; import type {PlatformStackScreenProps} from '@navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@navigation/types'; @@ -20,7 +17,6 @@ import DomainNotFoundPageWrapper from '@pages/domain/DomainNotFoundPageWrapper'; import {setDomainGroupCreatePreferredPolicyID} from '@userActions/Domain'; -import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; @@ -43,7 +39,6 @@ function DomainGroupCreatePreferredWorkspacePage({route}: DomainGroupCreatePrefe const styles = useThemeStyles(); const {translate, localeCompare} = useLocalize(); - const icons = useMemoizedLazyExpensifyIcons(['FallbackWorkspaceAvatar']); const [currentPolicyID] = useOnyx(ONYXKEYS.DOMAIN_GROUP_CREATE_PREFERRED_POLICY_ID); const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: createAdminPoliciesSelector(currentPolicyID)}); @@ -60,15 +55,6 @@ function DomainGroupCreatePreferredWorkspacePage({route}: DomainGroupCreatePrefe created: policy.created, keyForList: policy.id, isSelected: currentPolicyID === policy.id, - icons: [ - { - source: policy.avatarURL ?? getDefaultWorkspaceAvatar(policy.name), - fallbackIcon: icons.FallbackWorkspaceAvatar, - name: policy.name, - type: CONST.ICON_TYPE_WORKSPACE, - id: policy.id, - }, - ], }); } diff --git a/src/pages/domain/Groups/DomainGroupPreferredWorkspacePage.tsx b/src/pages/domain/Groups/DomainGroupPreferredWorkspacePage.tsx index 3fea72a595c6..33e9d7190905 100644 --- a/src/pages/domain/Groups/DomainGroupPreferredWorkspacePage.tsx +++ b/src/pages/domain/Groups/DomainGroupPreferredWorkspacePage.tsx @@ -5,13 +5,10 @@ import UserListItem from '@components/SelectionList/ListItem/UserListItem'; import type {ListItem} from '@components/SelectionList/types'; import Text from '@components/Text'; -import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; -import {getDefaultWorkspaceAvatar} from '@libs/ReportUtils'; - import Navigation from '@navigation/Navigation'; import type {PlatformStackScreenProps} from '@navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@navigation/types'; @@ -20,7 +17,6 @@ import DomainNotFoundPageWrapper from '@pages/domain/DomainNotFoundPageWrapper'; import {updateDomainSecurityGroup} from '@userActions/Domain'; -import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; @@ -41,7 +37,6 @@ function DomainGroupPreferredWorkspacePage({route}: DomainGroupPreferredWorkspac const styles = useThemeStyles(); const {translate, localeCompare} = useLocalize(); - const icons = useMemoizedLazyExpensifyIcons(['FallbackWorkspaceAvatar']); const [group] = useOnyx(`${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, { selector: selectGroupByID(groupID), @@ -67,15 +62,6 @@ function DomainGroupPreferredWorkspacePage({route}: DomainGroupPreferredWorkspac created: policy.created, keyForList: policy.id, isSelected: currentPolicyID === policy.id, - icons: [ - { - source: policy.avatarURL ?? getDefaultWorkspaceAvatar(policy.name), - fallbackIcon: icons.FallbackWorkspaceAvatar, - name: policy.name, - type: CONST.ICON_TYPE_WORKSPACE, - id: policy.id, - }, - ], }); } diff --git a/src/pages/iou/request/step/BaseRequestStepWorkspace.tsx b/src/pages/iou/request/step/BaseRequestStepWorkspace.tsx index b68c83634040..c7695c5458ed 100644 --- a/src/pages/iou/request/step/BaseRequestStepWorkspace.tsx +++ b/src/pages/iou/request/step/BaseRequestStepWorkspace.tsx @@ -5,7 +5,6 @@ import UserListItem from '@components/SelectionList/ListItem/UserListItem'; import Text from '@components/Text'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; -import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useSearchResults from '@hooks/useSearchResults'; @@ -13,7 +12,6 @@ import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; import {getPolicyByCustomUnitID, sortWorkspacesBySelected} from '@libs/PolicyUtils'; -import {getDefaultWorkspaceAvatar} from '@libs/ReportUtils'; import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils'; import tokenizedSearch from '@libs/tokenizedSearch'; @@ -45,7 +43,6 @@ type BaseRequestStepWorkspaceProps = WithFullTransactionOrNotFoundProps; function DynamicIOURequestStepSendFrom({route, transaction}: DynamicIOURequestStepSendFromProps) { - const icons = useMemoizedLazyExpensifyIcons(['FallbackWorkspaceAvatar']); const {translate, localeCompare} = useLocalize(); const {transactionID} = route.params; const backPath = useDynamicBackPath(DYNAMIC_ROUTES.MONEY_REQUEST_STEP_SEND_FROM.path); @@ -60,19 +56,11 @@ function DynamicIOURequestStepSendFrom({route, transaction}: DynamicIOURequestSt .map((policy) => ({ text: policy.name, value: policy.id, + policyID: policy.id, keyForList: policy.id, - icons: [ - { - id: policy.id, - source: policy?.avatarURL ? policy.avatarURL : getDefaultWorkspaceAvatar(policy.name), - fallbackIcon: icons.FallbackWorkspaceAvatar, - name: policy.name, - type: CONST.ICON_TYPE_WORKSPACE, - }, - ], isSelected: selectedWorkspace?.policyID === policy.id, })); - }, [allPolicies, currentUserLogin, selectedWorkspace?.policyID, localeCompare, icons.FallbackWorkspaceAvatar]); + }, [allPolicies, currentUserLogin, selectedWorkspace?.policyID, localeCompare]); const navigateBack = () => { Navigation.goBack(backPath); diff --git a/src/pages/workspace/copyPolicySettings/CopyPolicySettingsSelectWorkspacesPage.tsx b/src/pages/workspace/copyPolicySettings/CopyPolicySettingsSelectWorkspacesPage.tsx index b6e3d1d69aec..cc10b45f40a4 100644 --- a/src/pages/workspace/copyPolicySettings/CopyPolicySettingsSelectWorkspacesPage.tsx +++ b/src/pages/workspace/copyPolicySettings/CopyPolicySettingsSelectWorkspacesPage.tsx @@ -1,4 +1,5 @@ -import WorkspaceAvatar from '@components/Avatar/WorkspaceAvatar'; +import PolicyAvatar from '@components/Avatar/connected/PolicyAvatar'; +import {AvatarTooltipsProvider} from '@components/Avatar/tooltips/AvatarTooltipContext'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; @@ -36,7 +37,6 @@ const SEARCH_THRESHOLD = 12; type EligiblePolicyItem = { id: string; title: string; - avatarURL?: string; }; function CopyPolicySettingsSelectWorkspacesPage() { @@ -72,7 +72,6 @@ function CopyPolicySettingsSelectWorkspacesPage() { .map((policy) => ({ id: policy.id, title: policy.name, - avatarURL: policy.avatarURL, })) .sort((a, b) => localeCompare(a.title, b.title)); @@ -87,13 +86,13 @@ function CopyPolicySettingsSelectWorkspacesPage() { keyForList: policy.id, isSelected: resolvedSelectedTargetIDs.includes(policy.id), leftElement: ( - - + - + ), })); diff --git a/tests/unit/components/Avatar/connected/PolicyAvatarTest.tsx b/tests/unit/components/Avatar/connected/PolicyAvatarTest.tsx new file mode 100644 index 000000000000..970e78f1acd8 --- /dev/null +++ b/tests/unit/components/Avatar/connected/PolicyAvatarTest.tsx @@ -0,0 +1,215 @@ +import {render, screen} from '@testing-library/react-native'; + +import PolicyAvatar from '@components/Avatar/connected/PolicyAvatar'; + +import {getDefaultWorkspaceAvatar} from '@libs/ReportUtils'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {PersonalDetailsList} from '@src/types/onyx'; + +import type {ViewStyle} from 'react-native'; + +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import waitForBatchedUpdatesWithAct from '../../../../utils/waitForBatchedUpdatesWithAct'; + +const POLICY_ID = 'policy123'; +const POLICY_NAME = 'Acme Workspace'; +const POLICY_AVATAR_URL = 'https://example.com/workspace-avatar.png'; +const FALLBACK_NAME = 'Pending Workspace'; + +const ACCOUNT_ID = 42; +const ACCOUNT_WITHOUT_DETAILS_ID = 7; +const LOGIN = 'john@example.com'; +const ACCOUNT_AVATAR_URL = 'https://example.com/uploaded-avatar.png'; + +const CUSTOM_CONTAINER_STYLE = {borderRadius: 20}; +const EMPTY_CONTAINER_STYLE: ViewStyle[] = []; +const BORDER_COLOR = '#ff0000'; + +// Stands in for the bundled fallback SVG so a resolved account icon can be asserted by identity. +function MockFallbackAvatar() { + return null; +} + +// Capture the props handed to the layout primitives, which is the whole contract of this component. +let mockCapturedSingleAvatarProps: Record = {}; +let mockCapturedSubscriptAvatarProps: Record = {}; + +const mockGetContainerStyles = jest.fn((size: string) => [{marginRight: 12, size}]); + +jest.mock('@hooks/useLazyAsset', () => ({ + useMemoizedLazyExpensifyIcons: () => ({ + ConciergeAvatar: MockFallbackAvatar, + NotificationsAvatar: MockFallbackAvatar, + FallbackAvatar: MockFallbackAvatar, + }), +})); + +jest.mock('@hooks/useStyleUtils', () => jest.fn(() => ({getContainerStyles: mockGetContainerStyles}))); + +let mockPersonalDetails: PersonalDetailsList = {}; + +jest.mock('@components/OnyxListItemProvider', () => ({ + usePersonalDetails: () => mockPersonalDetails, +})); + +jest.mock('@components/Avatar/layouts/SingleAvatar', () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const {View} = require('react-native'); + return (props: Record) => { + mockCapturedSingleAvatarProps = props; + return ; + }; +}); + +jest.mock('@components/Avatar/layouts/SubscriptAvatar', () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const {View} = require('react-native'); + return (props: Record) => { + mockCapturedSubscriptAvatarProps = props; + return ; + }; +}); + +describe('PolicyAvatar (connected)', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(() => { + jest.clearAllMocks(); + mockCapturedSingleAvatarProps = {}; + mockCapturedSubscriptAvatarProps = {}; + mockPersonalDetails = { + [ACCOUNT_ID]: {accountID: ACCOUNT_ID, login: LOGIN, avatar: ACCOUNT_AVATAR_URL}, + }; + }); + + afterEach(async () => { + await Onyx.clear(); + await waitForBatchedUpdatesWithAct(); + }); + + it.each([ + ['the uploaded avatar when the policy has one', {id: POLICY_ID, name: POLICY_NAME, avatarURL: POLICY_AVATAR_URL}, POLICY_AVATAR_URL, POLICY_NAME], + // A workspace with no uploaded avatar stores an empty string, which has to fall through to the default avatar. + ['the default avatar when avatarURL is an empty string', {id: POLICY_ID, name: POLICY_NAME, avatarURL: ''}, getDefaultWorkspaceAvatar(POLICY_NAME), POLICY_NAME], + ])('should render a single workspace avatar resolved from %s', async (_case, policy, expectedSource, expectedName) => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, policy); + await waitForBatchedUpdatesWithAct(); + + render(); + + expect(screen.getByTestId('MockedSingleAvatar')).toBeOnTheScreen(); + expect(mockCapturedSingleAvatarProps.avatar).toEqual({ + id: POLICY_ID, + type: CONST.ICON_TYPE_WORKSPACE, + source: expectedSource, + name: expectedName, + }); + }); + + it('should seed the avatar from the fallback display name when the policy is not in Onyx', () => { + render( + , + ); + + expect(mockCapturedSingleAvatarProps.avatar).toEqual({ + id: POLICY_ID, + type: CONST.ICON_TYPE_WORKSPACE, + source: getDefaultWorkspaceAvatar(FALLBACK_NAME), + name: FALLBACK_NAME, + }); + expect(mockCapturedSingleAvatarProps.fallbackDisplayName).toBe(FALLBACK_NAME); + }); + + it('should render the account as a subscript on the workspace avatar when an accountID is passed', async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, {id: POLICY_ID, name: POLICY_NAME, avatarURL: POLICY_AVATAR_URL}); + await waitForBatchedUpdatesWithAct(); + + render( + , + ); + + expect(screen.getByTestId('MockedSubscriptAvatar')).toBeOnTheScreen(); + expect(screen.queryByTestId('MockedSingleAvatar')).not.toBeOnTheScreen(); + expect(mockCapturedSubscriptAvatarProps.primaryAvatar).toEqual(expect.objectContaining({id: POLICY_ID, type: CONST.ICON_TYPE_WORKSPACE})); + expect(mockCapturedSubscriptAvatarProps.secondaryAvatar).toEqual(expect.objectContaining({id: ACCOUNT_ID, type: CONST.ICON_TYPE_AVATAR, name: LOGIN})); + expect(mockCapturedSubscriptAvatarProps.subscriptAvatarBorderColor).toBe(BORDER_COLOR); + }); + + it('should downgrade to a single avatar while the account icon is still nameless', async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, {id: POLICY_ID, name: POLICY_NAME, avatarURL: POLICY_AVATAR_URL}); + await waitForBatchedUpdatesWithAct(); + + render( + , + ); + + expect(screen.getByTestId('MockedSingleAvatar')).toBeOnTheScreen(); + expect(screen.queryByTestId('MockedSubscriptAvatar')).not.toBeOnTheScreen(); + expect(mockCapturedSingleAvatarProps.avatar).toEqual(expect.objectContaining({id: POLICY_ID, type: CONST.ICON_TYPE_WORKSPACE})); + }); + + it.each([ + ['the default size when none is passed', undefined, CONST.AVATAR_SIZE.DEFAULT], + ['the passed size', CONST.AVATAR_SIZE.SMALL, CONST.AVATAR_SIZE.SMALL], + ])('should derive the container styles from %s', (_case, size, expectedSize) => { + render( + , + ); + + expect(mockGetContainerStyles).toHaveBeenCalledWith(expectedSize); + expect(mockCapturedSingleAvatarProps.size).toBe(expectedSize); + expect(mockCapturedSingleAvatarProps.containerStyles).toEqual(mockGetContainerStyles(expectedSize)); + }); + + it.each([ + ['a style object', CUSTOM_CONTAINER_STYLE], + // An empty array is a deliberate "no container styles" request, so it must win over the size-derived default + ['an empty style array', EMPTY_CONTAINER_STYLE], + ])('should replace the derived container styles when containerStyle is %s', (_case, containerStyle) => { + render( + , + ); + + expect(mockCapturedSingleAvatarProps.containerStyles).toBe(containerStyle); + expect(mockGetContainerStyles).not.toHaveBeenCalled(); + }); + + // `ReportActionAvatars` never applied the single-avatar container style to a subscript stack, and the subscript + // frame supplies its own size-derived container, so forwarding it here would shift every workspace+member row. + it('should not forward the container style to the subscript layout', async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, {id: POLICY_ID, name: POLICY_NAME, avatarURL: POLICY_AVATAR_URL}); + await waitForBatchedUpdatesWithAct(); + + render( + , + ); + + expect(mockCapturedSubscriptAvatarProps.containerStyle).toBeUndefined(); + }); +}); diff --git a/tests/unit/components/Avatar/connected/usePolicyIconsTest.ts b/tests/unit/components/Avatar/connected/usePolicyIconsTest.ts new file mode 100644 index 000000000000..c427a1b6b59e --- /dev/null +++ b/tests/unit/components/Avatar/connected/usePolicyIconsTest.ts @@ -0,0 +1,158 @@ +import {renderHook} from '@testing-library/react-native'; + +import usePolicyIcons from '@components/Avatar/connected/usePolicyIcons'; + +import * as useOnyxModule from '@hooks/useOnyx'; + +import {getDefaultWorkspaceAvatar} from '@libs/ReportUtils'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {PersonalDetailsList} from '@src/types/onyx'; + +import Onyx from 'react-native-onyx'; + +import waitForBatchedUpdatesWithAct from '../../../../utils/waitForBatchedUpdatesWithAct'; + +const POLICY_ID = 'policy123'; +const OTHER_POLICY_ID = 'policy456'; +const POLICY_NAME = 'Acme Workspace'; +const OTHER_POLICY_NAME = 'Beta Workspace'; +const POLICY_AVATAR_URL = 'https://example.com/workspace-avatar.png'; +const FALLBACK_NAME = 'Pending Workspace'; + +const ACCOUNT_ID = 42; +const LOGIN = 'john@example.com'; +const ACCOUNT_AVATAR_URL = 'https://example.com/uploaded-avatar.png'; + +// Stands in for the bundled fallback SVG so a resolved account icon can be asserted by identity. +function MockFallbackAvatar() { + return null; +} + +jest.mock('@hooks/useLazyAsset', () => ({ + useMemoizedLazyExpensifyIcons: () => ({ + ConciergeAvatar: MockFallbackAvatar, + NotificationsAvatar: MockFallbackAvatar, + FallbackAvatar: MockFallbackAvatar, + }), +})); + +let mockPersonalDetails: PersonalDetailsList = {}; + +jest.mock('@components/OnyxListItemProvider', () => ({ + usePersonalDetails: () => mockPersonalDetails, +})); + +// Passthrough spy on `useOnyx`, so a test can assert which keys the hook subscribes to. +const useOnyxSpy = jest.spyOn(useOnyxModule, 'default'); + +describe('usePolicyIcons', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(() => { + mockPersonalDetails = { + [ACCOUNT_ID]: {accountID: ACCOUNT_ID, login: LOGIN, avatar: ACCOUNT_AVATAR_URL}, + }; + useOnyxSpy.mockClear(); + }); + + afterEach(async () => { + await Onyx.clear(); + await waitForBatchedUpdatesWithAct(); + }); + + it.each([ + ['the uploaded avatar when the policy has one', {id: POLICY_ID, name: POLICY_NAME, avatarURL: POLICY_AVATAR_URL}, POLICY_AVATAR_URL, POLICY_NAME], + // A workspace with no uploaded avatar stores an empty string, which has to fall through to the default avatar. + ['the default avatar when avatarURL is an empty string', {id: POLICY_ID, name: POLICY_NAME, avatarURL: ''}, getDefaultWorkspaceAvatar(POLICY_NAME), POLICY_NAME], + ['the default avatar when avatarURL is absent', {id: POLICY_ID, name: POLICY_NAME}, getDefaultWorkspaceAvatar(POLICY_NAME), POLICY_NAME], + ])('should resolve %s', async (_case, policy, expectedSource, expectedName) => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, policy); + await waitForBatchedUpdatesWithAct(); + + const {result} = renderHook(() => usePolicyIcons(POLICY_ID)); + + expect(result.current).toEqual([ + { + id: POLICY_ID, + type: CONST.ICON_TYPE_WORKSPACE, + source: expectedSource, + name: expectedName, + }, + ]); + }); + + it('should seed the icon from the fallback display name when the policy is not in Onyx', () => { + const {result} = renderHook(() => usePolicyIcons(POLICY_ID, undefined, FALLBACK_NAME)); + + expect(result.current).toEqual([ + { + id: POLICY_ID, + type: CONST.ICON_TYPE_WORKSPACE, + source: getDefaultWorkspaceAvatar(FALLBACK_NAME), + name: FALLBACK_NAME, + }, + ]); + }); + + it('should fall back to the building avatar when neither the policy nor a fallback name is available', () => { + const {result} = renderHook(() => usePolicyIcons(POLICY_ID)); + + expect(result.current).toEqual([ + { + id: POLICY_ID, + type: CONST.ICON_TYPE_WORKSPACE, + source: getDefaultWorkspaceAvatar(''), + name: '', + }, + ]); + }); + + it('should append the account icon after the workspace icon', async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, {id: POLICY_ID, name: POLICY_NAME, avatarURL: POLICY_AVATAR_URL}); + await waitForBatchedUpdatesWithAct(); + + const {result} = renderHook(() => usePolicyIcons(POLICY_ID, ACCOUNT_ID)); + + expect(result.current).toHaveLength(2); + expect(result.current.at(0)).toEqual(expect.objectContaining({id: POLICY_ID, type: CONST.ICON_TYPE_WORKSPACE})); + expect(result.current.at(1)).toEqual(expect.objectContaining({id: ACCOUNT_ID, type: CONST.ICON_TYPE_AVATAR, name: LOGIN})); + }); + + it('should resolve the icon again when the policy changes', async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, {id: POLICY_ID, name: POLICY_NAME, avatarURL: POLICY_AVATAR_URL}); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${OTHER_POLICY_ID}`, {id: OTHER_POLICY_ID, name: OTHER_POLICY_NAME}); + await waitForBatchedUpdatesWithAct(); + + const {result, rerender} = renderHook((policyID: string) => usePolicyIcons(policyID), {initialProps: POLICY_ID}); + rerender(OTHER_POLICY_ID); + + expect(result.current.at(0)).toEqual({ + id: OTHER_POLICY_ID, + type: CONST.ICON_TYPE_WORKSPACE, + source: getDefaultWorkspaceAvatar(OTHER_POLICY_NAME), + name: OTHER_POLICY_NAME, + }); + }); + + it('should not subscribe to the whole policy collection when the policy ID is an empty string', () => { + const {result} = renderHook(() => usePolicyIcons('', undefined, FALLBACK_NAME)); + + // An empty policy ID has to resolve to a keyed miss, never to the collection root key. + const subscribedKeys = useOnyxSpy.mock.calls.map(([key]) => key); + expect(subscribedKeys).not.toContain(ONYXKEYS.COLLECTION.POLICY); + expect(subscribedKeys).toContain(`${ONYXKEYS.COLLECTION.POLICY}undefined`); + + expect(result.current).toEqual([ + { + id: '', + type: CONST.ICON_TYPE_WORKSPACE, + source: getDefaultWorkspaceAvatar(FALLBACK_NAME), + name: FALLBACK_NAME, + }, + ]); + }); +});