From 623e2f8a5a827b322223c1fefe5d88517529f8f9 Mon Sep 17 00:00:00 2001 From: Nicolas Bonet Date: Mon, 6 Jul 2026 12:45:39 -0500 Subject: [PATCH 1/3] Fix missing owner details on pending join request workspace rows The owner of a workspace the user only requested to join is usually not in the personal details list, so the owner column rendered empty. Fall back to the ownerEmail already provided by policyDetailsForNonMembers, and use the pending policy name for the default workspace avatar. Co-Authored-By: Claude Fable 5 --- src/pages/workspace/WorkspacesListPage.tsx | 18 ++++++++-- src/selectors/Policy.ts | 3 +- tests/ui/WorkspaceListPageTest.tsx | 26 ++++++++++++++ tests/unit/selectors/PolicyTest.ts | 40 ++++++++++++++++++++++ 4 files changed, 83 insertions(+), 4 deletions(-) diff --git a/src/pages/workspace/WorkspacesListPage.tsx b/src/pages/workspace/WorkspacesListPage.tsx index 60b2a2e5a2f2..2c5b93f383c0 100755 --- a/src/pages/workspace/WorkspacesListPage.tsx +++ b/src/pages/workspace/WorkspacesListPage.tsx @@ -27,6 +27,7 @@ import type {WorkspaceNavigatorParamList} from '@libs/Navigation/types'; import {temporaryGetDisplayNameOrDefault} from '@libs/PersonalDetailsUtils'; import {getDefaultWorkspaceAvatar} from '@libs/ReportUtils'; import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; +import {getDefaultAvatarURL} from '@libs/UserAvatarUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -99,8 +100,19 @@ function WorkspacesListPage() { for (const policy of workspaceListPolicies ?? []) { if (policy.isJoinRequestPending && policy.nonMemberDetails) { - const {policyID, ownerAccountID} = policy.nonMemberDetails; - const ownerDetails = ownerAccountID ? ownerDisplayDetails?.[ownerAccountID] : undefined; + const {policyID, ownerAccountID, ownerEmail} = policy.nonMemberDetails; + let ownerDetails = ownerAccountID ? ownerDisplayDetails?.[ownerAccountID] : undefined; + + // The owner of a policy the user only requested to join is usually not in the personal details list, + // so fall back to the owner email and default avatar the join request already provides. + if (!ownerDetails && ownerAccountID && ownerEmail) { + ownerDetails = { + accountID: ownerAccountID, + login: ownerEmail, + displayName: ownerEmail, + avatar: getDefaultAvatarURL({accountID: ownerAccountID, accountEmail: ownerEmail}), + }; + } const pendingWorkspaceRow: WorkspaceRowData = { keyForList: policyID, @@ -119,7 +131,7 @@ function WorkspacesListPage() { ownerAvatar: ownerDetails ? ownerDetails.avatar : undefined, ownerName: ownerDetails ? temporaryGetDisplayNameOrDefault({passedPersonalDetails: ownerDetails, translate}) : undefined, iconType: policy.nonMemberDetails.avatar ? CONST.ICON_TYPE_AVATAR : CONST.ICON_TYPE_ICON, - icon: policy.nonMemberDetails.avatar ? policy.nonMemberDetails.avatar : getDefaultWorkspaceAvatar(policy.name), + icon: policy.nonMemberDetails.avatar ? policy.nonMemberDetails.avatar : getDefaultWorkspaceAvatar(policy.nonMemberDetails.name), action: () => null, dismissError: () => null, }; diff --git a/src/selectors/Policy.ts b/src/selectors/Policy.ts index ed84c7aad07e..98198d4d33ee 100644 --- a/src/selectors/Policy.ts +++ b/src/selectors/Policy.ts @@ -81,7 +81,7 @@ type WorkspaceListPolicy = Pick & {policyID: string}; + nonMemberDetails?: Pick & {policyID: string}; }; /** @@ -109,6 +109,7 @@ const createWorkspaceListPoliciesSelector = name: details.name, type: details.type, ownerAccountID: details.ownerAccountID, + ownerEmail: details.ownerEmail, avatar: details.avatar, }; } diff --git a/tests/ui/WorkspaceListPageTest.tsx b/tests/ui/WorkspaceListPageTest.tsx index 33e16b0b2042..b16a118cce73 100644 --- a/tests/ui/WorkspaceListPageTest.tsx +++ b/tests/ui/WorkspaceListPageTest.tsx @@ -9,6 +9,7 @@ import type {WorkspaceNavigatorParamList} from '@libs/Navigation/types'; import WorkspacesListPage from '@pages/workspace/WorkspacesListPage'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import SCREENS from '@src/SCREENS'; @@ -135,6 +136,31 @@ describe('WorkspaceListPage', () => { expect(newWorkspaceButton).toBeOnTheScreen(); }); + it('should show the owner email of a pending join request workspace when the owner is not in the personal details list', async () => { + const TEST_POLICY_ID = 'pending-policy-id'; + const OWNER_EMAIL = 'owner@example.com'; + + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${TEST_POLICY_ID}`, { + id: TEST_POLICY_ID, + isJoinRequestPending: true, + policyDetailsForNonMembers: { + [TEST_POLICY_ID]: { + name: 'Pending Workspace', + type: CONST.POLICY.TYPE.TEAM, + ownerAccountID: 42, + ownerEmail: OWNER_EMAIL, + }, + }, + }); + + renderPage(); + + await waitForBatchedUpdatesWithAct(); + + expect(screen.getByText('Pending Workspace')).toBeOnTheScreen(); + expect(screen.getByText(new RegExp(OWNER_EMAIL))).toBeOnTheScreen(); + }); + it('should show a "New workspace" button when there are workspaces but no domains', async () => { const TEST_POLICY_ID = 'test-policy-id'; diff --git a/tests/unit/selectors/PolicyTest.ts b/tests/unit/selectors/PolicyTest.ts index 0d8029563367..b22cffd6e858 100644 --- a/tests/unit/selectors/PolicyTest.ts +++ b/tests/unit/selectors/PolicyTest.ts @@ -7,6 +7,7 @@ import { activeAdminPoliciesSelector, adminPoliciesConnectedToQBDSelector, createOwnedPaidPoliciesCountsSelector, + createWorkspaceListPoliciesSelector, hasMultipleOutputCurrenciesSelector, hasOnlyPersonalPoliciesSelector, hasPoliciesConnectedToQBDSelector, @@ -485,3 +486,42 @@ describe('hasOnlyPersonalPoliciesSelector', () => { expect(hasOnlyPersonalPoliciesSelector(policies)).toBe(false); }); }); + +describe('createWorkspaceListPoliciesSelector', () => { + it('projects owner details from policyDetailsForNonMembers for join-request-pending policies', () => { + const pendingPolicyID = '1'; + const policies: OnyxCollection = { + pendingPolicy: buildSelectorPolicy(1, { + isJoinRequestPending: true, + policyDetailsForNonMembers: { + [pendingPolicyID]: { + name: 'Pending Workspace', + type: CONST.POLICY.TYPE.TEAM, + ownerAccountID: 42, + ownerEmail: 'owner@example.com', + avatar: '', + }, + }, + }), + }; + + const selector = createWorkspaceListPoliciesSelector(TEST_LOGIN); + expect(selector(policies).at(0)?.nonMemberDetails).toEqual({ + policyID: '1', + name: 'Pending Workspace', + type: CONST.POLICY.TYPE.TEAM, + ownerAccountID: 42, + ownerEmail: 'owner@example.com', + avatar: '', + }); + }); + + it('leaves nonMemberDetails undefined for regular policies', () => { + const policies: OnyxCollection = { + teamPolicy: buildSelectorPolicy(1, {role: CONST.POLICY.ROLE.ADMIN}), + }; + + const selector = createWorkspaceListPoliciesSelector(TEST_LOGIN); + expect(selector(policies).at(0)?.nonMemberDetails).toBeUndefined(); + }); +}); From 5d98bf84f97d204038a2b61a5579737c7a93aaa9 Mon Sep 17 00:00:00 2001 From: Nicolas Bonet Date: Wed, 8 Jul 2026 10:26:45 -0700 Subject: [PATCH 2/3] Address review feedback: derive owner default avatar in selector, dedupe tests - Derive the pending join request owner's default avatar URL in createWorkspaceListPoliciesSelector so the email is only hashed when policy data changes, not on every page render. - Remove the duplicated createWorkspaceListPoliciesSelector suite from tests/unit/selectors/PolicyTest.ts and extend the existing projection test in tests/unit/PolicySelectorTest.ts with ownerEmail and the derived ownerDefaultAvatar. - Assert the workspace default avatar test ID in the pending join request UI test. Co-Authored-By: Claude Fable 5 --- src/pages/workspace/WorkspacesListPage.tsx | 5 ++- src/selectors/Policy.ts | 10 +++++- tests/ui/WorkspaceListPageTest.tsx | 4 +++ tests/unit/PolicySelectorTest.ts | 5 +++ tests/unit/selectors/PolicyTest.ts | 40 ---------------------- 5 files changed, 20 insertions(+), 44 deletions(-) diff --git a/src/pages/workspace/WorkspacesListPage.tsx b/src/pages/workspace/WorkspacesListPage.tsx index 2c5b93f383c0..5b3e3f524795 100755 --- a/src/pages/workspace/WorkspacesListPage.tsx +++ b/src/pages/workspace/WorkspacesListPage.tsx @@ -27,7 +27,6 @@ import type {WorkspaceNavigatorParamList} from '@libs/Navigation/types'; import {temporaryGetDisplayNameOrDefault} from '@libs/PersonalDetailsUtils'; import {getDefaultWorkspaceAvatar} from '@libs/ReportUtils'; import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; -import {getDefaultAvatarURL} from '@libs/UserAvatarUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -100,7 +99,7 @@ function WorkspacesListPage() { for (const policy of workspaceListPolicies ?? []) { if (policy.isJoinRequestPending && policy.nonMemberDetails) { - const {policyID, ownerAccountID, ownerEmail} = policy.nonMemberDetails; + const {policyID, ownerAccountID, ownerEmail, ownerDefaultAvatar} = policy.nonMemberDetails; let ownerDetails = ownerAccountID ? ownerDisplayDetails?.[ownerAccountID] : undefined; // The owner of a policy the user only requested to join is usually not in the personal details list, @@ -110,7 +109,7 @@ function WorkspacesListPage() { accountID: ownerAccountID, login: ownerEmail, displayName: ownerEmail, - avatar: getDefaultAvatarURL({accountID: ownerAccountID, accountEmail: ownerEmail}), + avatar: ownerDefaultAvatar, }; } diff --git a/src/selectors/Policy.ts b/src/selectors/Policy.ts index d2debd41533e..84a9060406d7 100644 --- a/src/selectors/Policy.ts +++ b/src/selectors/Policy.ts @@ -1,6 +1,7 @@ import {hasSynchronizationErrorMessage, isConnectionUnverified} from '@libs/actions/connections'; import {getDisplayNameForWorkspace} from '@libs/actions/Policy/Policy'; import {getActiveAdminWorkspaces, getOwnedPaidPolicies, isPendingDeletePolicy, isPolicyAdmin, shouldShowPolicy} from '@libs/PolicyUtils'; +import {getDefaultAvatarURL} from '@libs/UserAvatarUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -81,7 +82,12 @@ type WorkspaceListPolicy = Pick & {policyID: string}; + nonMemberDetails?: Pick & { + policyID: string; + + /** Default avatar URL for the owner, derived here so the page doesn't re-hash the email on every render */ + ownerDefaultAvatar?: string; + }; }; /** @@ -110,6 +116,8 @@ const createWorkspaceListPoliciesSelector = type: details.type, ownerAccountID: details.ownerAccountID, ownerEmail: details.ownerEmail, + ownerDefaultAvatar: + details.ownerAccountID && details.ownerEmail ? getDefaultAvatarURL({accountID: details.ownerAccountID, accountEmail: details.ownerEmail}) : undefined, avatar: details.avatar, }; } diff --git a/tests/ui/WorkspaceListPageTest.tsx b/tests/ui/WorkspaceListPageTest.tsx index b16a118cce73..934bc4a0c324 100644 --- a/tests/ui/WorkspaceListPageTest.tsx +++ b/tests/ui/WorkspaceListPageTest.tsx @@ -159,6 +159,10 @@ describe('WorkspaceListPage', () => { expect(screen.getByText('Pending Workspace')).toBeOnTheScreen(); expect(screen.getByText(new RegExp(OWNER_EMAIL))).toBeOnTheScreen(); + // The workspace default icon is derived from nonMemberDetails.name ("Pending Workspace"), so the + // rendered SVG test ID is keyed by its first alphanumeric character. The icon is decorative and + // hidden from accessibility, so the query must include hidden elements. + expect(screen.getByTestId('SvgDefaultAvatar_p Icon', {includeHiddenElements: true})).toBeOnTheScreen(); }); it('should show a "New workspace" button when there are workspaces but no domains', async () => { diff --git a/tests/unit/PolicySelectorTest.ts b/tests/unit/PolicySelectorTest.ts index 1b74062f09f1..b2da219ddc09 100644 --- a/tests/unit/PolicySelectorTest.ts +++ b/tests/unit/PolicySelectorTest.ts @@ -1,3 +1,5 @@ +import {getDefaultAvatarURL} from '@libs/UserAvatarUtils'; + import CONST from '@src/CONST'; import IntlStore from '@src/languages/IntlStore'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -352,6 +354,7 @@ describe('createWorkspaceListPoliciesSelector', () => { name: 'External WS', type: CONST.POLICY.TYPE.CORPORATE, ownerAccountID: 99, + ownerEmail: 'owner@example.com', avatar: 'https://img/ext.png', }, }, @@ -364,6 +367,8 @@ describe('createWorkspaceListPoliciesSelector', () => { name: 'External WS', type: CONST.POLICY.TYPE.CORPORATE, ownerAccountID: 99, + ownerEmail: 'owner@example.com', + ownerDefaultAvatar: getDefaultAvatarURL({accountID: 99, accountEmail: 'owner@example.com'}), avatar: 'https://img/ext.png', }); }); diff --git a/tests/unit/selectors/PolicyTest.ts b/tests/unit/selectors/PolicyTest.ts index 68a6fd305428..d2dccb2a2e8b 100644 --- a/tests/unit/selectors/PolicyTest.ts +++ b/tests/unit/selectors/PolicyTest.ts @@ -7,7 +7,6 @@ import { activeAdminPoliciesSelector, adminPoliciesConnectedToQBDSelector, createOwnedPaidPoliciesCountsSelector, - createWorkspaceListPoliciesSelector, hasOnlyPersonalPoliciesSelector, hasReusablePoliciesConnectedToSelector, reusablePoliciesConnectedToSelector, @@ -419,42 +418,3 @@ describe('hasOnlyPersonalPoliciesSelector', () => { expect(hasOnlyPersonalPoliciesSelector(policies)).toBe(false); }); }); - -describe('createWorkspaceListPoliciesSelector', () => { - it('projects owner details from policyDetailsForNonMembers for join-request-pending policies', () => { - const pendingPolicyID = '1'; - const policies: OnyxCollection = { - pendingPolicy: buildSelectorPolicy(1, { - isJoinRequestPending: true, - policyDetailsForNonMembers: { - [pendingPolicyID]: { - name: 'Pending Workspace', - type: CONST.POLICY.TYPE.TEAM, - ownerAccountID: 42, - ownerEmail: 'owner@example.com', - avatar: '', - }, - }, - }), - }; - - const selector = createWorkspaceListPoliciesSelector(TEST_LOGIN); - expect(selector(policies).at(0)?.nonMemberDetails).toEqual({ - policyID: '1', - name: 'Pending Workspace', - type: CONST.POLICY.TYPE.TEAM, - ownerAccountID: 42, - ownerEmail: 'owner@example.com', - avatar: '', - }); - }); - - it('leaves nonMemberDetails undefined for regular policies', () => { - const policies: OnyxCollection = { - teamPolicy: buildSelectorPolicy(1, {role: CONST.POLICY.ROLE.ADMIN}), - }; - - const selector = createWorkspaceListPoliciesSelector(TEST_LOGIN); - expect(selector(policies).at(0)?.nonMemberDetails).toBeUndefined(); - }); -}); From fa0510446d29f33985d588b03df7e3f6736474bd Mon Sep 17 00:00:00 2001 From: Nicolas Bonet Date: Wed, 8 Jul 2026 15:29:29 -0700 Subject: [PATCH 3/3] Add missing getDefaultAvatarURL import Co-Authored-By: Claude Fable 5 --- src/selectors/Policy.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/selectors/Policy.ts b/src/selectors/Policy.ts index f6ffe9fd02e0..3a15511e36b6 100644 --- a/src/selectors/Policy.ts +++ b/src/selectors/Policy.ts @@ -2,6 +2,7 @@ import {hasSynchronizationErrorMessage, isConnectionUnverified} from '@libs/acti import {getDisplayNameForWorkspace} from '@libs/actions/Policy/Policy'; // eslint-disable-next-line no-restricted-imports -- isPaidGroupPolicy is intentional: copy-settings targets are billing/paid-only (Collect/Control), so free group plans like Submit must be excluded (see createCopySettingsEligibleTargetsSelector). import {getActiveAdminWorkspaces, getOwnedPaidPolicies, isPaidGroupPolicy, isPendingDeletePolicy, isPolicyAdmin, shouldShowPolicy} from '@libs/PolicyUtils'; +import {getDefaultAvatarURL} from '@libs/UserAvatarUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS';