Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 72 additions & 11 deletions src/libs/actions/Policy/Member.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,19 @@ import {importApprovalWorkflowRules} from '@userActions/Workflow';

import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {ImportedSpreadsheetMemberData, InvitedEmailsToAccountIDs, Policy, PolicyEmployee, PolicyOwnershipChangeChecks, Report, ReportAction, ReportActions, Rule} from '@src/types/onyx';
import type {
ImportedSpreadsheetMemberData,
InvitedEmailsToAccountIDs,
Policy,
PolicyCategories,
PolicyCategory,
PolicyEmployee,
PolicyOwnershipChangeChecks,
Report,
ReportAction,
ReportActions,
Rule,
} from '@src/types/onyx';
import type {ImportFinalModal} from '@src/types/onyx/ImportedSpreadsheet';
import type {ImportedSpreadsheetApprover} from '@src/types/onyx/ImportedSpreadsheetMemberData';
import type {PendingAction} from '@src/types/onyx/OnyxCommon';
Expand All @@ -46,7 +58,7 @@ import type {NotificationPreference, Participant} from '@src/types/onyx/Report';
import type {OnyxData} from '@src/types/onyx/Request';
import {isEmptyObject} from '@src/types/utils/EmptyObject';

import type {OnyxCollection, OnyxCollectionInputValue, OnyxEntry, OnyxUpdate} from 'react-native-onyx';
import type {NullishDeep, OnyxCollection, OnyxCollectionInputValue, OnyxEntry, OnyxUpdate} from 'react-native-onyx';
import type {ValueOf} from 'type-fest';

import Onyx from 'react-native-onyx';
Expand All @@ -60,6 +72,20 @@ type WorkspaceMembersRoleData = {
role: ValueOf<typeof CONST.POLICY.ROLE>;
};

type AddMembersToWorkspaceOptions = {
/** Who the invited members submit to, instead of the workspace's default approver */
approverEmail?: string;

/** The notification preference of the invited members' workspace chats */
policyExpenseChatNotificationPreference?: NotificationPreference;

/** Whether the invited members work from the office, for commuter exclusions */
hasOfficeWorkArrangement?: boolean;

/** The workspace's categories, so the invited members can use every category until the backend sends visibleTo */
policyCategories?: OnyxEntry<PolicyCategories>;
};

function hasPolicyAdminsRoomsAccess(role: string | undefined): boolean {
return (
role === CONST.POLICY.ROLE.ADMIN ||
Expand Down Expand Up @@ -838,6 +864,36 @@ function clearWorkspaceOwnerChangeFlow(policyID: string | undefined) {
});
}

/**
* A new member has no category restriction, so they can use every category. Add them to each category limited to some
* members, so their category visibility is right before the backend sends visibleTo, and offline.
*/
function buildInvitedMembersCategoryVisibilityOnyxData(policyID: string, policyCategories: OnyxEntry<PolicyCategories>, accountIDs: number[]) {
const optimisticCategories: Record<string, NullishDeep<PolicyCategory>> = {};
const failureCategories: Record<string, NullishDeep<PolicyCategory>> = {};
for (const [categoryName, category] of Object.entries(policyCategories ?? {})) {
const visibleTo = category.visibleTo;
if (!visibleTo) {
continue;
}
const hiddenAccountIDs = accountIDs.filter((accountID) => !visibleTo[accountID]);
if (hiddenAccountIDs.length === 0) {
continue;
}
optimisticCategories[categoryName] = {visibleTo: Object.fromEntries(hiddenAccountIDs.map((accountID) => [accountID, true]))};
failureCategories[categoryName] = {visibleTo: Object.fromEntries(hiddenAccountIDs.map((accountID) => [accountID, null]))};
}

if (isEmptyObject(optimisticCategories)) {
return {optimisticData: [], failureData: []};
}

const key = `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}` as const;
const optimisticData: Array<OnyxUpdate<typeof ONYXKEYS.COLLECTION.POLICY_CATEGORIES>> = [{onyxMethod: Onyx.METHOD.MERGE, key, value: optimisticCategories}];
const failureData: Array<OnyxUpdate<typeof ONYXKEYS.COLLECTION.POLICY_CATEGORIES>> = [{onyxMethod: Onyx.METHOD.MERGE, key, value: failureCategories}];
return {optimisticData, failureData};
Comment thread
nkdengineer marked this conversation as resolved.
Resolved
}

function buildAddMembersToWorkspaceOnyxData(
invitedEmailsToAccountIDs: InvitedEmailsToAccountIDs,
newPersonalDetailsOnyxData: OnyxData<typeof ONYXKEYS.PERSONAL_DETAILS_LIST>,
Expand All @@ -846,9 +902,7 @@ function buildAddMembersToWorkspaceOnyxData(
role: string,
currentUser: CurrentUser,
reportActionsList: OnyxCollection<ReportActions> | undefined,
approverEmail?: string,
policyExpenseChatNotificationPreference?: NotificationPreference,
hasOfficeWorkArrangement?: boolean,
{approverEmail, policyExpenseChatNotificationPreference, hasOfficeWorkArrangement, policyCategories}: AddMembersToWorkspaceOptions = {},
) {
const policyID = policy.id;
const logins = Object.keys(invitedEmailsToAccountIDs).map((memberLogin) => PhoneNumber.addSMSDomainIfPhoneNumber(memberLogin));
Expand All @@ -863,6 +917,7 @@ function buildAddMembersToWorkspaceOnyxData(
const adminRoomMembers = buildRoomMembersOnyxData(CONST.REPORT.CHAT_TYPE.POLICY_ADMINS, policyID, hasPolicyAdminsRoomsAccess(effectiveRole) ? accountIDs : []);
const optimisticAnnounceChat = ReportUtils.buildOptimisticAnnounceChat(policyID, [...policyMemberAccountIDs, ...accountIDs], currentUser.accountID);
const announceRoomChat = optimisticAnnounceChat.announceChatData;
const categoryVisibilityOnyxData = buildInvitedMembersCategoryVisibilityOnyxData(policyID, policyCategories, accountIDs);

const doesPersonalDetailExistByAccountID: Record<number, boolean> = {};
const newPersonalDetailAccountIDs = new Set<number>();
Expand Down Expand Up @@ -918,6 +973,7 @@ function buildAddMembersToWorkspaceOnyxData(
| typeof ONYXKEYS.COLLECTION.REPORT_METADATA
| typeof ONYXKEYS.COLLECTION.REPORT
| typeof ONYXKEYS.COLLECTION.REPORT_DRAFT
| typeof ONYXKEYS.COLLECTION.POLICY_CATEGORIES
>
| PersonalDetailsOnyxUpdate
> = [
Expand All @@ -937,6 +993,7 @@ function buildAddMembersToWorkspaceOnyxData(
...announceRoomChat.onyxOptimisticData,
...(announceRoomMembers.optimisticData ?? []),
...(adminRoomMembers.optimisticData ?? []),
...categoryVisibilityOnyxData.optimisticData,
);

const successData: Array<
Expand Down Expand Up @@ -967,6 +1024,7 @@ function buildAddMembersToWorkspaceOnyxData(
| typeof ONYXKEYS.COLLECTION.REPORT_METADATA
| typeof ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE
| typeof ONYXKEYS.COLLECTION.REPORT
| typeof ONYXKEYS.COLLECTION.POLICY_CATEGORIES
>
| PersonalDetailsOnyxUpdate
> = [
Expand All @@ -981,7 +1039,13 @@ function buildAddMembersToWorkspaceOnyxData(
},
},
];
failureData.push(...membersChats.onyxFailureData, ...announceRoomChat.onyxFailureData, ...(announceRoomMembers.failureData ?? []), ...(adminRoomMembers.failureData ?? []));
failureData.push(
...membersChats.onyxFailureData,
...announceRoomChat.onyxFailureData,
...(announceRoomMembers.failureData ?? []),
...(adminRoomMembers.failureData ?? []),
...categoryVisibilityOnyxData.failureData,
);

return {optimisticData, successData, failureData, optimisticAnnounceChat, membersChats, logins, effectiveRole};
}
Expand All @@ -999,8 +1063,7 @@ function addMembersToWorkspace(
role: string,
currentUser: CurrentUser,
reportActionsList: OnyxCollection<ReportActions>,
approverEmail?: string,
hasOfficeWorkArrangement?: boolean,
{approverEmail, hasOfficeWorkArrangement, policyCategories}: Omit<AddMembersToWorkspaceOptions, 'policyExpenseChatNotificationPreference'> = {},
) {
if (!policy?.id) {
Log.warn('addMembersToWorkspace: Policy ID is undefined');
Expand All @@ -1016,9 +1079,7 @@ function addMembersToWorkspace(
role,
currentUser,
reportActionsList,
approverEmail,
undefined,
hasOfficeWorkArrangement,
{approverEmail, hasOfficeWorkArrangement, policyCategories},
);

const params: AddMembersToWorkspaceParams = {
Expand Down
3 changes: 1 addition & 2 deletions src/libs/actions/Report/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9099,8 +9099,7 @@ function changeReportPolicyAndInviteSubmitter({
CONST.POLICY.ROLE.USER,
currentUser,
reportActionsList,
undefined,
CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS,
{policyExpenseChatNotificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS},
);
const optimisticPolicyExpenseChatReportID = membersChats.reportCreationData[submitterLogin].reportID;
const optimisticPolicyExpenseChatCreatedReportActionID = membersChats.reportCreationData[submitterLogin].reportActionID;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import {isEmptyObject} from '@src/types/utils/EmptyObject';
import type {OnyxEntry} from 'react-native-onyx';
import type {GestureResponderEvent} from 'react-native/Libraries/Types/CoreEventTypes';

import policyCategoriesVisibilitySelector from '@selectors/PolicyCategories';
import {Str} from 'expensify-common';
import React, {useEffect, useState} from 'react';
import {Keyboard, View} from 'react-native';
Expand Down Expand Up @@ -122,6 +123,7 @@ function WorkspaceInviteMessageComponent({
const workspaceInviteRoleDraft = isSubmitPolicy(policy) ? CONST.POLICY.ROLE.EDITOR : draftInviteRole;
const defaultApprover = getDefaultApprover(policy);
const [approverDraft] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_INVITE_APPROVER_DRAFT}${policyID}`);
const [policyCategoriesVisibility] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, {selector: policyCategoriesVisibilitySelector});
Comment thread
nkdengineer marked this conversation as resolved.
Resolved
const workspaceInviteApproverDraft = approverDraft ?? defaultApprover;
const approverDetails = usePersonalDetailByLogin(workspaceInviteApproverDraft);
const employeePersonalDetails = usePersonalDetailsByLogins(Object.keys(policy?.employeeList ?? {}));
Expand Down Expand Up @@ -217,8 +219,11 @@ function WorkspaceInviteMessageComponent({
avatar: currentUserPersonalDetails?.avatar,
},
filteredReportActions,
shouldShowApproverRow ? validatedApprover : undefined,
shouldShowWorkArrangement ? selectedHasOfficeWorkArrangement : undefined,
{
approverEmail: shouldShowApproverRow ? validatedApprover : undefined,
hasOfficeWorkArrangement: shouldShowWorkArrangement ? selectedHasOfficeWorkArrangement : undefined,
policyCategories: policyCategoriesVisibility,
},
);
setWorkspaceInviteMessageDraft(policyID, welcomeNote ?? null);
clearWorkspaceInviteWorkArrangementDraft(policyID);
Expand Down
61 changes: 61 additions & 0 deletions tests/actions/PolicyMemberTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -621,6 +621,67 @@ describe('actions/PolicyMember', () => {
await mockFetch?.resume?.();
});

it('lets an invited member use every category while some categories are limited to other members', async () => {
// Given a workspace where Fuel is limited to one member, Meals to a member being invited back with a stale entry, and Travel is open to everyone
const policyID = '1';
const existingAccountID = 1111;
const newAccountID = 1234;
const returningAccountID = 5678;
const newEmail = 'new@gmail.com';
const returningEmail = 'returning@gmail.com';
const policy = createRandomPolicy(Number(policyID));
const policyCategories = {
Fuel: {name: 'Fuel', enabled: true, visibleTo: {[existingAccountID]: true}},
Meals: {name: 'Meals', enabled: true, visibleTo: {[existingAccountID]: true, [returningAccountID]: true}},
Travel: {name: 'Travel', enabled: true, visibleTo: null},
};
await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, policyCategories);

// When a new member and a previously restricted member are invited, before the backend responds
mockFetch?.pause?.();
Member.addMembersToWorkspace(
{[newEmail]: newAccountID, [returningEmail]: returningAccountID},
{},
'Welcome',
policy,
[existingAccountID],
CONST.POLICY.ROLE.USER,
currentUser,
{},
{
policyCategories,
},
);
await waitForBatchedUpdates();

// Then both are added to every limited category, since a new member has no restriction, and Travel stays open to everyone
const categories = await getOnyxValue(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`);
expect(categories?.Fuel?.visibleTo).toEqual({[existingAccountID]: true, [newAccountID]: true, [returningAccountID]: true});
expect(categories?.Meals?.visibleTo).toEqual({[existingAccountID]: true, [newAccountID]: true, [returningAccountID]: true});
expect(categories?.Travel?.visibleTo).toBeFalsy();
await mockFetch?.resume?.();
});

it('removes invited members from limited categories when the invite fails', async () => {
// Given a workspace where Fuel is limited to one member
const policyID = '1';
const existingAccountID = 1111;
const newAccountID = 1234;
const newEmail = 'new@gmail.com';
const policy = createRandomPolicy(Number(policyID));
const policyCategories = {Fuel: {name: 'Fuel', enabled: true, visibleTo: {[existingAccountID]: true}}};
await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, policyCategories);

// When inviting a new member fails
mockFetch?.fail?.();
Member.addMembersToWorkspace({[newEmail]: newAccountID}, {}, 'Welcome', policy, [existingAccountID], CONST.POLICY.ROLE.USER, currentUser, {}, {policyCategories});
await waitForBatchedUpdates();

// Then Fuel is limited to the existing member again, since the new member never joined
const categories = await getOnyxValue(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`);
expect(categories?.Fuel?.visibleTo).toEqual({[existingAccountID]: true});
});

it('uses new personal details onyx data to decide new-member success-data participants instead of the deprecated copy', () => {
const policyID = '1';
const policy = {...createRandomPolicy(Number(policyID))};
Expand Down
6 changes: 3 additions & 3 deletions tests/ui/WorkspaceInviteMessageDEWApproverRowTest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -162,8 +162,8 @@ describe('WorkspaceInviteMessageComponent - Approver row on Dynamic External Wor

// Then the default approver is forwarded so the backend sets it as the new member's submitsTo
expect(addMembersToWorkspaceMock).toHaveBeenCalledTimes(1);
// The work arrangement is passed after the approver, so the approver is the second-to-last argument.
expect(addMembersToWorkspaceMock.mock.lastCall?.at(-2)).toBe(ADMIN_EMAIL);
// The approver is passed in the options object, the last argument.
expect(addMembersToWorkspaceMock.mock.lastCall?.[8]?.approverEmail).toBe(ADMIN_EMAIL);
});

it('hides the Approver row and sends no approver on a DEW workspace with "Hide People Table Columns" set', async () => {
Expand All @@ -178,7 +178,7 @@ describe('WorkspaceInviteMessageComponent - Approver row on Dynamic External Wor

// Then no approver is forwarded, so the invite cannot override the external workflow's routing
expect(addMembersToWorkspaceMock).toHaveBeenCalledTimes(1);
expect(addMembersToWorkspaceMock.mock.lastCall?.at(-1)).toBeUndefined();
expect(addMembersToWorkspaceMock.mock.lastCall?.[8]?.approverEmail).toBeUndefined();
});

it('still shows the Approver row on an Advanced workspace with a stale "Hide People Table Columns" flag', async () => {
Expand Down
2 changes: 1 addition & 1 deletion tests/ui/WorkspaceInviteMessageFastEditSaveTest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,7 @@ describe('WorkspaceInviteMessageComponent - "+N more" workflow edit', () => {
await renderAndPressInvite(DIRECT_EXPENSES_FROM_ROUTE);

// Then the invite assigns Dana to Alice while the fast-edit save leaves Dana out so it cannot reassign them to Carol
expect(addMembersToWorkspaceMock.mock.lastCall?.at(-2)).toBe(ALICE_EMAIL);
expect(addMembersToWorkspaceMock.mock.lastCall?.[8]?.approverEmail).toBe(ALICE_EMAIL);
expect(saveFastEditApprovalWorkflowMock).toHaveBeenCalledTimes(1);
const [params] = saveFastEditApprovalWorkflowMock.mock.calls.at(0) ?? [];
expect(params?.approvalWorkflow.members.map((member) => member.email)).toEqual([ALICE_EMAIL, BOB_EMAIL]);
Expand Down
Loading