From 8cf00515100cb597876ad01cfd7ba94a21e43c46 Mon Sep 17 00:00:00 2001 From: alberto Date: Thu, 8 Oct 2026 15:08:47 +0200 Subject: [PATCH 01/19] Add handling of user-level offices --- src/CONST/index.ts | 5 + src/CONST/runtimeConfigured.ts | 2 + src/ONYXKEYS.ts | 4 + src/ROUTES.ts | 12 + src/SCREENS.ts | 3 + .../WorkspaceMembersTableRow.tsx | 20 ++ .../Tables/WorkspaceMembersTable/index.tsx | 22 +- src/languages/de.ts | 9 + src/languages/el.ts | 9 + src/languages/en.ts | 9 + src/languages/es.ts | 9 + src/languages/fr.ts | 9 + src/languages/it.ts | 9 + src/languages/ja.ts | 9 + src/languages/nl.ts | 9 + src/languages/pl.ts | 9 + src/languages/pt-BR.ts | 9 + src/languages/zh-hans.ts | 9 + .../UpdateEmployeeOfficeLocationParams.ts | 14 + src/libs/API/parameters/index.ts | 1 + src/libs/API/types.ts | 2 + src/libs/ExportOnyxState/masking.ts | 1 + .../ModalStackNavigators/index.tsx | 3 + .../RELATIONS/WORKSPACE_TO_RHP.ts | 3 + src/libs/Navigation/linkingConfig/config.ts | 9 + src/libs/Navigation/types.ts | 10 + src/libs/OfficeLocationUtils.ts | 67 +++++ src/libs/ReportActionsUtils.ts | 23 ++ src/libs/actions/Policy/DistanceRate.ts | 149 +++++++++++ src/libs/actions/Policy/Member.ts | 10 + .../actionContents/PolicyChangeLogContent.tsx | 2 + src/pages/workspace/WorkspaceMembersPage.tsx | 55 +++- .../members/WorkspaceMemberDetailsPage.tsx | 21 ++ .../WorkspaceMemberOfficeLocationPage.tsx | 106 ++++++++ ...rkspaceMembersChangeOfficeLocationPage.tsx | 127 +++++++++ ...kspaceMembersChangeWorkArrangementPage.tsx | 118 ++++++++ src/pages/workspace/withPolicy.tsx | 3 + src/setup/index.ts | 1 + src/types/onyx/OriginalMessage.ts | 25 ++ src/types/onyx/PolicyEmployee.ts | 6 + tests/ui/WorkspaceMemberDetailsPageTest.tsx | 62 +++++ .../WorkspaceMemberOfficeLocationPageTest.tsx | 140 ++++++++++ ...aceMembersChangeOfficeLocationPageTest.tsx | 148 ++++++++++ ...ceMembersChangeWorkArrangementPageTest.tsx | 134 ++++++++++ tests/ui/WorkspaceMembersTest.tsx | 128 +++++++++ tests/unit/DistanceRateTest.ts | 252 ++++++++++++++---- tests/unit/OfficeLocationUtilsTest.ts | 105 ++++++++ tests/unit/ReportActionsUtilsTest.ts | 36 +++ 48 files changed, 1871 insertions(+), 57 deletions(-) create mode 100644 src/libs/API/parameters/UpdateEmployeeOfficeLocationParams.ts create mode 100644 src/libs/OfficeLocationUtils.ts create mode 100644 src/pages/workspace/members/WorkspaceMemberOfficeLocationPage.tsx create mode 100644 src/pages/workspace/members/WorkspaceMembersChangeOfficeLocationPage.tsx create mode 100644 src/pages/workspace/members/WorkspaceMembersChangeWorkArrangementPage.tsx create mode 100644 tests/ui/WorkspaceMemberOfficeLocationPageTest.tsx create mode 100644 tests/ui/WorkspaceMembersChangeOfficeLocationPageTest.tsx create mode 100644 tests/ui/WorkspaceMembersChangeWorkArrangementPageTest.tsx create mode 100644 tests/unit/OfficeLocationUtilsTest.ts diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 954f802a3942..a70e7497bfeb 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1877,6 +1877,7 @@ const CONST = { ADD_OFFICE_LOCATION: 'POLICYCHANGELOG_ADD_OFFICE_LOCATION', UPDATE_OFFICE_LOCATION: 'POLICYCHANGELOG_UPDATE_OFFICE_LOCATION', DELETE_OFFICE_LOCATION: 'POLICYCHANGELOG_DELETE_OFFICE_LOCATION', + UPDATE_EMPLOYEE_OFFICE_LOCATION: 'POLICYCHANGELOG_UPDATE_EMPLOYEE_OFFICE_LOCATION', ADD_RULE: 'POLICYCHANGELOG_ADD_RULE', UPDATE_RULE: 'POLICYCHANGELOG_UPDATE_RULE', REMOVE_RULE: 'POLICYCHANGELOG_REMOVE_RULE', @@ -4669,6 +4670,8 @@ const CONST = { MAKE_CARD_ADMIN: 'makeCardAdmin', MAKE_PEOPLE_ADMIN: 'makePeopleAdmin', MAKE_PAYMENTS_ADMIN: 'makePaymentsAdmin', + CHANGE_OFFICE_LOCATION: 'changeOfficeLocation', + CHANGE_WORK_ARRANGEMENT: 'changeWorkArrangement', }, BULK_ACTION_TYPES: { DELETE: 'delete', @@ -4824,6 +4827,8 @@ const CONST = { ADDRESS: 'address', IS_DEFAULT: 'isDefault', }, + // The officeID that assigns a member to the company address, which isn't keyed in the workspace's officeLocations + COMPANY_ADDRESS_OFFICE_ID: '1', WORK_ARRANGEMENT: { OFFICE_BASED: 'officeBased', NO_REGULAR_WORKPLACE: 'noRegularWorkplace', diff --git a/src/CONST/runtimeConfigured.ts b/src/CONST/runtimeConfigured.ts index b392e0db9024..6c493c6b6a85 100644 --- a/src/CONST/runtimeConfigured.ts +++ b/src/CONST/runtimeConfigured.ts @@ -76,6 +76,8 @@ const CONST_RUNTIME: ConstRuntime = { SCREENS.AI_FEATURES_PROMO_MODAL.DYNAMIC_ROOT, SCREENS.MONEY_REQUEST.DYNAMIC_STEP_SCAN, SCREENS.DOMAIN.MEMBERS_MOVE_TO_GROUP, + SCREENS.WORKSPACE.MEMBERS_CHANGE_OFFICE_LOCATION, + SCREENS.WORKSPACE.MEMBERS_CHANGE_WORK_ARRANGEMENT, SCREENS.PRE_MOUNT_BUFFER, ...Object.values(SCREENS.MULTIFACTOR_AUTHENTICATION), ], diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index b03c1c5f0291..4ccfe37d5aab 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -550,6 +550,9 @@ const ONYXKEYS = { /** Selected domain member account IDs for the move-to-group operation */ RAM_ONLY_DOMAIN_MEMBERS_SELECTED_FOR_MOVE: 'domainMembersSelectedForMove', + /** Account IDs of the workspace members selected for a bulk office location or work arrangement change */ + RAM_ONLY_WORKSPACE_MEMBERS_SELECTED_FOR_BULK_EDIT: 'workspaceMembersSelectedForBulkEdit', + // The theme setting set by the user in preferences. // This can be either "light", "dark", "system", "light-contrast", "dark-contrast" or "system-contrast" PREFERRED_THEME: 'nvp_preferredTheme', @@ -1834,6 +1837,7 @@ type OnyxValuesMapping = { [ONYXKEYS.VIEWING_PUBLIC_ROOM_REPORT_ID]: string; [ONYXKEYS.MY_DOMAIN_SECURITY_GROUPS]: Record; [ONYXKEYS.RAM_ONLY_DOMAIN_MEMBERS_SELECTED_FOR_MOVE]: string[]; + [ONYXKEYS.RAM_ONLY_WORKSPACE_MEMBERS_SELECTED_FOR_BULK_EDIT]: number[]; [ONYXKEYS.VERIFY_3DS_SUBSCRIPTION]: string; [ONYXKEYS.PREFERRED_THEME]: ValueOf; [ONYXKEYS.SIGN_IN_HIGH_CONTRAST_INTENT]: boolean; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index ab25a518dba4..02648148fe17 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -3465,6 +3465,14 @@ const ROUTES = { route: 'workspaces/:policyID/members/import', getRoute: (policyID: string) => `workspaces/${policyID}/members/import` as const, }, + WORKSPACE_MEMBERS_CHANGE_OFFICE_LOCATION: { + route: 'workspaces/:policyID/members/change-office-location', + getRoute: (policyID: string) => `workspaces/${policyID}/members/change-office-location` as const, + }, + WORKSPACE_MEMBERS_CHANGE_WORK_ARRANGEMENT: { + route: 'workspaces/:policyID/members/change-work-arrangement', + getRoute: (policyID: string) => `workspaces/${policyID}/members/change-work-arrangement` as const, + }, WORKSPACE_MEMBERS_IMPORTED: { route: 'workspaces/:policyID/members/imported', getRoute: (policyID: string) => `workspaces/${policyID}/members/imported` as const, @@ -3712,6 +3720,10 @@ const ROUTES = { route: 'workspaces/:policyID/members/:accountID/work-arrangement', getRoute: (policyID: string, accountID: number) => `workspaces/${policyID}/members/${accountID}/work-arrangement` as const, }, + WORKSPACE_MEMBER_OFFICE_LOCATION: { + route: 'workspaces/:policyID/members/:accountID/office-location', + getRoute: (policyID: string, accountID: number) => `workspaces/${policyID}/members/${accountID}/office-location` as const, + }, WORKSPACE_CUSTOM_FIELDS: { route: 'workspaces/:policyID/members/:accountID/:customFieldType', getRoute: (policyID: string, accountID: number, customFieldType: CustomFieldType) => `/workspaces/${policyID}/members/${accountID}/${customFieldType}` as const, diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 29a1a47d6f25..05819af2a1e5 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -854,6 +854,8 @@ const SCREENS = { ROOMS: 'Workspace_Rooms', ROOM_CREATE: 'Workspace_Room_Create', MEMBERS_IMPORT: 'Members_Import', + MEMBERS_CHANGE_OFFICE_LOCATION: 'Members_Change_Office_Location', + MEMBERS_CHANGE_WORK_ARRANGEMENT: 'Members_Change_Work_Arrangement', MEMBERS_IMPORTED: 'Members_Imported', MEMBERS_IMPORTED_CONFIRMATION: 'Members_Imported_Confirmation', DYNAMIC_WORKSPACE_INVITE: 'Dynamic_Workspace_Invite', @@ -954,6 +956,7 @@ const SCREENS = { MEMBER_DETAILS: 'Workspace_Member_Details', MEMBER_DETAILS_ROLE: 'Workspace_Member_Details_Role', MEMBER_WORK_ARRANGEMENT: 'Workspace_Member_Work_Arrangement', + MEMBER_OFFICE_LOCATION: 'Workspace_Member_Office_Location', MEMBER_CUSTOM_FIELD: 'Workspace_Member_Custom_Field', MEMBER_NEW_CARD: 'Workspace_Member_NewCard', DYNAMIC_OWNER_CHANGE_CHECK: 'Dynamic_Workspace_Owner_Change_Check', diff --git a/src/components/Tables/WorkspaceMembersTable/WorkspaceMembersTableRow.tsx b/src/components/Tables/WorkspaceMembersTable/WorkspaceMembersTableRow.tsx index 540bc847c3ec..21f23f952b6b 100644 --- a/src/components/Tables/WorkspaceMembersTable/WorkspaceMembersTableRow.tsx +++ b/src/components/Tables/WorkspaceMembersTable/WorkspaceMembersTableRow.tsx @@ -34,6 +34,9 @@ type WorkspaceMembersTableRowProps = { /** Whether the custom field 2 column is visible on web screens or not */ shouldShowCustomField2Column: boolean; + /** Whether the office location column is visible on web screens or not */ + shouldShowOfficeLocationColumn: boolean; + /** Policy used to determine which roles can be assigned from the inline editor */ policy: OnyxEntry; }; @@ -43,6 +46,7 @@ export default function WorkspaceMembersTableRow({ rowIndex, shouldShowCustomField1Column, shouldShowCustomField2Column, + shouldShowOfficeLocationColumn, shouldUseNarrowTableLayout, policy, }: WorkspaceMembersTableRowProps) { @@ -100,6 +104,22 @@ export default function WorkspaceMembersTableRow({ + {!shouldUseNarrowTableLayout && shouldShowOfficeLocationColumn && ( + + {!!item.officeLocationName && ( + + )} + + )} + {!shouldUseNarrowTableLayout && shouldShowCustomField1Column && ( ; employeeUserID?: string; employeePayrollID?: string; + officeLocationName?: string; name: string; email: string; shouldShowEmployeeUserID: boolean; @@ -54,6 +55,7 @@ type WorkspaceMembersTableProps = { selectedKeys: string[]; shouldShowCustomField1Column: boolean; shouldShowCustomField2Column: boolean; + shouldShowOfficeLocationColumn: boolean; onRowSelectionChange: (selectedRowKeys: string[]) => void; headerComponent?: React.ReactElement; }; @@ -76,6 +78,7 @@ export default function WorkspaceMembersTable({ selectedKeys, shouldShowCustomField1Column, shouldShowCustomField2Column, + shouldShowOfficeLocationColumn, members, onRowSelectionChange, headerComponent, @@ -101,6 +104,18 @@ export default function WorkspaceMembersTable({ }, }, + ...(shouldShowOfficeLocationColumn + ? [ + { + sortable: true, + key: 'officeLocation' as const, + label: translate('workspace.people.officeLocation'), + dynamicSizing: { + getContentToMeasure: (item: WorkspaceMemberRowData) => (item.officeLocationName ? [{text: item.officeLocationName, fontSize: fontScale.text}] : []), + }, + }, + ] + : []), ...(shouldShowCustomField1Column ? [ { @@ -160,6 +175,10 @@ export default function WorkspaceMembersTable({ return compareOptionalValues(item1.role, item2.role, compareRoleNames, orderMultiplier, memberNameComparison); } + if (activeSorting.columnKey === 'officeLocation') { + return compareOptionalValues(item1.officeLocationName, item2.officeLocationName, localeCompare, orderMultiplier, memberNameComparison); + } + if (activeSorting.columnKey === 'customField1') { return compareOptionalValues(item1.employeeUserID, item2.employeeUserID, localeCompare, orderMultiplier, memberNameComparison); } @@ -284,6 +303,7 @@ export default function WorkspaceMembersTable({ shouldUseNarrowTableLayout={shouldUseNarrowTableLayout} shouldShowCustomField1Column={shouldShowCustomField1Column} shouldShowCustomField2Column={shouldShowCustomField2Column} + shouldShowOfficeLocationColumn={shouldShowOfficeLocationColumn} policy={policy} /> ); diff --git a/src/languages/de.ts b/src/languages/de.ts index f1655012fad5..4d0ffc12faf0 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -7218,6 +7218,10 @@ Der Control-Tarif beginnt bei 9 $ pro aktivem Mitglied und Monat.`, 'Das Mitglied arbeitet von zu Hause aus oder reist zwischen verschiedenen Standorten ohne festes Büro, daher gelten die Pendelregelungen nicht.', futureOnlyNote: 'Änderungen gelten nur für zukünftige Kilometerberechnungen. Bestehende Kilometerabrechnungen werden nicht neu berechnet.', }, + officeLocation: 'Bürostandort', + officeLocationWithAddress: ({name, address}: {name: string; address: string}) => `${name} (${address})`, + changeOfficeLocation: 'Bürostandort ändern', + changeWorkArrangement: 'Arbeitsvereinbarung ändern', }, card: { getStartedIssuing: 'Beginne, indem du deine erste virtuelle oder physische Karte ausstellst.', @@ -9592,6 +9596,11 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc setAsPrimary: ({name}: {name: string}) => `hat den Bürostandort „${name}“ als Hauptstandort festgelegt`, setCompanyAddressAsPrimary: 'hat die Firmenadresse als Hauptstandort festgelegt', removed: ({name}: {name: string}) => `hat den Bürostandort „${name}“ entfernt`, + changedMemberOfficeLocation: ({displayName, officeLocation, previousOfficeLocation}: {displayName: string; officeLocation: string; previousOfficeLocation: string}) => + `hat den Bürostandort von ${displayName} geändert: ${officeLocation} (zuvor: ${previousOfficeLocation})`, + quotedName: ({name}: {name: string}) => `„${name}“`, + companyAddress: 'Firmenadresse', + primaryOffice: 'Hauptstandort', }, workArrangement: { set: ({arrangement}: {arrangement: string}) => `Standard-Arbeitsmodell auf ${arrangement} festlegen`, diff --git a/src/languages/el.ts b/src/languages/el.ts index 0d4cd68731d3..4415b2c7897b 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -7348,6 +7348,10 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ optionNoRegularWorkspaceHelp: 'Το μέλος εργάζεται από το σπίτι ή ταξιδεύει μεταξύ τοποθεσιών χωρίς τακτικό γραφείο, επομένως οι κανόνες μετακίνησης δεν ισχύουν.', futureOnlyNote: 'Οι αλλαγές ισχύουν μόνο για μελλοντικούς υπολογισμούς χιλιομέτρων. Οι υπάρχουσες δαπάνες χιλιομέτρων δεν επανυπολογίζονται.', }, + officeLocation: 'Τοποθεσία γραφείου', + officeLocationWithAddress: ({name, address}: {name: string; address: string}) => `${name} (${address})`, + changeOfficeLocation: 'Αλλαγή τοποθεσίας γραφείου', + changeWorkArrangement: 'Αλλαγή διευθέτησης εργασίας', }, card: { getStartedIssuing: 'Ξεκινήστε εκδίδοντας την πρώτη σας εικονική ή φυσική κάρτα.', @@ -9717,6 +9721,11 @@ ${reportName}`, setAsPrimary: ({name}: {name: string}) => `όρισε την τοποθεσία γραφείου «${name}» ως κύρια`, setCompanyAddressAsPrimary: 'όρισε τη διεύθυνση εταιρείας ως κύρια', removed: ({name}: {name: string}) => `αφαίρεσε την τοποθεσία γραφείου «${name}»`, + changedMemberOfficeLocation: ({displayName, officeLocation, previousOfficeLocation}: {displayName: string; officeLocation: string; previousOfficeLocation: string}) => + `άλλαξε την τοποθεσία γραφείου του/της ${displayName}: ${officeLocation} (προηγουμένως: ${previousOfficeLocation})`, + quotedName: ({name}: {name: string}) => `«${name}»`, + companyAddress: 'διεύθυνση εταιρείας', + primaryOffice: 'κύρια τοποθεσία γραφείου', }, workArrangement: { set: ({arrangement}: {arrangement: string}) => `ορίστε την προεπιλεγμένη εργασιακή ρύθμιση σε ${arrangement}`, diff --git a/src/languages/en.ts b/src/languages/en.ts index a486b027cf6c..51e46e14692a 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -7490,6 +7490,10 @@ const translations = { optionNoRegularWorkspaceHelp: "Member works from home or travels between locations with no regular office, so commute rules don't apply.", futureOnlyNote: 'Changes apply only to future mileage calculations. Existing mileage expenses are not recalculated.', }, + officeLocation: 'Office location', + officeLocationWithAddress: ({name, address}: {name: string; address: string}) => `${name} (${address})`, + changeOfficeLocation: 'Change office location', + changeWorkArrangement: 'Change work arrangement', removeWorkspaceMemberButtonTitle: 'Remove from workspace', removeGroupMemberButtonTitle: 'Remove from group', removeRoomMemberButtonTitle: 'Remove from chat', @@ -9742,6 +9746,11 @@ const translations = { setAsPrimary: ({name}: {name: string}) => `set the office location "${name}" as primary`, setCompanyAddressAsPrimary: 'set the company address as primary', removed: ({name}: {name: string}) => `removed the office location "${name}"`, + changedMemberOfficeLocation: ({displayName, officeLocation, previousOfficeLocation}: {displayName: string; officeLocation: string; previousOfficeLocation: string}) => + `changed ${displayName}'s office location to ${officeLocation} (previously ${previousOfficeLocation})`, + quotedName: ({name}: {name: string}) => `"${name}"`, + companyAddress: 'the company address', + primaryOffice: 'the primary office', }, workArrangement: { set: ({arrangement}: {arrangement: string}) => `set the default work arrangement to ${arrangement}`, diff --git a/src/languages/es.ts b/src/languages/es.ts index 8ef241861bf2..9a9e47984d9b 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -7087,6 +7087,10 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, 'La persona miembro trabaja desde casa o viaja entre ubicaciones sin una oficina fija, por lo que no se aplican las normas sobre desplazamientos.', futureOnlyNote: 'Los cambios se aplican solo a los cálculos de kilometraje futuros. Los gastos de kilometraje existentes no se recalculan.', }, + officeLocation: 'Ubicación de oficina', + officeLocationWithAddress: ({name, address}: {name: string; address: string}) => `${name} (${address})`, + changeOfficeLocation: 'Cambiar ubicación de oficina', + changeWorkArrangement: 'Cambiar modalidad de trabajo', }, accounting: { settings: 'configuración', @@ -9368,6 +9372,11 @@ ${reportName}`, setAsPrimary: ({name}: {name: string}) => `estableció la ubicación de oficina "${name}" como principal`, setCompanyAddressAsPrimary: 'estableció la dirección física de la empresa como principal', removed: ({name}: {name: string}) => `eliminó la ubicación de oficina "${name}"`, + changedMemberOfficeLocation: ({displayName, officeLocation, previousOfficeLocation}: {displayName: string; officeLocation: string; previousOfficeLocation: string}) => + `cambió la ubicación de oficina de ${displayName} a ${officeLocation} (antes ${previousOfficeLocation})`, + quotedName: ({name}: {name: string}) => `"${name}"`, + companyAddress: 'la dirección física de la empresa', + primaryOffice: 'la ubicación de oficina principal', }, workArrangement: { set: ({arrangement}: {arrangement: string}) => `establecer la modalidad de trabajo predeterminada en ${arrangement}`, diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 503ab9a6601e..d66e333763f0 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -7235,6 +7235,10 @@ Le forfait Control commence à 9 $ par Membre actif et par mois.`, 'Le membre travaille à domicile ou se déplace entre plusieurs sites sans bureau fixe, donc les règles relatives aux trajets domicile-travail ne s’appliquent pas.', futureOnlyNote: 'Les modifications s’appliquent uniquement aux calculs de kilométrage futurs. Les dépenses de kilométrage existantes ne sont pas recalculées.', }, + officeLocation: 'Lieu de travail', + officeLocationWithAddress: ({name, address}: {name: string; address: string}) => `${name} (${address})`, + changeOfficeLocation: 'Modifier le lieu de travail', + changeWorkArrangement: 'Modifier les modalités de travail', }, card: { getStartedIssuing: 'Commencez par émettre votre première carte virtuelle ou physique.', @@ -9613,6 +9617,11 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e setAsPrimary: ({name}: {name: string}) => `a défini le lieu de travail « ${name} » comme principal`, setCompanyAddressAsPrimary: 'a défini l’adresse de l’entreprise comme principale', removed: ({name}: {name: string}) => `a supprimé le lieu de travail « ${name} »`, + changedMemberOfficeLocation: ({displayName, officeLocation, previousOfficeLocation}: {displayName: string; officeLocation: string; previousOfficeLocation: string}) => + `a remplacé le lieu de travail de ${displayName} par ${officeLocation} (auparavant ${previousOfficeLocation})`, + quotedName: ({name}: {name: string}) => `« ${name} »`, + companyAddress: 'l’adresse de l’entreprise', + primaryOffice: 'le lieu de travail principal', }, workArrangement: { set: ({arrangement}: {arrangement: string}) => `définir l’organisation de travail par défaut sur ${arrangement}`, diff --git a/src/languages/it.ts b/src/languages/it.ts index de4e9c9a082e..10c176d8cd82 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -7176,6 +7176,10 @@ Il piano Control parte da 9 $ al mese per ogni membro attivo.`, 'Il membro lavora da casa o si sposta tra diverse sedi senza un ufficio fisso, quindi le regole relative al tragitto casa-lavoro non si applicano.', futureOnlyNote: 'Le modifiche si applicano solo ai calcoli chilometrici futuri. Le spese chilometriche esistenti non vengono ricalcolate.', }, + officeLocation: 'Sede dell’ufficio', + officeLocationWithAddress: ({name, address}: {name: string; address: string}) => `${name} (${address})`, + changeOfficeLocation: 'Cambia sede dell’ufficio', + changeWorkArrangement: 'Cambia modalità di lavoro', }, card: { getStartedIssuing: 'Inizia emettendo la tua prima carta virtuale o fisica.', @@ -9540,6 +9544,11 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, setAsPrimary: ({name}: {name: string}) => `ha impostato la sede dell’ufficio "${name}" come principale`, setCompanyAddressAsPrimary: 'ha impostato l’indirizzo dell’azienda come principale', removed: ({name}: {name: string}) => `ha rimosso la sede dell’ufficio "${name}"`, + changedMemberOfficeLocation: ({displayName, officeLocation, previousOfficeLocation}: {displayName: string; officeLocation: string; previousOfficeLocation: string}) => + `ha impostato ${officeLocation} come sede dell’ufficio di ${displayName} (in precedenza ${previousOfficeLocation})`, + quotedName: ({name}: {name: string}) => `"${name}"`, + companyAddress: 'l’indirizzo dell’azienda', + primaryOffice: 'la sede principale', }, workArrangement: { set: ({arrangement}: {arrangement: string}) => `imposta la modalità di lavoro predefinita su ${arrangement}`, diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 674e405c0a8f..7b9daabac5ab 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -7086,6 +7086,10 @@ Control プランは、アクティブメンバー1人あたり月額 $9 から optionNoRegularWorkspaceHelp: 'メンバーは在宅勤務、または決まったオフィスがなく拠点間を移動しているため、通勤規程は適用されません。', futureOnlyNote: '変更は今後の走行距離計算にのみ適用されます。既存の走行距離経費は再計算されません。', }, + officeLocation: 'オフィスの所在地', + officeLocationWithAddress: ({name, address}: {name: string; address: string}) => `${name}(${address})`, + changeOfficeLocation: 'オフィスの所在地を変更', + changeWorkArrangement: '勤務形態を変更', }, card: { getStartedIssuing: 'まずは最初のバーチャルカードまたは物理カードを発行しましょう。', @@ -9407,6 +9411,11 @@ ${reportName}`, setAsPrimary: ({name}: {name: string}) => `オフィスの所在地「${name}」をメインに設定しました`, setCompanyAddressAsPrimary: '会社住所をメインに設定しました', removed: ({name}: {name: string}) => `オフィスの所在地「${name}」を削除しました`, + changedMemberOfficeLocation: ({displayName, officeLocation, previousOfficeLocation}: {displayName: string; officeLocation: string; previousOfficeLocation: string}) => + `${displayName}さんのオフィスの所在地を${officeLocation}(以前は${previousOfficeLocation})に変更しました`, + quotedName: ({name}: {name: string}) => `「${name}」`, + companyAddress: '会社住所', + primaryOffice: 'メインのオフィス', }, workArrangement: { set: ({arrangement}: {arrangement: string}) => `デフォルトの勤務形態を${arrangement}に設定します`, diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 2d43022aa86f..d1ea494b3208 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -7160,6 +7160,10 @@ Het Control-abonnement begint bij $9 per actieve deelnemer per maand.`, optionNoRegularWorkspaceHelp: 'Lid werkt thuis of reist tussen locaties zonder vaste kantoorlocatie, waardoor woon-werkregels niet van toepassing zijn.', futureOnlyNote: 'Wijzigingen zijn alleen van toepassing op toekomstige kilometerberekeningen. Bestaande kilometerdeclaraties worden niet opnieuw berekend.', }, + officeLocation: 'Kantoorlocatie', + officeLocationWithAddress: ({name, address}: {name: string; address: string}) => `${name} (${address})`, + changeOfficeLocation: 'Kantoorlocatie wijzigen', + changeWorkArrangement: 'Werkregeling wijzigen', }, card: { getStartedIssuing: 'Begin met het uitgeven van je eerste virtuele of fysieke kaart.', @@ -9512,6 +9516,11 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, setAsPrimary: ({name}: {name: string}) => `heeft de kantoorlocatie ‘${name}’ als primair ingesteld`, setCompanyAddressAsPrimary: 'heeft het adres van het bedrijf als primair ingesteld', removed: ({name}: {name: string}) => `heeft de kantoorlocatie ‘${name}’ verwijderd`, + changedMemberOfficeLocation: ({displayName, officeLocation, previousOfficeLocation}: {displayName: string; officeLocation: string; previousOfficeLocation: string}) => + `heeft de kantoorlocatie van ${displayName} gewijzigd in ${officeLocation} (eerder ${previousOfficeLocation})`, + quotedName: ({name}: {name: string}) => `‘${name}’`, + companyAddress: 'het adres van het bedrijf', + primaryOffice: 'de primaire kantoorlocatie', }, workArrangement: { set: ({arrangement}: {arrangement: string}) => `stel de standaard werkregeling in op ${arrangement}`, diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 92db99e596b8..7d73674eed9f 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -7184,6 +7184,10 @@ Plan Control zaczyna się od 9 USD za aktywnego członka miesięcznie.`, optionNoRegularWorkspaceHelp: 'Członek pracuje z domu lub podróżuje między lokalizacjami bez stałego biura, więc zasady dotyczące dojazdów do pracy nie mają zastosowania.', futureOnlyNote: 'Zmiany mają zastosowanie tylko do przyszłych obliczeń kilometrówki. Istniejące wydatki za przejazdy nie są przeliczane ponownie.', }, + officeLocation: 'Lokalizacja biura', + officeLocationWithAddress: ({name, address}: {name: string; address: string}) => `${name} (${address})`, + changeOfficeLocation: 'Zmień lokalizację biura', + changeWorkArrangement: 'Zmień ustalenia dotyczące pracy', }, card: { getStartedIssuing: 'Zacznij od wydania swojej pierwszej wirtualnej lub fizycznej karty.', @@ -9522,6 +9526,11 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, setAsPrimary: ({name}: {name: string}) => `ustawiono lokalizację biura „${name}” jako główną`, setCompanyAddressAsPrimary: 'ustawiono adres firmy jako główny', removed: ({name}: {name: string}) => `usunięto lokalizację biura „${name}”`, + changedMemberOfficeLocation: ({displayName, officeLocation, previousOfficeLocation}: {displayName: string; officeLocation: string; previousOfficeLocation: string}) => + `zmienił(a) lokalizację biura użytkownika ${displayName} na ${officeLocation} (wcześniej ${previousOfficeLocation})`, + quotedName: ({name}: {name: string}) => `„${name}”`, + companyAddress: 'adres firmy', + primaryOffice: 'główne biuro', }, workArrangement: { set: ({arrangement}: {arrangement: string}) => `ustaw domyślny tryb pracy na ${arrangement}`, diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 0ab68013dcc9..3ec4693b678c 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -7162,6 +7162,10 @@ O plano Control começa em US$ 9 por membro ativo por mês.`, optionNoRegularWorkspaceHelp: 'O membro trabalha em casa ou viaja entre locais sem um escritório fixo, portanto as regras de deslocamento não se aplicam.', futureOnlyNote: 'As alterações se aplicam apenas aos cálculos de quilometragem futuros. As despesas de quilometragem existentes não são recalculadas.', }, + officeLocation: 'Local de escritório', + officeLocationWithAddress: ({name, address}: {name: string; address: string}) => `${name} (${address})`, + changeOfficeLocation: 'Alterar local de escritório', + changeWorkArrangement: 'Alterar arranjo de trabalho', }, card: { getStartedIssuing: 'Comece emitindo seu primeiro cartão virtual ou físico.', @@ -9510,6 +9514,11 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, setAsPrimary: ({name}: {name: string}) => `definiu o local de escritório "${name}" como principal`, setCompanyAddressAsPrimary: 'definiu o endereço da empresa como principal', removed: ({name}: {name: string}) => `removeu o local de escritório "${name}"`, + changedMemberOfficeLocation: ({displayName, officeLocation, previousOfficeLocation}: {displayName: string; officeLocation: string; previousOfficeLocation: string}) => + `alterou o local de escritório de ${displayName} para ${officeLocation} (antes ${previousOfficeLocation})`, + quotedName: ({name}: {name: string}) => `"${name}"`, + companyAddress: 'o endereço da empresa', + primaryOffice: 'o local de escritório principal', }, workArrangement: { set: ({arrangement}: {arrangement: string}) => `definir o arranjo de trabalho padrão como ${arrangement}`, diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index f07f81dcb7c2..b9f52654cc66 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -6908,6 +6908,10 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM optionNoRegularWorkspaceHelp: '成员在家办公或在各地点之间出差,没有固定办公地点,因此通勤规则不适用。', futureOnlyNote: '更改仅适用于未来的里程计算,现有的里程报销将不会重新计算。', }, + officeLocation: '办公地点', + officeLocationWithAddress: ({name, address}: {name: string; address: string}) => `${name}(${address})`, + changeOfficeLocation: '更改办公地点', + changeWorkArrangement: '更改工作安排', }, card: { getStartedIssuing: '从发放您的第一张虚拟卡或实体卡开始使用。', @@ -9161,6 +9165,11 @@ ${reportName}`, setAsPrimary: ({name}: {name: string}) => `已将办公地点“${name}”设为主要地点`, setCompanyAddressAsPrimary: '已将公司地址设为主要地点', removed: ({name}: {name: string}) => `已删除办公地点“${name}”`, + changedMemberOfficeLocation: ({displayName, officeLocation, previousOfficeLocation}: {displayName: string; officeLocation: string; previousOfficeLocation: string}) => + `已将 ${displayName} 的办公地点更改为${officeLocation}(之前为${previousOfficeLocation})`, + quotedName: ({name}: {name: string}) => `“${name}”`, + companyAddress: '公司地址', + primaryOffice: '主要办公地点', }, workArrangement: { set: ({arrangement}: {arrangement: string}) => `将默认工作安排设置为 ${arrangement}`, diff --git a/src/libs/API/parameters/UpdateEmployeeOfficeLocationParams.ts b/src/libs/API/parameters/UpdateEmployeeOfficeLocationParams.ts new file mode 100644 index 000000000000..2b6ba060a2d6 --- /dev/null +++ b/src/libs/API/parameters/UpdateEmployeeOfficeLocationParams.ts @@ -0,0 +1,14 @@ +type UpdateEmployeeOfficeLocationParams = { + policyID: string; + + /** Comma-separated accountIDs of the employees to update. Supports individual and bulk updates. */ + employeeAccountIDList: string; + + /** Comma-separated logins of the same employees, which still match them once an offline invite replaces their accountIDs */ + employeeLoginList: string; + + /** Office to assign, keyed in the workspace's officeLocations, or CONST.POLICY.COMPANY_ADDRESS_OFFICE_ID for the company address */ + officeID: string; +}; + +export default UpdateEmployeeOfficeLocationParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index 3b1c52b518eb..ae93edf36f85 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -602,6 +602,7 @@ export type {default as DisablePolicyCommuterExclusionsParams} from './DisablePo export type {default as AddOfficeLocationParams} from './AddOfficeLocationParams'; export type {default as UpdateOfficeLocationParams} from './UpdateOfficeLocationParams'; export type {default as DeleteOfficeLocationParams} from './DeleteOfficeLocationParams'; +export type {default as UpdateEmployeeOfficeLocationParams} from './UpdateEmployeeOfficeLocationParams'; export type {default as SetPolicyRequireMapOrGPSParams} from './SetPolicyRequireMapOrGPSParams'; export type {default as CreateDigitalWalletParams} from './CreateDigitalWalletParams'; export type {default as GetGuideCallAvailabilityScheduleParams} from './GetGuideCallAvailabilitySchedule'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index dcced4f51b64..c1e08a542fcb 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -303,6 +303,7 @@ const WRITE_COMMANDS = { ADD_OFFICE_LOCATION: 'AddOfficeLocation', UPDATE_OFFICE_LOCATION: 'UpdateOfficeLocation', DELETE_OFFICE_LOCATION: 'DeleteOfficeLocation', + UPDATE_EMPLOYEE_OFFICE_LOCATION: 'UpdateEmployeeOfficeLocation', SET_POLICY_REQUIRE_MAP_OR_GPS: 'SetPolicyRequireMapOrGPS', SET_POLICY_EXPENSE_MAX_AGE: 'SetPolicyExpenseMaxAge', ADD_POLICY_AGENT_RULE: 'AddPolicyAgentRule', @@ -1215,6 +1216,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.ADD_OFFICE_LOCATION]: Parameters.AddOfficeLocationParams; [WRITE_COMMANDS.UPDATE_OFFICE_LOCATION]: Parameters.UpdateOfficeLocationParams; [WRITE_COMMANDS.DELETE_OFFICE_LOCATION]: Parameters.DeleteOfficeLocationParams; + [WRITE_COMMANDS.UPDATE_EMPLOYEE_OFFICE_LOCATION]: Parameters.UpdateEmployeeOfficeLocationParams; [WRITE_COMMANDS.SET_POLICY_REQUIRE_MAP_OR_GPS]: Parameters.SetPolicyRequireMapOrGPSParams; [WRITE_COMMANDS.RETRACT_REPORT]: Parameters.RetractReportParams; [WRITE_COMMANDS.FINISH_CORPAY_BANK_ACCOUNT_ONBOARDING]: Parameters.FinishCorpayBankAccountOnboardingParams; diff --git a/src/libs/ExportOnyxState/masking.ts b/src/libs/ExportOnyxState/masking.ts index 47e3d71a2f83..b3f06511ffc3 100644 --- a/src/libs/ExportOnyxState/masking.ts +++ b/src/libs/ExportOnyxState/masking.ts @@ -511,6 +511,7 @@ const onyxKeysToMaskFragileData = new Set([ ONYXKEYS.RAM_ONLY_HAS_FRESH_WALLET_DATA, ONYXKEYS.RAM_ONLY_IS_LOADING_SEARCH_FILTERS_CATEGORY_DATA, ONYXKEYS.RAM_ONLY_WALLET_ONFIDO, + ONYXKEYS.RAM_ONLY_WORKSPACE_MEMBERS_SELECTED_FOR_BULK_EDIT, ONYXKEYS.RECENTLY_USED_CURRENCIES, ONYXKEYS.RECENTLY_USED_REPORT_FIELDS, ONYXKEYS.RECENT_SEARCHES, diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 88f702cb9117..9ce534eaa063 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -551,6 +551,8 @@ const SettingsModalStackNavigator = createModalStackNavigator('../../../../pages/settings/Subscription/SubscriptionPlan/DynamicSubscriptionPlanDowngradeBlockedPage').default, [SCREENS.WORKSPACE.DYNAMIC_WORKSPACE_INVITE]: () => require('../../../../pages/workspace/DynamicWorkspaceInvitePage').default, [SCREENS.WORKSPACE.MEMBERS_IMPORT]: () => require('../../../../pages/workspace/members/ImportMembersPage').default, + [SCREENS.WORKSPACE.MEMBERS_CHANGE_OFFICE_LOCATION]: () => require('../../../../pages/workspace/members/WorkspaceMembersChangeOfficeLocationPage').default, + [SCREENS.WORKSPACE.MEMBERS_CHANGE_WORK_ARRANGEMENT]: () => require('../../../../pages/workspace/members/WorkspaceMembersChangeWorkArrangementPage').default, [SCREENS.WORKSPACE.WORKFLOWS_IMPORT]: () => require('../../../../pages/workspace/workflows/ImportWorkflowsPage').default, [SCREENS.WORKSPACE.MEMBERS_IMPORTED]: () => require('../../../../pages/workspace/members/ImportedMembersPage').default, [SCREENS.WORKSPACE.MEMBERS_IMPORTED_CONFIRMATION]: () => require('../../../../pages/workspace/members/ImportedMembersConfirmationPage').default, @@ -607,6 +609,7 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/workspace/rooms/WorkspaceRoomCreatePage').default, [SCREENS.WORKSPACE.MEMBER_DETAILS_ROLE]: () => require('../../../../pages/workspace/members/WorkspaceMemberDetailsRolePage').default, [SCREENS.WORKSPACE.MEMBER_WORK_ARRANGEMENT]: () => require('../../../../pages/workspace/members/WorkArrangementPage').default, + [SCREENS.WORKSPACE.MEMBER_OFFICE_LOCATION]: () => require('../../../../pages/workspace/members/WorkspaceMemberOfficeLocationPage').default, [SCREENS.WORKSPACE.MEMBER_CUSTOM_FIELD]: () => require('../../../../pages/workspace/members/WorkspaceMemberCustomFieldPage').default, [SCREENS.WORKSPACE.DYNAMIC_OWNER_CHANGE_CHECK]: () => require('@pages/workspace/members/DynamicWorkspaceOwnerChangeWrapperPage').default, [SCREENS.WORKSPACE.DYNAMIC_OWNER_CHANGE_SUCCESS]: () => require('../../../../pages/workspace/members/DynamicWorkspaceOwnerChangeSuccessPage').default, diff --git a/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts b/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts index c08f8d946fa6..c1e2f1380fc0 100755 --- a/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts +++ b/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts @@ -21,11 +21,14 @@ const WORKSPACE_TO_RHP: Partial['config'] = { [SCREENS.WORKSPACE.MEMBERS_IMPORT]: { path: ROUTES.WORKSPACE_MEMBERS_IMPORT.route, }, + [SCREENS.WORKSPACE.MEMBERS_CHANGE_OFFICE_LOCATION]: { + path: ROUTES.WORKSPACE_MEMBERS_CHANGE_OFFICE_LOCATION.route, + }, + [SCREENS.WORKSPACE.MEMBERS_CHANGE_WORK_ARRANGEMENT]: { + path: ROUTES.WORKSPACE_MEMBERS_CHANGE_WORK_ARRANGEMENT.route, + }, [SCREENS.WORKSPACE.WORKFLOWS_IMPORT]: { path: ROUTES.WORKSPACE_WORKFLOWS_IMPORT.route, }, @@ -1161,6 +1167,9 @@ const config: LinkingOptions['config'] = { [SCREENS.WORKSPACE.MEMBER_WORK_ARRANGEMENT]: { path: ROUTES.WORKSPACE_MEMBER_WORK_ARRANGEMENT.route, }, + [SCREENS.WORKSPACE.MEMBER_OFFICE_LOCATION]: { + path: ROUTES.WORKSPACE_MEMBER_OFFICE_LOCATION.route, + }, [SCREENS.WORKSPACE.DYNAMIC_OWNER_CHANGE_SUCCESS]: DYNAMIC_ROUTES.WORKSPACE_OWNER_CHANGE_SUCCESS.path, [SCREENS.WORKSPACE.DYNAMIC_OWNER_CHANGE_ERROR]: DYNAMIC_ROUTES.WORKSPACE_OWNER_CHANGE_ERROR.path, [SCREENS.WORKSPACE.DYNAMIC_OWNER_CHANGE_CHECK]: DYNAMIC_ROUTES.WORKSPACE_OWNER_CHANGE_CHECK.path, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index a53bdd6a61db..5223bb07655b 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -377,6 +377,12 @@ type SettingsNavigatorParamList = { [SCREENS.WORKSPACE.MEMBERS_IMPORT]: { policyID: string; }; + [SCREENS.WORKSPACE.MEMBERS_CHANGE_OFFICE_LOCATION]: { + policyID: string; + }; + [SCREENS.WORKSPACE.MEMBERS_CHANGE_WORK_ARRANGEMENT]: { + policyID: string; + }; [SCREENS.WORKSPACE.WORKFLOWS_IMPORT]: { policyID: string; }; @@ -798,6 +804,10 @@ type SettingsNavigatorParamList = { policyID: string; accountID: string; }; + [SCREENS.WORKSPACE.MEMBER_OFFICE_LOCATION]: { + policyID: string; + accountID: string; + }; [SCREENS.WORKSPACE.MEMBER_CUSTOM_FIELD]: { policyID: string; accountID: string; diff --git a/src/libs/OfficeLocationUtils.ts b/src/libs/OfficeLocationUtils.ts new file mode 100644 index 000000000000..076d9113d163 --- /dev/null +++ b/src/libs/OfficeLocationUtils.ts @@ -0,0 +1,67 @@ +import type {LocaleContextProps, LocalizedTranslate} from '@components/LocaleContextProvider'; + +import CONST from '@src/CONST'; +import type {Policy} from '@src/types/onyx'; + +import type {OnyxEntry} from 'react-native-onyx'; + +import {hasCompanyAddress} from './PolicyUtils'; +import {formatAddressToString} from './ReportActionsUtils'; + +type AssignableOfficeLocation = { + /** Key of the office in the workspace's officeLocations, or CONST.POLICY.COMPANY_ADDRESS_OFFICE_ID for the company address */ + officeID: string; + + /** Nickname of the office */ + name: string; + + /** Address of the office on a single line */ + address: string; + + /** Whether members without an assigned office follow this one */ + isPrimary: boolean; +}; + +/** + * Whether the workspace has offices besides its company address, which is when its members can be assigned to one + */ +function hasOfficeLocations(policy: OnyxEntry): boolean { + return Object.values(policy?.officeLocations ?? {}).some((officeLocation) => officeLocation.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); +} + +/** + * The offices a workspace member can be assigned to. The company address comes first, as on the workspace overview, + * followed by the primary office and then the rest by name. + */ +function getAssignableOfficeLocations(policy: OnyxEntry, translate: LocalizedTranslate, localeCompare: LocaleContextProps['localeCompare']): AssignableOfficeLocation[] { + const officeLocations = Object.entries(policy?.officeLocations ?? {}) + .filter(([, officeLocation]) => officeLocation.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) + .sort( + ([, officeLocation], [, otherOfficeLocation]) => + Number(!!otherOfficeLocation.isDefault) - Number(!!officeLocation.isDefault) || localeCompare(officeLocation.name, otherOfficeLocation.name), + ) + .map(([officeID, officeLocation]) => ({officeID, name: officeLocation.name, address: formatAddressToString(officeLocation.address), isPrimary: !!officeLocation.isDefault})); + if (!hasCompanyAddress(policy)) { + return officeLocations; + } + + // The company address is the primary office while no office is + const companyAddress = { + officeID: CONST.POLICY.COMPANY_ADDRESS_OFFICE_ID, + name: translate('common.companyAddress'), + address: formatAddressToString(policy?.address), + isPrimary: !officeLocations.some((officeLocation) => officeLocation.isPrimary), + }; + return [companyAddress, ...officeLocations]; +} + +/** + * The office a member commutes to: the one assigned to them, or the primary office when they have none or the assigned + * office was removed + */ +function getMemberOfficeLocation(officeLocations: AssignableOfficeLocation[], officeID: string | undefined): AssignableOfficeLocation | undefined { + return officeLocations.find((officeLocation) => officeLocation.officeID === officeID) ?? officeLocations.find((officeLocation) => officeLocation.isPrimary); +} + +export {hasOfficeLocations, getAssignableOfficeLocations, getMemberOfficeLocation}; +export type {AssignableOfficeLocation}; diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index cda6bed85a1a..a55306d95763 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -4769,6 +4769,28 @@ function getDeletedOfficeLocationMessage(translate: LocalizedTranslate, reportAc return translate('workspaceActions.officeLocation.removed', {name: getOriginalMessage(reportAction)?.name ?? ''}); } +function getUpdatedEmployeeOfficeLocationMessage(translate: LocalizedTranslate, reportAction: OnyxEntry): string { + if (!isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_EMPLOYEE_OFFICE_LOCATION)) { + return getReportActionText(reportAction); + } + const {name, email, officeID, officeName, oldOfficeID, oldOfficeName} = getOriginalMessage(reportAction) ?? {}; + + // An office logged without a name is the primary office, which a member follows when none is assigned to them or + // the assigned one was removed + const describeOfficeLocation = (loggedOfficeID: string | undefined, loggedOfficeName: string | undefined) => { + if (loggedOfficeID === CONST.POLICY.COMPANY_ADDRESS_OFFICE_ID) { + return translate('workspaceActions.officeLocation.companyAddress'); + } + return loggedOfficeName ? translate('workspaceActions.officeLocation.quotedName', {name: loggedOfficeName}) : translate('workspaceActions.officeLocation.primaryOffice'); + }; + + return translate('workspaceActions.officeLocation.changedMemberOfficeLocation', { + displayName: name ?? (email ? formatPhoneNumber(email) : ''), + officeLocation: describeOfficeLocation(officeID, officeName), + previousOfficeLocation: describeOfficeLocation(oldOfficeID, oldOfficeName), + }); +} + function getUpdatedProhibitedExpensesMessage(translate: LocalizedTranslate, reportAction: OnyxEntry) { const {newProhibitedExpenses, oldProhibitedExpenses} = getOriginalMessage(reportAction as ReportAction) ?? {}; @@ -5603,6 +5625,7 @@ export { getAddedOfficeLocationMessage, getUpdatedOfficeLocationMessage, getDeletedOfficeLocationMessage, + getUpdatedEmployeeOfficeLocationMessage, getWorkspaceTagUpdateMessage, getWorkspaceReportFieldUpdateMessage, getWorkspaceReportFieldDeleteMessage, diff --git a/src/libs/actions/Policy/DistanceRate.ts b/src/libs/actions/Policy/DistanceRate.ts index 8a4c527cdb53..2f223bb41d8d 100644 --- a/src/libs/actions/Policy/DistanceRate.ts +++ b/src/libs/actions/Policy/DistanceRate.ts @@ -16,6 +16,7 @@ import type { SetPolicyRequireMapOrGPSParams, SetPolicyWorkArrangementParams, SetWorkspaceDistanceAutoUpdateParams, + UpdateEmployeeOfficeLocationParams, UpdateOfficeLocationParams, UpdatePolicyDistanceRateParams, UpdatePolicyDistanceRateValueParams, @@ -28,6 +29,7 @@ import Log from '@libs/Log'; import {generateHexadecimalValue, rand64} from '@libs/NumberUtils'; import {buildOnyxDataForPolicyDistanceRateUpdates, getExpectedUnitForCurrency} from '@libs/PolicyDistanceRatesUtils'; import {goBackWhenEnableFeature, removePendingFieldsFromCustomUnit} from '@libs/PolicyUtils'; +import {getUpdatedEmployeeOfficeLocationMessage} from '@libs/ReportActionsUtils'; import {getRoom} from '@libs/ReportUtils'; import {getWorkArrangementLabel} from '@libs/WorkArrangementUtils'; @@ -41,6 +43,7 @@ import type {OnyxData} from '@src/types/onyx/Request'; import type {NullishDeep, OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; +import {Str} from 'expensify-common'; import Onyx from 'react-native-onyx'; import {generateCustomUnitID} from './Policy'; @@ -847,6 +850,151 @@ function setEmployeeWorkArrangement( API.write(WRITE_COMMANDS.SET_EMPLOYEE_WORK_ARRANGEMENT, parameters, onyxData); } +type OfficeLocationMemberUpdate = { + /** The account ID of the workspace member being updated. */ + accountID: number; + /** The member's login, used as the employeeList key and in the changelog action. */ + email: string; + /** The member's display name, used in the changelog message. */ + name: string; + /** The office the member was assigned to, used to restore it if the update fails. */ + previousOfficeID: string | undefined; + /** The ID assigned to this member's optimistic changelog action. */ + optimisticReportActionID: string; +}; + +/** + * Assign one or more workspace members to an office, keyed in the workspace's officeLocations, or to the company + * address with CONST.POLICY.COMPANY_ADDRESS_OFFICE_ID. The single command covers both individual and bulk updates: + * employeeAccountIDList carries every affected accountID, and one optimistic member office location changelog action is + * created per member in the workspace admins room. + */ +function updateEmployeeOfficeLocation( + policy: OnyxEntry, + employeeAccountIDList: number[], + officeID: string, + personalDetails: OnyxEntry, + translate: LocalizedTranslate, +) { + const policyID = policy?.id; + if (!policyID) { + return; + } + const policyKey = `${ONYXKEYS.COLLECTION.POLICY}${policyID}` as const; + + const updates: OfficeLocationMemberUpdate[] = []; + for (const accountID of employeeAccountIDList) { + const personalDetail = personalDetails?.[accountID]; + const login = personalDetail?.login; + if (!login) { + continue; + } + const employee = policy?.employeeList?.[login]; + if (!employee || employee.officeID === officeID) { + continue; + } + updates.push({ + accountID, + email: login, + name: personalDetail?.displayName ?? login, + previousOfficeID: employee.officeID, + optimisticReportActionID: rand64(), + }); + } + + if (updates.length === 0) { + return; + } + + const created = DateUtils.getDBTime(); + const employeeListOptimisticUpdate: Record> = {}; + const employeeListSuccessUpdate: Record> = {}; + const employeeListFailureUpdate: Record> = {}; + for (const update of updates) { + employeeListOptimisticUpdate[update.email] = {officeID, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}; + employeeListSuccessUpdate[update.email] = {pendingAction: null}; + employeeListFailureUpdate[update.email] = { + officeID: update.previousOfficeID ?? null, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('workspace.editor.genericFailureMessage'), + }; + } + + const optimisticData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: policyKey, + value: {employeeList: employeeListOptimisticUpdate}, + }, + ]; + const successData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: policyKey, + value: {employeeList: employeeListSuccessUpdate}, + }, + ]; + const failureData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: policyKey, + value: {employeeList: employeeListFailureUpdate}, + }, + ]; + + const adminsRoom = getRoom(CONST.REPORT.CHAT_TYPE.POLICY_ADMINS, policyID); + if (adminsRoom?.reportID) { + const reportActionsKey = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${adminsRoom.reportID}` as const; + const optimisticReportActions: Record = {}; + const successReportActions: Record> = {}; + const failureReportActions: Record = {}; + for (const update of updates) { + const optimisticReportAction: ReportAction = { + reportActionID: update.optimisticReportActionID, + actionName: CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_EMPLOYEE_OFFICE_LOCATION, + created, + shouldShow: true, + automatic: false, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + originalMessage: { + accountID: update.accountID, + email: update.email, + name: update.name, + officeID, + officeName: policy?.officeLocations?.[officeID]?.name ?? '', + oldOfficeID: update.previousOfficeID, + oldOfficeName: update.previousOfficeID ? (policy?.officeLocations?.[update.previousOfficeID]?.name ?? '') : '', + }, + }; + const text = getUpdatedEmployeeOfficeLocationMessage(translate, optimisticReportAction); + optimisticReportActions[update.optimisticReportActionID] = { + ...optimisticReportAction, + message: [ + { + type: CONST.REPORT.MESSAGE.TYPE.COMMENT, + html: `${Str.htmlEncode(text)}`, + text, + }, + ], + }; + successReportActions[update.optimisticReportActionID] = {pendingAction: null}; + failureReportActions[update.optimisticReportActionID] = null; + } + optimisticData.push({onyxMethod: Onyx.METHOD.MERGE, key: reportActionsKey, value: optimisticReportActions}); + successData.push({onyxMethod: Onyx.METHOD.MERGE, key: reportActionsKey, value: successReportActions}); + failureData.push({onyxMethod: Onyx.METHOD.MERGE, key: reportActionsKey, value: failureReportActions}); + } + + const parameters: UpdateEmployeeOfficeLocationParams = { + policyID, + employeeAccountIDList: updates.map((update) => update.accountID).join(','), + employeeLoginList: updates.map((update) => update.email).join(','), + officeID, + }; + const onyxData: OnyxData = {optimisticData, successData, failureData}; + API.write(WRITE_COMMANDS.UPDATE_EMPLOYEE_OFFICE_LOCATION, parameters, onyxData); +} + /** * Add an office to a workspace. Making it the default clears the flag on the current default office, so callers * pass the workspace's current `officeLocations` for the failure path to restore it. An office added without a `name` @@ -1252,6 +1400,7 @@ export { disablePolicyCommuterExclusions, clearPolicyCommuterExclusionsErrors, setEmployeeWorkArrangement, + updateEmployeeOfficeLocation, addOfficeLocation, updateOfficeLocation, deleteOfficeLocation, diff --git a/src/libs/actions/Policy/Member.ts b/src/libs/actions/Policy/Member.ts index b9921e38020a..2dab79efc368 100644 --- a/src/libs/actions/Policy/Member.ts +++ b/src/libs/actions/Policy/Member.ts @@ -1482,6 +1482,14 @@ function clearImportedSpreadsheetMemberData() { Onyx.set(ONYXKEYS.IMPORTED_SPREADSHEET_MEMBER_ROLE, null); } +function setWorkspaceMembersSelectedForBulkEdit(accountIDs: number[]) { + Onyx.set(ONYXKEYS.RAM_ONLY_WORKSPACE_MEMBERS_SELECTED_FOR_BULK_EDIT, accountIDs); +} + +function clearWorkspaceMembersSelectedForBulkEdit() { + Onyx.set(ONYXKEYS.RAM_ONLY_WORKSPACE_MEMBERS_SELECTED_FOR_BULK_EDIT, []); +} + export { removeMembers, buildUpdateWorkspaceMembersRoleOnyxData, @@ -1512,4 +1520,6 @@ export { setImportedSpreadsheetMemberData, setImportedSpreadsheetMemberRole, clearImportedSpreadsheetMemberData, + setWorkspaceMembersSelectedForBulkEdit, + clearWorkspaceMembersSelectedForBulkEdit, }; diff --git a/src/pages/inbox/report/actionContents/PolicyChangeLogContent.tsx b/src/pages/inbox/report/actionContents/PolicyChangeLogContent.tsx index 74b37c5baf16..7a4e621c9cdb 100644 --- a/src/pages/inbox/report/actionContents/PolicyChangeLogContent.tsx +++ b/src/pages/inbox/report/actionContents/PolicyChangeLogContent.tsx @@ -28,6 +28,7 @@ import { getDeletedApprovalRuleMessage, getDeletedBudgetMessage, getDeletedOfficeLocationMessage, + getUpdatedEmployeeOfficeLocationMessage, getForeignCurrencyDefaultTaxUpdateMessage, getForwardsToUpdateMessage, getInvoiceCompanyNameUpdateMessage, @@ -242,6 +243,7 @@ const POLICY_CHANGE_LOG_RESOLVERS: Record = { [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_OFFICE_LOCATION]: (translate, action) => getAddedOfficeLocationMessage(translate, action), [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_OFFICE_LOCATION]: (translate, action) => getUpdatedOfficeLocationMessage(translate, action), [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_OFFICE_LOCATION]: (translate, action) => getDeletedOfficeLocationMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_EMPLOYEE_OFFICE_LOCATION]: (translate, action) => getUpdatedEmployeeOfficeLocationMessage(translate, action), [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_REIMBURSEMENT_CHOICE]: (translate, action) => getUpdatedReimbursementChoiceMessage(translate, action), [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.SET_AUTO_JOIN]: (translate, action) => getSetAutoJoinMessage(translate, action), [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_DEFAULT_TITLE]: (translate, action) => getUpdatedDefaultTitleMessage(translate, action), diff --git a/src/pages/workspace/WorkspaceMembersPage.tsx b/src/pages/workspace/WorkspaceMembersPage.tsx index 570744f6ce47..62b7939cfcf0 100644 --- a/src/pages/workspace/WorkspaceMembersPage.tsx +++ b/src/pages/workspace/WorkspaceMembersPage.tsx @@ -21,6 +21,7 @@ import useMergeSyncResultsPage from '@hooks/useMergeSyncResultsPage'; import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; +import usePermissions from '@hooks/usePermissions'; import {usePersonalDetailsByLogins} from '@hooks/usePersonalDetailByLogin'; import usePrevious from '@hooks/usePrevious'; import usePrivateIsArchivedMap from '@hooks/usePrivateIsArchivedMap'; @@ -43,6 +44,7 @@ import { downloadMembersCSV, openWorkspaceMembersPage, removeMembers, + setWorkspaceMembersSelectedForBulkEdit, updateWorkspaceMembersRole, } from '@libs/actions/Policy/Member'; import {removeApprovalWorkflow as removeApprovalWorkflowAction, updateApprovalWorkflow} from '@libs/actions/Workflow'; @@ -54,6 +56,7 @@ import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/crea import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {WorkspaceSplitNavigatorParamList} from '@libs/Navigation/types'; +import {getAssignableOfficeLocations, getMemberOfficeLocation, hasOfficeLocations} from '@libs/OfficeLocationUtils'; import {isPersonalDetailsReady} from '@libs/OptionsListUtils'; import {getPersonalDetailsByID, temporaryGetDisplayNameOrDefault} from '@libs/PersonalDetailsUtils'; import {isPolicyReimburser} from '@libs/PolicyMemberRoleUtils'; @@ -72,6 +75,7 @@ import { isExpensifyTeam, isGroupPolicy, isPaidGroupPolicy, + isPolicyAdmin, isPolicyApprover, isSubmitPolicy, PAYER_ROLES, @@ -88,6 +92,7 @@ import {dismissAddedWithPrimaryLoginMessages} from '@userActions/Policy/Policy'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; +import type {Route} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import type {PersonalDetails, PolicyEmployee} from '@src/types/onyx'; import type {PendingAction} from '@src/types/onyx/OnyxCommon'; @@ -118,7 +123,7 @@ function invertObject(object: Record): Record { function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembersPageProps) { useWorkspaceDocumentTitle(policy?.name, 'common.members'); const tableRef = useRef>(null); - const icons = useMemoizedLazyExpensifyIcons(['Download', 'FallbackAvatar', 'MakeAdmin', 'Plus', 'RemoveMembers', 'Sync', 'Table', 'User', 'UserEye']); + const icons = useMemoizedLazyExpensifyIcons(['Building', 'Download', 'FallbackAvatar', 'Location', 'MakeAdmin', 'Plus', 'RemoveMembers', 'Sync', 'Table', 'User', 'UserEye']); const employeePersonalDetails = usePersonalDetailsByLogins(Object.keys(policy?.employeeList ?? {})); const policyMemberEmailsToAccountIDs = useMemo( () => getMemberAccountIDsForWorkspace(policy?.employeeList, employeePersonalDetails, true), @@ -140,6 +145,18 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers const {isAccountLocked} = useLockedAccountState(); const {showLockedAccountModal} = useLockedAccountActions(); const [selectedEmployees, setSelectedEmployees] = useState([]); + const {isBetaEnabled} = usePermissions(); + const [membersSelectedForBulkEditCount = 0] = useOnyx(ONYXKEYS.RAM_ONLY_WORKSPACE_MEMBERS_SELECTED_FOR_BULK_EDIT, {selector: (accountIDs) => accountIDs?.length}); + + // A bulk office location or work arrangement change empties the members it was given once it is applied, which + // ends the selection here too + const [prevMembersSelectedForBulkEditCount, setPrevMembersSelectedForBulkEditCount] = useState(membersSelectedForBulkEditCount); + if (prevMembersSelectedForBulkEditCount !== membersSelectedForBulkEditCount) { + setPrevMembersSelectedForBulkEditCount(membersSelectedForBulkEditCount); + if (!membersSelectedForBulkEditCount) { + setSelectedEmployees([]); + } + } // We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to apply the correct modal type for the decision modal // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth @@ -418,6 +435,9 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers const hasAnyCustomField2 = useMemo(() => filteredMembers.some(({policyEmployee}) => !!policyEmployee.employeePayrollID), [filteredMembers]); const shouldShowCustomField1Column = isControlPolicyWithWideLayout && hasAnyCustomField1; const shouldShowCustomField2Column = isControlPolicyWithWideLayout && hasAnyCustomField2; + const shouldShowOfficeLocations = isBetaEnabled(CONST.BETAS.COMMUTER_EXCLUSIONS) && hasOfficeLocations(policy); + const shouldShowOfficeLocationColumn = !shouldUseNarrowLayout && shouldShowOfficeLocations; + const officeLocations = useMemo(() => getAssignableOfficeLocations(policy, translate, localeCompare), [policy, translate, localeCompare]); // Submit workspaces have a flat role model where every member, including the owner, is an Editor. const isSubmitWorkspace = isSubmitPolicy(policy); @@ -462,6 +482,7 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers email: memberEmail, employeeUserID: policyEmployee.employeeUserID, employeePayrollID: policyEmployee.employeePayrollID, + officeLocationName: shouldShowOfficeLocationColumn ? getMemberOfficeLocation(officeLocations, policyEmployee.officeID)?.name : undefined, isInteractive: !details.isOptimisticPersonalDetail, isSelectionDisabled: !canWriteMembers || @@ -495,6 +516,8 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers session?.accountID, shouldShowCustomField1Column, shouldShowCustomField2Column, + shouldShowOfficeLocationColumn, + officeLocations, invitedPrimaryToSecondaryLogins, openMemberDetails, dismissError, @@ -603,6 +626,11 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers updateWorkspaceMembersRole(policy, loginsToUpdate, accountIDsToUpdate, role); }; + const editSelectedEmployees = (bulkEditRoute: Route) => { + setWorkspaceMembersSelectedForBulkEdit(selectedEmployees.map((login) => policyMemberEmailsToAccountIDs[login]).filter((accountID) => accountID !== undefined)); + Navigation.navigate(bulkEditRoute); + }; + const getBulkActionsButtonOptions = () => { const selectedEmployeesRoles = selectedEmployees.map((email) => { return policy?.employeeList?.[email]?.role; @@ -708,6 +736,30 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers options.push(paymentsAdminOption); } + // Only policy admins can change office locations and work arrangements, unlike people admins who manage members + const isCurrentUserPolicyAdmin = isPolicyAdmin(policy, currentUserLogin); + if (shouldShowOfficeLocations && isCurrentUserPolicyAdmin) { + options.push({ + text: translate('workspace.people.changeOfficeLocation'), + value: CONST.POLICY.MEMBERS_BULK_ACTION_TYPES.CHANGE_OFFICE_LOCATION, + icon: icons.Location, + onSelected: () => editSelectedEmployees(ROUTES.WORKSPACE_MEMBERS_CHANGE_OFFICE_LOCATION.getRoute(policyID)), + }); + } + + if ( + policy?.commuterExclusions?.method === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE && + isBetaEnabled(CONST.BETAS.COMMUTER_EXCLUSIONS_ARRANGEMENTS) && + isCurrentUserPolicyAdmin + ) { + options.push({ + text: translate('workspace.people.changeWorkArrangement'), + value: CONST.POLICY.MEMBERS_BULK_ACTION_TYPES.CHANGE_WORK_ARRANGEMENT, + icon: icons.Building, + onSelected: () => editSelectedEmployees(ROUTES.WORKSPACE_MEMBERS_CHANGE_WORK_ARRANGEMENT.getRoute(policyID)), + }); + } + return options; }; @@ -921,6 +973,7 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers selectedKeys={selectedEmployees} shouldShowCustomField1Column={shouldShowCustomField1Column} shouldShowCustomField2Column={shouldShowCustomField2Column} + shouldShowOfficeLocationColumn={shouldShowOfficeLocationColumn} onRowSelectionChange={setSelectedEmployees} headerComponent={tableHeaderComponent} /> diff --git a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx index 8df2f6712c50..1e464fe47d17 100644 --- a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx +++ b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx @@ -37,6 +37,7 @@ import {isRuleBotEnforcingRules} from '@libs/AgentRulesUtils'; import {getAllCardsForWorkspace, getCardFeedIcon, getCardFeedWithDomainID, getPlaidInstitutionIconUrl, lastFourNumbersFromCardName, maskCardNumber} from '@libs/CardUtils'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import {getAssignableOfficeLocations, getMemberOfficeLocation, hasOfficeLocations} from '@libs/OfficeLocationUtils'; import {getPhoneNumber, temporaryGetDisplayNameOrDefault} from '@libs/PersonalDetailsUtils'; import {addSMSDomainIfPhoneNumber} from '@libs/PhoneNumber'; import {isPolicyReimburser} from '@libs/PolicyMemberRoleUtils'; @@ -142,6 +143,7 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM const accountID = memberPersonalDetails?.accountID ?? routeAccountID; const member = policy?.employeeList?.[memberLogin]; const memberWorkArrangement = getEffectiveWorkArrangement(member?.hasOfficeWorkArrangement, policy?.commuterExclusions?.isOfficeWorkArrangement); + const memberOfficeLocation = getMemberOfficeLocation(getAssignableOfficeLocations(policy, translate, localeCompare), member?.officeID); const prevMember = usePrevious(member); const details = memberPersonalDetails ?? ({} as PersonalDetails); const fallbackIcon = details.fallbackIcon ?? ''; @@ -151,6 +153,8 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM const isSelectedMemberCurrentUser = accountID === currentUserAccountID; const isCurrentUserAdmin = policy?.employeeList?.[currentUserLogin]?.role === CONST.POLICY.ROLE.ADMIN; const isCurrentUserOwner = policy?.owner === currentUserLogin; + const shouldShowOfficeLocation = isBetaEnabled(CONST.BETAS.COMMUTER_EXCLUSIONS) && hasOfficeLocations(policy); + const canChangeOfficeLocation = shouldShowOfficeLocation && isCurrentUserAdmin; const canWriteMembers = canMemberWrite(policy, currentUserLogin, CONST.POLICY.POLICY_FEATURE.MEMBERS); const canManageSelectedMemberRole = canMemberAssignRole(policy, currentUserLogin, member?.role); const canRemoveSelectedMember = canWriteMembers && !isSelectedMemberOwner && !isSelectedMemberCurrentUser && canMemberManageMemberWithRole(policy, currentUserLogin, member?.role); @@ -422,6 +426,23 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM Navigation.navigate(ROUTES.WORKSPACE_MEMBER_DETAILS_ROLE.getRoute(policyID, accountID)); }} /> + {shouldShowOfficeLocation && ( + Navigation.navigate(ROUTES.WORKSPACE_MEMBER_OFFICE_LOCATION.getRoute(policyID, accountID))} + /> + )} {policy?.commuterExclusions?.method === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE && isWorkArrangementBetaEnabled && ( & + PlatformStackScreenProps & { + personalDetails: OnyxEntry; + }; + +function WorkspaceMemberOfficeLocationPage({policy, personalDetails, route}: WorkspaceMemberOfficeLocationPageProps) { + const accountID = Number(route.params.accountID); + const policyID = route.params.policyID; + const styles = useThemeStyles(); + const {translate, localeCompare} = useLocalize(); + const {login: currentUserLogin = ''} = useCurrentUserPersonalDetails(); + const {isBetaEnabled} = usePermissions(); + + const memberLogin = personalDetails?.[accountID]?.login ?? ''; + const member = policy?.employeeList?.[memberLogin]; + const canChangeOfficeLocation = isBetaEnabled(CONST.BETAS.COMMUTER_EXCLUSIONS) && isPolicyAdmin(policy, currentUserLogin) && hasOfficeLocations(policy) && !!member; + const officeLocations = getAssignableOfficeLocations(policy, translate, localeCompare); + const currentOfficeID = getMemberOfficeLocation(officeLocations, member?.officeID)?.officeID; + + const navigateBackToDetails = () => { + Navigation.goBack(ROUTES.WORKSPACE_MEMBER_DETAILS.getRoute(policyID, accountID)); + }; + + const changeOfficeLocation = ({keyForList}: ListItem) => { + if (keyForList === currentOfficeID || !canChangeOfficeLocation) { + return; + } + updateEmployeeOfficeLocation(policy, [accountID], keyForList, personalDetails, translate); + navigateBackToDetails(); + }; + + const options: ListItem[] = officeLocations.map((officeLocation) => ({ + text: officeLocation.name, + alternateText: officeLocation.address, + keyForList: officeLocation.officeID, + isSelected: officeLocation.officeID === currentOfficeID, + })); + + return ( + + + + + + + {translate('workspace.people.workArrangementPage.futureOnlyNote')} + + + + + ); +} + +export default withPolicyAndFullscreenLoading(WorkspaceMemberOfficeLocationPage); diff --git a/src/pages/workspace/members/WorkspaceMembersChangeOfficeLocationPage.tsx b/src/pages/workspace/members/WorkspaceMembersChangeOfficeLocationPage.tsx new file mode 100644 index 000000000000..dc1eb057a7c4 --- /dev/null +++ b/src/pages/workspace/members/WorkspaceMembersChangeOfficeLocationPage.tsx @@ -0,0 +1,127 @@ +import Button from '@components/Button'; +import FixedFooter from '@components/FixedFooter'; +import HeaderWithBackButtonAndTitle from '@components/Header/composed/HeaderWithBackButtonAndTitle'; +import ScreenWrapper from '@components/ScreenWrapper'; +import SelectionList from '@components/SelectionList'; +import SingleSelectListItem from '@components/SelectionList/ListItem/SingleSelectListItem'; +import type {ListItem} from '@components/SelectionList/types'; +import Text from '@components/Text'; + +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import usePermissions from '@hooks/usePermissions'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {updateEmployeeOfficeLocation} from '@libs/actions/Policy/DistanceRate'; +import {clearWorkspaceMembersSelectedForBulkEdit} from '@libs/actions/Policy/Member'; +import Navigation from '@libs/Navigation/Navigation'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import {getAssignableOfficeLocations, getMemberOfficeLocation, hasOfficeLocations} from '@libs/OfficeLocationUtils'; +import {isPolicyAdmin} from '@libs/PolicyUtils'; + +import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading'; +import type {WithPolicyAndFullscreenLoadingProps} from '@pages/workspace/withPolicyAndFullscreenLoading'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; +import type {PersonalDetailsList} from '@src/types/onyx'; +import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; + +import type {OnyxEntry} from 'react-native-onyx'; + +import React, {useState} from 'react'; +import {View} from 'react-native'; + +type WorkspaceMembersChangeOfficeLocationPageProps = Omit & + PlatformStackScreenProps & { + personalDetails: OnyxEntry; + }; + +function WorkspaceMembersChangeOfficeLocationPage({policy, personalDetails, route}: WorkspaceMembersChangeOfficeLocationPageProps) { + const policyID = route.params.policyID; + const styles = useThemeStyles(); + const {translate, localeCompare} = useLocalize(); + const {login: currentUserLogin = ''} = useCurrentUserPersonalDetails(); + const {isBetaEnabled} = usePermissions(); + const [selectedAccountIDs, selectedAccountIDsMetadata] = useOnyx(ONYXKEYS.RAM_ONLY_WORKSPACE_MEMBERS_SELECTED_FOR_BULK_EDIT); + const [chosenOfficeID, setChosenOfficeID] = useState(); + + // The selection only lives in memory, so it is gone when the page is opened from a link or after a refresh + const hasSelectedMembers = isLoadingOnyxValue(selectedAccountIDsMetadata) || !!selectedAccountIDs?.length; + const canChangeOfficeLocation = isBetaEnabled(CONST.BETAS.COMMUTER_EXCLUSIONS) && isPolicyAdmin(policy, currentUserLogin) && hasOfficeLocations(policy); + const officeLocations = getAssignableOfficeLocations(policy, translate, localeCompare); + + // The office the selected members share starts out selected, and none does while their offices differ + const selectedMembersOfficeIDs = new Set( + (selectedAccountIDs ?? []).map((accountID) => getMemberOfficeLocation(officeLocations, policy?.employeeList?.[personalDetails?.[accountID]?.login ?? '']?.officeID)?.officeID), + ); + const officeID = chosenOfficeID ?? (selectedMembersOfficeIDs.size === 1 ? [...selectedMembersOfficeIDs].at(0) : undefined); + + const navigateBackToMembers = () => { + Navigation.goBack(ROUTES.WORKSPACE_MEMBERS.getRoute(policyID)); + }; + + const changeOfficeLocation = () => { + if (!officeID || !selectedAccountIDs?.length) { + return; + } + updateEmployeeOfficeLocation(policy, selectedAccountIDs, officeID, personalDetails, translate); + Navigation.goBack(ROUTES.WORKSPACE_MEMBERS.getRoute(policyID), {afterTransition: clearWorkspaceMembersSelectedForBulkEdit}); + }; + + const options: ListItem[] = officeLocations.map((officeLocation) => ({ + text: officeLocation.name, + alternateText: officeLocation.address, + keyForList: officeLocation.officeID, + isSelected: officeLocation.officeID === officeID, + })); + + return ( + + + + + setChosenOfficeID(keyForList)} + initiallyFocusedItemKey={officeID} + /> + + {translate('workspace.people.workArrangementPage.futureOnlyNote')} + + + + + + + + ); +} + +export default withPolicyAndFullscreenLoading(WorkspaceMembersChangeOfficeLocationPage); diff --git a/src/pages/workspace/members/WorkspaceMembersChangeWorkArrangementPage.tsx b/src/pages/workspace/members/WorkspaceMembersChangeWorkArrangementPage.tsx new file mode 100644 index 000000000000..6e19cec63c4c --- /dev/null +++ b/src/pages/workspace/members/WorkspaceMembersChangeWorkArrangementPage.tsx @@ -0,0 +1,118 @@ +import Button from '@components/Button'; +import FixedFooter from '@components/FixedFooter'; +import HeaderWithBackButtonAndTitle from '@components/Header/composed/HeaderWithBackButtonAndTitle'; +import ScreenWrapper from '@components/ScreenWrapper'; +import ScrollView from '@components/ScrollView'; +import Text from '@components/Text'; +import WorkArrangementSelector from '@components/WorkArrangementSelector'; + +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import usePermissions from '@hooks/usePermissions'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {setEmployeeWorkArrangement} from '@libs/actions/Policy/DistanceRate'; +import {clearWorkspaceMembersSelectedForBulkEdit} from '@libs/actions/Policy/Member'; +import Navigation from '@libs/Navigation/Navigation'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import {isPolicyAdmin} from '@libs/PolicyUtils'; +import {getEffectiveWorkArrangement} from '@libs/WorkArrangementUtils'; + +import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading'; +import type {WithPolicyAndFullscreenLoadingProps} from '@pages/workspace/withPolicyAndFullscreenLoading'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; +import type {PersonalDetailsList} from '@src/types/onyx'; +import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; + +import type {OnyxEntry} from 'react-native-onyx'; + +import React, {useState} from 'react'; + +type WorkspaceMembersChangeWorkArrangementPageProps = Omit & + PlatformStackScreenProps & { + personalDetails: OnyxEntry; + }; + +function WorkspaceMembersChangeWorkArrangementPage({policy, personalDetails, route}: WorkspaceMembersChangeWorkArrangementPageProps) { + const policyID = route.params.policyID; + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const {login: currentUserLogin = ''} = useCurrentUserPersonalDetails(); + const {isBetaEnabled} = usePermissions(); + const [selectedAccountIDs, selectedAccountIDsMetadata] = useOnyx(ONYXKEYS.RAM_ONLY_WORKSPACE_MEMBERS_SELECTED_FOR_BULK_EDIT); + const [chosenIsOffice, setChosenIsOffice] = useState(); + + // The selection only lives in memory, so it is gone when the page is opened from a link or after a refresh + const hasSelectedMembers = isLoadingOnyxValue(selectedAccountIDsMetadata) || !!selectedAccountIDs?.length; + const canChangeWorkArrangement = + isBetaEnabled(CONST.BETAS.COMMUTER_EXCLUSIONS_ARRANGEMENTS) && + isPolicyAdmin(policy, currentUserLogin) && + policy?.commuterExclusions?.method === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE; + + // The arrangement the selected members share starts out selected, and none does while their arrangements differ + const selectedMembersIsOffice = new Set( + (selectedAccountIDs ?? []).map((accountID) => + getEffectiveWorkArrangement(policy?.employeeList?.[personalDetails?.[accountID]?.login ?? '']?.hasOfficeWorkArrangement, policy?.commuterExclusions?.isOfficeWorkArrangement), + ), + ); + const isOffice = chosenIsOffice ?? (selectedMembersIsOffice.size === 1 ? [...selectedMembersIsOffice].at(0) : undefined); + + const navigateBackToMembers = () => { + Navigation.goBack(ROUTES.WORKSPACE_MEMBERS.getRoute(policyID)); + }; + + const changeWorkArrangement = () => { + if (isOffice === undefined || !selectedAccountIDs?.length) { + return; + } + setEmployeeWorkArrangement(policy, selectedAccountIDs, isOffice, personalDetails, translate); + Navigation.goBack(ROUTES.WORKSPACE_MEMBERS.getRoute(policyID), {afterTransition: clearWorkspaceMembersSelectedForBulkEdit}); + }; + + return ( + + + + + + {translate('workspace.people.workArrangementPage.futureOnlyNote')} + + + + + + + ); +} + +export default withPolicyAndFullscreenLoading(WorkspaceMembersChangeWorkArrangementPage); diff --git a/src/pages/workspace/withPolicy.tsx b/src/pages/workspace/withPolicy.tsx index b82d3e72f03e..39298b63346f 100644 --- a/src/pages/workspace/withPolicy.tsx +++ b/src/pages/workspace/withPolicy.tsx @@ -45,6 +45,9 @@ type PolicyRouteName = | typeof SCREENS.WORKSPACE.MEMBER_DETAILS | typeof SCREENS.WORKSPACE.MEMBER_DETAILS_ROLE | typeof SCREENS.WORKSPACE.MEMBER_WORK_ARRANGEMENT + | typeof SCREENS.WORKSPACE.MEMBER_OFFICE_LOCATION + | typeof SCREENS.WORKSPACE.MEMBERS_CHANGE_OFFICE_LOCATION + | typeof SCREENS.WORKSPACE.MEMBERS_CHANGE_WORK_ARRANGEMENT | typeof SCREENS.WORKSPACE.MEMBER_CUSTOM_FIELD | typeof SCREENS.WORKSPACE.INVOICES | typeof SCREENS.WORKSPACE.DYNAMIC_OWNER_CHANGE_CHECK diff --git a/src/setup/index.ts b/src/setup/index.ts index 24d54ed5d84d..75245787538a 100644 --- a/src/setup/index.ts +++ b/src/setup/index.ts @@ -103,6 +103,7 @@ export default function () { ONYXKEYS.RAM_ONLY_MERGE_HR_LINK_TOKEN, ONYXKEYS.COLLECTION.RAM_ONLY_ISSUE_NEW_EXPENSIFY_CARD, ONYXKEYS.RAM_ONLY_DOMAIN_MEMBERS_SELECTED_FOR_MOVE, + ONYXKEYS.RAM_ONLY_WORKSPACE_MEMBERS_SELECTED_FOR_BULK_EDIT, ONYXKEYS.RAM_ONLY_HAS_DISMISSED_CONCIERGE_NOTIFICATION_BANNER, ONYXKEYS.RAM_ONLY_CORPAY_PAY_MODAL, ], diff --git a/src/types/onyx/OriginalMessage.ts b/src/types/onyx/OriginalMessage.ts index 4746ac13e91a..c299c0b935bd 100644 --- a/src/types/onyx/OriginalMessage.ts +++ b/src/types/onyx/OriginalMessage.ts @@ -887,6 +887,30 @@ type OriginalMessageOfficeLocationChangeLog = { oldAddress?: CompanyAddress; }; +/** Model of a member office location change log action */ +type OriginalMessageEmployeeOfficeLocationChangeLog = { + /** Account ID of the member */ + accountID?: number; + + /** Login of the member */ + email?: string; + + /** Display name of the member */ + name?: string; + + /** Office the member was assigned to, or empty when the assignment was cleared */ + officeID?: string; + + /** Nickname of the office the member was assigned to */ + officeName?: string; + + /** Office the member was assigned to before the change, or empty when they had none */ + oldOfficeID?: string; + + /** Nickname of the office the member was assigned to before the change */ + oldOfficeName?: string; +}; + /** Model of a policy copy change log action */ type OriginalMessagePolicyChangeCopyLog = { /** The ID of the source policy from which the user copied settings */ @@ -1803,6 +1827,7 @@ type OriginalMessageMap = { [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_OFFICE_LOCATION]: OriginalMessageOfficeLocationChangeLog; [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_OFFICE_LOCATION]: OriginalMessageOfficeLocationChangeLog; [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_OFFICE_LOCATION]: OriginalMessageOfficeLocationChangeLog; + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_EMPLOYEE_OFFICE_LOCATION]: OriginalMessageEmployeeOfficeLocationChangeLog; } & Record, OriginalMessageChangeLog>; type OriginalMessage = T extends keyof OriginalMessageMap ? OriginalMessageMap[T] : never; diff --git a/src/types/onyx/PolicyEmployee.ts b/src/types/onyx/PolicyEmployee.ts index dd43c199a4ad..e87eeb14f194 100644 --- a/src/types/onyx/PolicyEmployee.ts +++ b/src/types/onyx/PolicyEmployee.ts @@ -36,6 +36,12 @@ type PolicyEmployee = OnyxCommon.OnyxValueWithOfflineFeedback<{ /** Whether the member is based in an office. When absent, the workspace default applies. */ hasOfficeWorkArrangement?: boolean; + /** + * The office the member is assigned to, keyed in the workspace's officeLocations, or CONST.POLICY.COMPANY_ADDRESS_OFFICE_ID + * for the company address. When absent, or when its office was deleted, the workspace's primary office applies. + */ + officeID?: string; + /** * Errors from api calls on the specific user * {: 'error message', : 'error message 2'} diff --git a/tests/ui/WorkspaceMemberDetailsPageTest.tsx b/tests/ui/WorkspaceMemberDetailsPageTest.tsx index 61ef607f8613..ae9252438ce9 100644 --- a/tests/ui/WorkspaceMemberDetailsPageTest.tsx +++ b/tests/ui/WorkspaceMemberDetailsPageTest.tsx @@ -22,6 +22,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import SCREENS from '@src/SCREENS'; import type {Policy} from '@src/types/onyx'; +import type {CompanyAddress, OfficeLocation} from '@src/types/onyx/Policy'; import {PortalProvider} from '@gorhom/portal'; import {NavigationContainer} from '@react-navigation/native'; @@ -236,6 +237,67 @@ describe('WorkspaceMemberDetailsPage', () => { await waitForBatchedUpdatesWithAct(); }); + const setupOfficeLocations = async ({memberOfficeID, hasOffices = true, betaEnabled = true}: {memberOfficeID?: string; hasOffices?: boolean; betaEnabled?: boolean}) => { + const typedPolicy = createMock(policy); + const employeeList = { + ...typedPolicy.employeeList, + [invitedEmail]: {...typedPolicy.employeeList?.[invitedEmail], officeID: memberOfficeID}, + }; + const officeLocations: Record = { + OFFICE1: {name: 'Headquarters', address: {addressStreet: '88 Kearny St', city: 'San Francisco', state: 'CA', zipCode: '94108', country: 'US'}, isDefault: true}, + OFFICE2: {name: 'New Jersey Office', address: {addressStreet: '900 Asbury Ave', city: 'Ocean City', state: 'NJ', zipCode: '08226', country: 'US'}}, + }; + const companyAddress: CompanyAddress = {addressStreet: '1 Market St', city: 'San Francisco', state: 'CA', zipCode: '94105', country: 'US'}; + const officeLocationsPolicy = createMock({ + ...typedPolicy, + address: companyAddress, + officeLocations: hasOffices ? officeLocations : undefined, + employeeList, + }); + await act(async () => { + await Onyx.set(ONYXKEYS.BETAS, betaEnabled ? [CONST.BETAS.COMMUTER_EXCLUSIONS] : []); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, officeLocationsPolicy); + }); + const {unmount} = renderPage({policyID: policy.id, accountID: String(invitedAccountID)}); + await waitForBatchedUpdatesWithAct(); + return unmount; + }; + + it.each([ + ['the office assigned to the member', 'OFFICE2', 'New Jersey Office', '900 Asbury Ave, Ocean City, NJ 08226'], + ['the company address when the member is assigned to it', CONST.POLICY.COMPANY_ADDRESS_OFFICE_ID, 'Company address', '1 Market St, San Francisco, CA 94105'], + ['the primary office when the member has no office', undefined, 'Headquarters', '88 Kearny St, San Francisco, CA 94108'], + ])('shows %s as the office location', async (_description, memberOfficeID, name, address) => { + // Given a workspace with offices and a member with the given office assignment + const unmount = await setupOfficeLocations({memberOfficeID}); + + // Then the office location item names that office and its address + const row = await screen.findByTestId('member-office-location-menu-item'); + expect(within(row).getByText(TestHelper.translateLocal('workspace.people.officeLocationWithAddress', {name, address}))).toBeOnTheScreen(); + unmount(); + await waitForBatchedUpdatesWithAct(); + }); + + it('hides the office location item when the workspace has no offices besides its company address', async () => { + // Given a workspace whose only office is its company address + const unmount = await setupOfficeLocations({hasOffices: false}); + + // Then there is nothing to choose between, so the office location item is hidden + expect(screen.queryByTestId('member-office-location-menu-item')).not.toBeOnTheScreen(); + unmount(); + await waitForBatchedUpdatesWithAct(); + }); + + it('hides the office location item when the beta is disabled', async () => { + // Given a workspace with offices but the commuter exclusions beta is disabled + const unmount = await setupOfficeLocations({betaEnabled: false}); + + // Then the office location item is hidden + expect(screen.queryByTestId('member-office-location-menu-item')).not.toBeOnTheScreen(); + unmount(); + await waitForBatchedUpdatesWithAct(); + }); + it('should show the member details when the route accountID matches their personal details entry', async () => { const {unmount} = renderPage({policyID: policy.id, accountID: String(invitedAccountID)}); await waitForBatchedUpdatesWithAct(); diff --git a/tests/ui/WorkspaceMemberOfficeLocationPageTest.tsx b/tests/ui/WorkspaceMemberOfficeLocationPageTest.tsx new file mode 100644 index 000000000000..eec5e1fc972a --- /dev/null +++ b/tests/ui/WorkspaceMemberOfficeLocationPageTest.tsx @@ -0,0 +1,140 @@ +import {act, render} from '@testing-library/react-native'; + +import SelectionList from '@components/SelectionList'; + +import {updateEmployeeOfficeLocation} from '@libs/actions/Policy/DistanceRate'; +import Navigation from '@libs/Navigation/Navigation'; +import {isPolicyAdmin} from '@libs/PolicyUtils'; + +import WorkspaceMemberOfficeLocationPage from '@pages/workspace/members/WorkspaceMemberOfficeLocationPage'; + +import ROUTES from '@src/ROUTES'; +import type {PersonalDetailsList, Policy} from '@src/types/onyx'; +import type {CompanyAddress, OfficeLocation} from '@src/types/onyx/Policy'; + +import type React from 'react'; +import type {PropsWithChildren} from 'react'; + +import createMock from '../utils/createMock'; + +jest.mock('@components/Header/composed/HeaderWithBackButtonAndTitle', () => jest.fn(() => null)); +jest.mock('@components/ScreenWrapper', () => jest.fn(({children}: PropsWithChildren) => children)); +jest.mock('@components/SelectionList', () => jest.fn(() => null)); +jest.mock('@components/SelectionList/ListItem/SingleSelectListItem', () => jest.fn(() => null)); +jest.mock('@components/Text', () => jest.fn(() => null)); +jest.mock('@hooks/useCurrentUserPersonalDetails', () => jest.fn(() => ({login: 'admin@example.com'}))); +jest.mock('@hooks/useLocalize', () => jest.fn(() => ({translate: (key: string) => key, localeCompare: (a: string, b: string) => a.localeCompare(b)}))); +jest.mock('@hooks/usePermissions', () => jest.fn(() => ({isBetaEnabled: () => true}))); +jest.mock('@hooks/useThemeStyles', () => jest.fn(() => new Proxy({}, {get: () => ({})}))); +jest.mock('@libs/Navigation/Navigation', () => ({goBack: jest.fn()})); +jest.mock('@libs/actions/Policy/DistanceRate', () => ({updateEmployeeOfficeLocation: jest.fn()})); +jest.mock('@libs/PolicyUtils', () => ({ + ...jest.requireActual>('@libs/PolicyUtils'), + isPolicyAdmin: jest.fn(() => true), +})); +jest.mock('@pages/workspace/AccessOrNotFoundWrapper', () => ({__esModule: true, default: ({children}: PropsWithChildren) => children})); +jest.mock('@pages/workspace/withPolicyAndFullscreenLoading', () => (Component: React.ComponentType) => Component); + +const companyAddress: CompanyAddress = {addressStreet: '1 Market St', city: 'San Francisco', state: 'CA', zipCode: '94105', country: 'US'}; +const headquartersAndNewJerseyOffices: Record = { + OFFICE1: {name: 'Headquarters', address: {addressStreet: '88 Kearny St', city: 'San Francisco', state: 'CA', zipCode: '94108', country: 'US'}, isDefault: true}, + OFFICE2: {name: 'New Jersey Office', address: {addressStreet: '900 Asbury Ave', city: 'Ocean City', state: 'NJ', zipCode: '08226', country: 'US'}}, +}; + +type MockOfficeLocationOption = {keyForList: string; isSelected?: boolean}; + +type MockSelectionListProps = { + data: MockOfficeLocationOption[]; + onSelectRow?: (item: MockOfficeLocationOption) => void; +}; + +type WorkspaceMemberOfficeLocationPageTestProps = React.ComponentProps & {policy: Policy; personalDetails: PersonalDetailsList}; + +describe('WorkspaceMemberOfficeLocationPage', () => { + const policyID = 'policy123'; + const accountID = 12345; + const memberLogin = 'member@example.com'; + const personalDetails = {[accountID]: {login: memberLogin}}; + const mockedSelectionList = jest.mocked(SelectionList); + const getSelectionListProps = () => mockedSelectionList.mock.lastCall?.[0] as MockSelectionListProps | undefined; + const getSelectedOfficeIDs = () => + getSelectionListProps() + ?.data.filter((option) => option.isSelected) + .map((option) => option.keyForList); + + beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(isPolicyAdmin).mockReturnValue(true); + }); + + const renderPage = (officeID?: string) => { + const policy = { + id: policyID, + address: companyAddress, + officeLocations: headquartersAndNewJerseyOffices, + employeeList: {[memberLogin]: {email: memberLogin, officeID}}, + }; + const props = createMock({ + policy: createMock(policy), + personalDetails, + route: {params: {policyID, accountID: String(accountID)}}, + }); + // The HOC mock exposes the wrapped screen directly, which accepts the policy prop that the production HOC normally injects. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- the test HOC mock returns the unwrapped component with its injected policy props restored + const UnwrappedPage = WorkspaceMemberOfficeLocationPage as React.ComponentType; + render(); + return props.policy; + }; + + it('lists the company address and the offices, with the primary office selected for a member without one', () => { + // Given a member who follows the primary office + renderPage(); + + // When the page lists the offices + // Then the company address and every office are offered, and the primary office is the selected one + expect(getSelectionListProps()?.data.map((option) => option.keyForList)).toEqual(['1', 'OFFICE1', 'OFFICE2']); + expect(getSelectedOfficeIDs()).toEqual(['OFFICE1']); + }); + + it('does nothing when the selected office is already the member’s', () => { + // Given a member assigned to the New Jersey office + renderPage('OFFICE2'); + + // When that office is selected again + act(() => { + getSelectionListProps()?.onSelectRow?.({keyForList: 'OFFICE2'}); + }); + + // Then nothing is sent and the page stays open + expect(updateEmployeeOfficeLocation).not.toHaveBeenCalled(); + expect(Navigation.goBack).not.toHaveBeenCalled(); + }); + + it('does nothing for a user who is not a workspace admin', () => { + // Given a user who manages members without being a workspace admin + jest.mocked(isPolicyAdmin).mockReturnValue(false); + renderPage(); + + // When they select another office + act(() => { + getSelectionListProps()?.onSelectRow?.({keyForList: 'OFFICE2'}); + }); + + // Then nothing is sent, since only admins can change office assignments + expect(updateEmployeeOfficeLocation).not.toHaveBeenCalled(); + }); + + it('assigns the selected office and returns to the member details', () => { + // Given a member who follows the primary office + const policy = renderPage(); + + // When the company address is selected + act(() => { + getSelectionListProps()?.onSelectRow?.({keyForList: '1'}); + }); + + // Then the member is assigned to it and the member details are shown + expect(updateEmployeeOfficeLocation).toHaveBeenCalledWith(policy, [accountID], '1', personalDetails, expect.any(Function)); + expect(Navigation.goBack).toHaveBeenCalledWith(ROUTES.WORKSPACE_MEMBER_DETAILS.getRoute(policyID, accountID)); + }); +}); diff --git a/tests/ui/WorkspaceMembersChangeOfficeLocationPageTest.tsx b/tests/ui/WorkspaceMembersChangeOfficeLocationPageTest.tsx new file mode 100644 index 000000000000..fbcb66f1150d --- /dev/null +++ b/tests/ui/WorkspaceMembersChangeOfficeLocationPageTest.tsx @@ -0,0 +1,148 @@ +import {act, render} from '@testing-library/react-native'; + +import Button from '@components/Button'; +import SelectionList from '@components/SelectionList'; + +import useOnyx from '@hooks/useOnyx'; + +import {updateEmployeeOfficeLocation} from '@libs/actions/Policy/DistanceRate'; +import {clearWorkspaceMembersSelectedForBulkEdit} from '@libs/actions/Policy/Member'; +import Navigation from '@libs/Navigation/Navigation'; + +import WorkspaceMembersChangeOfficeLocationPage from '@pages/workspace/members/WorkspaceMembersChangeOfficeLocationPage'; + +import ROUTES from '@src/ROUTES'; +import type {PersonalDetailsList, Policy} from '@src/types/onyx'; +import type {OfficeLocation} from '@src/types/onyx/Policy'; + +import type React from 'react'; +import type {PropsWithChildren} from 'react'; + +import createMock from '../utils/createMock'; + +jest.mock('@components/Button', () => + Object.assign( + jest.fn(() => null), + {Text: () => null, KeyboardShortcut: () => null}, + ), +); +jest.mock('@components/FixedFooter', () => jest.fn(({children}: PropsWithChildren) => children)); +jest.mock('@components/Header/composed/HeaderWithBackButtonAndTitle', () => jest.fn(() => null)); +jest.mock('@components/ScreenWrapper', () => jest.fn(({children}: PropsWithChildren) => children)); +jest.mock('@components/SelectionList', () => jest.fn(() => null)); +jest.mock('@components/SelectionList/ListItem/SingleSelectListItem', () => jest.fn(() => null)); +jest.mock('@components/Text', () => jest.fn(() => null)); +jest.mock('@hooks/useCurrentUserPersonalDetails', () => jest.fn(() => ({login: 'admin@example.com'}))); +jest.mock('@hooks/useLocalize', () => jest.fn(() => ({translate: (key: string) => key, localeCompare: (a: string, b: string) => a.localeCompare(b)}))); +jest.mock('@hooks/useOnyx', () => jest.fn()); +jest.mock('@hooks/usePermissions', () => jest.fn(() => ({isBetaEnabled: () => true}))); +jest.mock('@hooks/useThemeStyles', () => jest.fn(() => new Proxy({}, {get: () => ({})}))); +jest.mock('@libs/Navigation/Navigation', () => ({goBack: jest.fn()})); +jest.mock('@libs/actions/Policy/DistanceRate', () => ({updateEmployeeOfficeLocation: jest.fn()})); +jest.mock('@libs/actions/Policy/Member', () => ({clearWorkspaceMembersSelectedForBulkEdit: jest.fn()})); +jest.mock('@libs/PolicyUtils', () => ({ + ...jest.requireActual>('@libs/PolicyUtils'), + isPolicyAdmin: jest.fn(() => true), +})); +jest.mock('@pages/workspace/AccessOrNotFoundWrapper', () => ({__esModule: true, default: ({children}: PropsWithChildren) => children})); +jest.mock('@pages/workspace/withPolicyAndFullscreenLoading', () => (Component: React.ComponentType) => Component); + +const headquartersAndNewJerseyOffices: Record = { + OFFICE1: {name: 'Headquarters', address: {addressStreet: '88 Kearny St', city: 'San Francisco', state: 'CA', zipCode: '94108', country: 'US'}, isDefault: true}, + OFFICE2: {name: 'New Jersey Office', address: {addressStreet: '900 Asbury Ave', city: 'Ocean City', state: 'NJ', zipCode: '08226', country: 'US'}}, +}; + +type MockOfficeLocationOption = {keyForList: string; isSelected?: boolean}; + +type MockSelectionListProps = { + data: MockOfficeLocationOption[]; + onSelectRow?: (item: MockOfficeLocationOption) => void; +}; + +type MockButtonProps = { + onPress?: () => void; + isDisabled?: boolean; +}; + +type WorkspaceMembersChangeOfficeLocationPageTestProps = React.ComponentProps & { + policy: Policy; + personalDetails: PersonalDetailsList; +}; + +describe('WorkspaceMembersChangeOfficeLocationPage', () => { + const policyID = 'policy123'; + const member1AccountID = 1; + const member2AccountID = 2; + const member1Login = 'member1@example.com'; + const member2Login = 'member2@example.com'; + const personalDetails = {[member1AccountID]: {login: member1Login}, [member2AccountID]: {login: member2Login}}; + const getSelectionListProps = () => jest.mocked(SelectionList).mock.lastCall?.[0] as MockSelectionListProps | undefined; + const getConfirmButtonProps = () => jest.mocked(Button).mock.lastCall?.[0] as MockButtonProps | undefined; + const getSelectedOfficeIDs = () => + getSelectionListProps() + ?.data.filter((option) => option.isSelected) + .map((option) => option.keyForList); + + beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(useOnyx).mockReturnValue([[member1AccountID, member2AccountID], {status: 'loaded'}]); + }); + + const renderPage = (member1OfficeID?: string, member2OfficeID?: string) => { + const policy = { + id: policyID, + officeLocations: headquartersAndNewJerseyOffices, + employeeList: { + [member1Login]: {email: member1Login, officeID: member1OfficeID}, + [member2Login]: {email: member2Login, officeID: member2OfficeID}, + }, + }; + const props = createMock({ + policy: createMock(policy), + personalDetails, + route: {params: {policyID}}, + }); + // The HOC mock exposes the wrapped screen directly, which accepts the policy prop that the production HOC normally injects. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- the test HOC mock returns the unwrapped component with its injected policy props restored + const UnwrappedPage = WorkspaceMembersChangeOfficeLocationPage as React.ComponentType; + render(); + return props.policy; + }; + + it('selects the office the selected members share', () => { + // Given one member assigned to the primary office and one following it + renderPage('OFFICE1'); + + // When the page lists the offices + // Then their shared office is selected and can be confirmed + expect(getSelectedOfficeIDs()).toEqual(['OFFICE1']); + expect(getConfirmButtonProps()?.isDisabled).toBe(false); + }); + + it('selects nothing while the selected members are in different offices', () => { + // Given members in different offices + renderPage('OFFICE1', 'OFFICE2'); + + // When the page lists the offices + // Then no office is selected and there is nothing to confirm yet + expect(getSelectedOfficeIDs()).toEqual([]); + expect(getConfirmButtonProps()?.isDisabled).toBe(true); + }); + + it('assigns the chosen office to every selected member and hands back the selection once the page closes', () => { + // Given members in different offices + const policy = renderPage('OFFICE1', 'OFFICE2'); + + // When the New Jersey office is chosen and confirmed + act(() => { + getSelectionListProps()?.onSelectRow?.({keyForList: 'OFFICE2'}); + }); + act(() => { + getConfirmButtonProps()?.onPress?.(); + }); + + // Then both members are assigned to it, and the selection is cleared after returning to the members + expect(updateEmployeeOfficeLocation).toHaveBeenCalledWith(policy, [member1AccountID, member2AccountID], 'OFFICE2', personalDetails, expect.any(Function)); + expect(Navigation.goBack).toHaveBeenCalledWith(ROUTES.WORKSPACE_MEMBERS.getRoute(policyID), {afterTransition: clearWorkspaceMembersSelectedForBulkEdit}); + }); +}); diff --git a/tests/ui/WorkspaceMembersChangeWorkArrangementPageTest.tsx b/tests/ui/WorkspaceMembersChangeWorkArrangementPageTest.tsx new file mode 100644 index 000000000000..22497cd88276 --- /dev/null +++ b/tests/ui/WorkspaceMembersChangeWorkArrangementPageTest.tsx @@ -0,0 +1,134 @@ +import {act, render} from '@testing-library/react-native'; + +import Button from '@components/Button'; +import WorkArrangementSelector from '@components/WorkArrangementSelector'; + +import useOnyx from '@hooks/useOnyx'; + +import {setEmployeeWorkArrangement} from '@libs/actions/Policy/DistanceRate'; +import {clearWorkspaceMembersSelectedForBulkEdit} from '@libs/actions/Policy/Member'; +import Navigation from '@libs/Navigation/Navigation'; + +import WorkspaceMembersChangeWorkArrangementPage from '@pages/workspace/members/WorkspaceMembersChangeWorkArrangementPage'; + +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; +import type {PersonalDetailsList, Policy} from '@src/types/onyx'; + +import type React from 'react'; +import type {PropsWithChildren} from 'react'; + +import createMock from '../utils/createMock'; + +jest.mock('@components/Button', () => + Object.assign( + jest.fn(() => null), + {Text: () => null, KeyboardShortcut: () => null}, + ), +); +jest.mock('@components/FixedFooter', () => jest.fn(({children}: PropsWithChildren) => children)); +jest.mock('@components/Header/composed/HeaderWithBackButtonAndTitle', () => jest.fn(() => null)); +jest.mock('@components/ScreenWrapper', () => jest.fn(({children}: PropsWithChildren) => children)); +jest.mock('@components/ScrollView', () => jest.fn(({children}: PropsWithChildren) => children)); +jest.mock('@components/Text', () => jest.fn(() => null)); +jest.mock('@components/WorkArrangementSelector', () => jest.fn(() => null)); +jest.mock('@hooks/useCurrentUserPersonalDetails', () => jest.fn(() => ({login: 'admin@example.com'}))); +jest.mock('@hooks/useLocalize', () => jest.fn(() => ({translate: (key: string) => key}))); +jest.mock('@hooks/useOnyx', () => jest.fn()); +jest.mock('@hooks/usePermissions', () => jest.fn(() => ({isBetaEnabled: () => true}))); +jest.mock('@hooks/useThemeStyles', () => jest.fn(() => new Proxy({}, {get: () => ({})}))); +jest.mock('@libs/Navigation/Navigation', () => ({goBack: jest.fn()})); +jest.mock('@libs/actions/Policy/DistanceRate', () => ({setEmployeeWorkArrangement: jest.fn()})); +jest.mock('@libs/actions/Policy/Member', () => ({clearWorkspaceMembersSelectedForBulkEdit: jest.fn()})); +jest.mock('@libs/PolicyUtils', () => ({isPolicyAdmin: jest.fn(() => true)})); +jest.mock('@pages/workspace/AccessOrNotFoundWrapper', () => ({__esModule: true, default: ({children}: PropsWithChildren) => children})); +jest.mock('@pages/workspace/withPolicyAndFullscreenLoading', () => (Component: React.ComponentType) => Component); + +type MockWorkArrangementSelectorProps = { + isOffice: boolean | undefined; + onSelect: (isOffice: boolean) => void; +}; + +type MockButtonProps = { + onPress?: () => void; + isDisabled?: boolean; +}; + +type WorkspaceMembersChangeWorkArrangementPageTestProps = React.ComponentProps & { + policy: Policy; + personalDetails: PersonalDetailsList; +}; + +describe('WorkspaceMembersChangeWorkArrangementPage', () => { + const policyID = 'policy123'; + const member1AccountID = 1; + const member2AccountID = 2; + const member1Login = 'member1@example.com'; + const member2Login = 'member2@example.com'; + const personalDetails = {[member1AccountID]: {login: member1Login}, [member2AccountID]: {login: member2Login}}; + const getSelectorProps = () => jest.mocked(WorkArrangementSelector).mock.lastCall?.[0] as MockWorkArrangementSelectorProps | undefined; + const getConfirmButtonProps = () => jest.mocked(Button).mock.lastCall?.[0] as MockButtonProps | undefined; + + beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(useOnyx).mockReturnValue([[member1AccountID, member2AccountID], {status: 'loaded'}]); + }); + + const renderPage = (member1IsOffice?: boolean, member2IsOffice?: boolean) => { + const policy = { + id: policyID, + commuterExclusions: {method: CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE, isOfficeWorkArrangement: false}, + employeeList: { + [member1Login]: {email: member1Login, hasOfficeWorkArrangement: member1IsOffice}, + [member2Login]: {email: member2Login, hasOfficeWorkArrangement: member2IsOffice}, + }, + }; + const props = createMock({ + policy: createMock(policy), + personalDetails, + route: {params: {policyID}}, + }); + // The HOC mock exposes the wrapped screen directly, which accepts the policy prop that the production HOC normally injects. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- the test HOC mock returns the unwrapped component with its injected policy props restored + const UnwrappedPage = WorkspaceMembersChangeWorkArrangementPage as React.ComponentType; + render(); + return props.policy; + }; + + it('selects the arrangement the selected members share', () => { + // Given a remote member and one following the remote workspace default + renderPage(false); + + // When the page shows the arrangements + // Then their shared arrangement is selected and can be confirmed + expect(getSelectorProps()?.isOffice).toBe(false); + expect(getConfirmButtonProps()?.isDisabled).toBe(false); + }); + + it('selects nothing while the selected members have different arrangements', () => { + // Given an office-based member and a remote one + renderPage(true, false); + + // When the page shows the arrangements + // Then no arrangement is selected and there is nothing to confirm yet + expect(getSelectorProps()?.isOffice).toBeUndefined(); + expect(getConfirmButtonProps()?.isDisabled).toBe(true); + }); + + it('applies the chosen arrangement to every selected member and hands back the selection once the page closes', () => { + // Given an office-based member and a remote one + const policy = renderPage(true, false); + + // When office-based is chosen and confirmed + act(() => { + getSelectorProps()?.onSelect(true); + }); + act(() => { + getConfirmButtonProps()?.onPress?.(); + }); + + // Then both members are made office-based, and the selection is cleared after returning to the members + expect(setEmployeeWorkArrangement).toHaveBeenCalledWith(policy, [member1AccountID, member2AccountID], true, personalDetails, expect.any(Function)); + expect(Navigation.goBack).toHaveBeenCalledWith(ROUTES.WORKSPACE_MEMBERS.getRoute(policyID), {afterTransition: clearWorkspaceMembersSelectedForBulkEdit}); + }); +}); diff --git a/tests/ui/WorkspaceMembersTest.tsx b/tests/ui/WorkspaceMembersTest.tsx index 3c06fa8976eb..53b2ff47d89d 100644 --- a/tests/ui/WorkspaceMembersTest.tsx +++ b/tests/ui/WorkspaceMembersTest.tsx @@ -11,6 +11,7 @@ import {CurrentReportIDContextProvider} from '@hooks/useCurrentReportID'; import * as useResponsiveLayoutModule from '@hooks/useResponsiveLayout'; import type ResponsiveLayoutResult from '@hooks/useResponsiveLayout/types'; +import Navigation from '@libs/Navigation/Navigation'; import createPlatformStackNavigator from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator'; import type {WorkspaceSplitNavigatorParamList} from '@navigation/types'; @@ -19,7 +20,9 @@ import WorkspaceMembersPage from '@pages/workspace/WorkspaceMembersPage'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; +import type {OfficeLocation} from '@src/types/onyx/Policy'; import {PortalProvider} from '@gorhom/portal'; import {NavigationContainer} from '@react-navigation/native'; @@ -27,6 +30,7 @@ import React from 'react'; import Onyx from 'react-native-onyx'; import createMock from '../utils/createMock'; +import getOnyxValue from '../utils/getOnyxValue'; import * as LHNTestUtils from '../utils/LHNTestUtils'; import * as TestHelper from '../utils/TestHelper'; import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; @@ -731,4 +735,128 @@ describe('WorkspaceMembers', () => { unmount(); }); }); + + describe('Office locations and work arrangements', () => { + const officeLocations: Record = { + OFFICE1: {name: 'Headquarters', address: {addressStreet: '88 Kearny St', city: 'San Francisco', state: 'CA', zipCode: '94108', country: 'US'}, isDefault: true}, + OFFICE2: {name: 'New Jersey Office', address: {addressStreet: '900 Asbury Ave', city: 'Ocean City', state: 'NJ', zipCode: '08226', country: 'US'}}, + }; + const officeLocationsPolicy = { + ...policy, + officeLocations, + commuterExclusions: {method: CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE}, + employeeList: { + ...policy.employeeList, + [userEmail]: {email: userEmail, role: CONST.POLICY.ROLE.USER, officeID: 'OFFICE2'}, + }, + }; + + const openBulkActionsForMember = async () => { + await screen.findByText(USER_OPTION); + selectCheckboxByMemberName('Member'); + fireEvent.press(await screen.findByTestId('WorkspaceMembersPage-header-dropdown-menu-button')); + await waitForBatchedUpdatesWithAct(); + }; + + beforeEach(async () => { + await act(async () => { + await Onyx.set(ONYXKEYS.BETAS, [CONST.BETAS.COMMUTER_EXCLUSIONS, CONST.BETAS.COMMUTER_EXCLUSIONS_ARRANGEMENTS]); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, officeLocationsPolicy); + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should show the office of each member, and the primary office for members without one', async () => { + // Given a workspace with offices where only one member is assigned to an office + const {unmount} = renderPage(SCREENS.WORKSPACE.MEMBERS, {policyID: policy.id}); + await waitForBatchedUpdatesWithAct(); + await screen.findByText(USER_OPTION); + + // Then the office location column shows the assigned office, and the primary office for everyone else + expect(screen.getByText(TestHelper.translateLocal('workspace.people.officeLocation'))).toBeOnTheScreen(); + expect(screen.getByText('New Jersey Office')).toBeOnTheScreen(); + expect(screen.getAllByText('Headquarters')).toHaveLength(Object.keys(policy.employeeList).length - 1); + + unmount(); + await waitForBatchedUpdatesWithAct(); + }); + + it('should hand the selected members to the office location picker', async () => { + // Given an admin who selected a member + const navigateSpy = jest.spyOn(Navigation, 'navigate').mockImplementation(() => {}); + const {unmount} = renderPage(SCREENS.WORKSPACE.MEMBERS, {policyID: policy.id}); + await waitForBatchedUpdatesWithAct(); + await openBulkActionsForMember(); + + // When they choose to change the office location + expect(screen.getByText(TestHelper.translateLocal('workspace.people.changeWorkArrangement'))).toBeOnTheScreen(); + const changeOfficeLocationItem = screen.getByText(TestHelper.translateLocal('workspace.people.changeOfficeLocation')); + fireEvent.press(changeOfficeLocationItem, { + nativeEvent: {}, + type: 'press', + target: changeOfficeLocationItem, + currentTarget: changeOfficeLocationItem, + }); + await waitForBatchedUpdatesWithAct(); + + // Then the office location picker opens for that member + await waitFor(() => { + expect(navigateSpy).toHaveBeenCalledWith(ROUTES.WORKSPACE_MEMBERS_CHANGE_OFFICE_LOCATION.getRoute(policy.id)); + }); + expect(await getOnyxValue(ONYXKEYS.RAM_ONLY_WORKSPACE_MEMBERS_SELECTED_FOR_BULK_EDIT)).toEqual([userAccountID]); + + unmount(); + await waitForBatchedUpdatesWithAct(); + }); + + it('should clear the selection once a bulk change has been applied', async () => { + // Given an admin who selected a member and opened a bulk change for them + const {unmount} = renderPage(SCREENS.WORKSPACE.MEMBERS, {policyID: policy.id}); + await waitForBatchedUpdatesWithAct(); + await screen.findByText(USER_OPTION); + selectCheckboxByMemberName('Member'); + await act(async () => { + await Onyx.set(ONYXKEYS.RAM_ONLY_WORKSPACE_MEMBERS_SELECTED_FOR_BULK_EDIT, [userAccountID]); + }); + expect(screen.getByTestId('WorkspaceMembersPage-header-dropdown-menu-button')).toBeOnTheScreen(); + + // When the bulk change is applied, which empties the members handed to it + await act(async () => { + await Onyx.set(ONYXKEYS.RAM_ONLY_WORKSPACE_MEMBERS_SELECTED_FOR_BULK_EDIT, []); + }); + + // Then the members page drops its selection too + await waitFor(() => { + expect(screen.queryByTestId('WorkspaceMembersPage-header-dropdown-menu-button')).not.toBeOnTheScreen(); + }); + + unmount(); + await waitForBatchedUpdatesWithAct(); + }); + + it('should not offer office location or work arrangement changes to People Admins', async () => { + // Given a People Admin, who manages members but cannot change their offices or work arrangements + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, { + role: CONST.POLICY.ROLE.PEOPLE_ADMIN, + employeeList: {[selfEmail]: {email: selfEmail, role: CONST.POLICY.ROLE.PEOPLE_ADMIN}}, + }); + }); + const {unmount} = renderPage(SCREENS.WORKSPACE.MEMBERS, {policyID: policy.id}); + await waitForBatchedUpdatesWithAct(); + + // When they open the bulk actions for a member + await openBulkActionsForMember(); + + // Then neither change is offered + expect(screen.queryByText(TestHelper.translateLocal('workspace.people.changeOfficeLocation'))).not.toBeOnTheScreen(); + expect(screen.queryByText(TestHelper.translateLocal('workspace.people.changeWorkArrangement'))).not.toBeOnTheScreen(); + + unmount(); + await waitForBatchedUpdatesWithAct(); + }); + }); }); diff --git a/tests/unit/DistanceRateTest.ts b/tests/unit/DistanceRateTest.ts index f25bc3200e03..eeac42261507 100644 --- a/tests/unit/DistanceRateTest.ts +++ b/tests/unit/DistanceRateTest.ts @@ -1,19 +1,27 @@ import type {LocalizedTranslate} from '@components/LocaleContextProvider'; -import {deletePolicyDistanceRates, enablePolicyDistanceRates, setEmployeeWorkArrangement, setWorkspaceDistanceAutoUpdate} from '@libs/actions/Policy/DistanceRate'; +import { + deletePolicyDistanceRates, + enablePolicyDistanceRates, + setEmployeeWorkArrangement, + setWorkspaceDistanceAutoUpdate, + updateEmployeeOfficeLocation, +} from '@libs/actions/Policy/DistanceRate'; import * as API from '@libs/API'; import {pause, resetQueue} from '@libs/Network/SequentialQueue'; import {isGovernmentRateUnmodified} from '@libs/PolicyDistanceRatesUtils'; import CONST from '@src/CONST'; +import IntlStore from '@src/languages/IntlStore'; import ONYXKEYS from '@src/ONYXKEYS'; import type {GovernmentMileageRate, Policy, Report, ReportActions, Transaction, TransactionViolations} from '@src/types/onyx'; -import type {CustomUnit, Rate, Unit} from '@src/types/onyx/Policy'; +import type {CustomUnit, OfficeLocation, Rate, Unit} from '@src/types/onyx/Policy'; import Onyx from 'react-native-onyx'; import createRandomPolicy from '../utils/collections/policies'; import createRandomTransaction from '../utils/collections/transaction'; +import {translateLocal} from '../utils/TestHelper'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; const translate: LocalizedTranslate = (phrase, ...parameters) => { @@ -39,6 +47,44 @@ describe('DistanceRate', () => { return waitForBatchedUpdates(); }); + function getPolicyFromOnyx(policyID: string): Promise { + return new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}` as const, + // eslint-disable-next-line rulesdir/prefer-early-return + callback: (value) => { + if (value !== undefined) { + Onyx.disconnect(connection); + resolve(value); + } + }, + }); + }); + } + + function getReportActionsFromOnyx(reportID: string) { + return new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}` as const, + // eslint-disable-next-line rulesdir/prefer-early-return + callback: (value) => { + if (value !== undefined) { + Onyx.disconnect(connection); + resolve(value); + } + }, + }); + }); + } + + function getSingleUpdateValue(value: unknown): unknown { + if (typeof value !== 'object' || value === null) { + return undefined; + } + const values = Object.values(value); + return values.length === 1 ? values.at(0) : undefined; + } + describe('deletePolicyDistanceRates', () => { it('should set customUnitOutOfPolicy violation only for transactions that have the deleted custom unit rate', async () => { const customUnitID = '5A55C2B68DDCB'; @@ -246,21 +292,6 @@ describe('DistanceRate', () => { return distanceCustomUnit; } - function getPolicyFromOnyx(policyID: string): Promise { - return new Promise((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}` as const, - // eslint-disable-next-line rulesdir/prefer-early-return - callback: (value) => { - if (value !== undefined) { - Onyx.disconnect(connection); - resolve(value); - } - }, - }); - }); - } - afterEach(() => { resetQueue(); return Onyx.clear(); @@ -400,44 +431,6 @@ describe('DistanceRate', () => { jest.restoreAllMocks(); }); - function getPolicyFromOnyx(policyID: string): Promise { - return new Promise((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}` as const, - // eslint-disable-next-line rulesdir/prefer-early-return - callback: (value) => { - if (value !== undefined) { - Onyx.disconnect(connection); - resolve(value); - } - }, - }); - }); - } - - function getReportActionsFromOnyx(reportID: string) { - return new Promise((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}` as const, - // eslint-disable-next-line rulesdir/prefer-early-return - callback: (value) => { - if (value !== undefined) { - Onyx.disconnect(connection); - resolve(value); - } - }, - }); - }); - } - - function getSingleUpdateValue(value: unknown): unknown { - if (typeof value !== 'object' || value === null) { - return undefined; - } - const values = Object.values(value); - return values.length === 1 ? values.at(0) : undefined; - } - async function seedWorkArrangementPolicy(policy: Policy) { await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); await Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, personalDetails); @@ -676,4 +669,153 @@ describe('DistanceRate', () => { resetQueue(); }); }); + + describe('updateEmployeeOfficeLocation', () => { + const member1AccountID = 31; + const member2AccountID = 32; + const member1Email = 'office1@test.com'; + const member2Email = 'office2@test.com'; + const personalDetails = { + [member1AccountID]: {accountID: member1AccountID, login: member1Email, displayName: 'Member One'}, + [member2AccountID]: {accountID: member2AccountID, login: member2Email, displayName: 'Member Two'}, + }; + const officeLocations: Record = { + OFFICE1: {name: 'Headquarters', address: {addressStreet: '88 Kearny St', city: 'San Francisco', state: 'CA', zipCode: '94108', country: 'US'}, isDefault: true}, + OFFICE2: {name: 'New Jersey Office', address: {addressStreet: '900 Asbury Ave', city: 'Ocean City', state: 'NJ', zipCode: '08226', country: 'US'}}, + }; + + beforeAll(() => IntlStore.load(CONST.LOCALES.DEFAULT)); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + async function seedOfficeLocationPolicy(policy: Policy) { + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); + await Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, personalDetails); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}2`, {reportID: '2', policyID: policy.id, chatType: CONST.REPORT.CHAT_TYPE.POLICY_ADMINS} as Report); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}2`, {}); + await waitForBatchedUpdates(); + } + + it('should optimistically assign the members and log one pending change per member that changed', async () => { + // Given one member following the primary office and one already assigned to the New Jersey office + const policy: Policy = { + ...createRandomPolicy(30), + officeLocations, + employeeList: { + [member1Email]: {email: member1Email}, + [member2Email]: {email: member2Email, officeID: 'OFFICE2'}, + }, + }; + await seedOfficeLocationPolicy(policy); + + pause(); + // When both members are assigned to the New Jersey office + updateEmployeeOfficeLocation(policy, [member1AccountID, member2AccountID], 'OFFICE2', personalDetails, translateLocal); + await waitForBatchedUpdates(); + + // Then only the member who changed is updated, and gets one pending change log naming the offices + const onyxPolicy = await getPolicyFromOnyx(policy.id); + expect(onyxPolicy.employeeList?.[member1Email]).toEqual({email: member1Email, officeID: 'OFFICE2', pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}); + expect(onyxPolicy.employeeList?.[member2Email]).toEqual({email: member2Email, officeID: 'OFFICE2'}); + + const actions = Object.values(await getReportActionsFromOnyx('2')); + expect(actions).toHaveLength(1); + expect(actions.at(0)).toMatchObject({ + actionName: CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_EMPLOYEE_OFFICE_LOCATION, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + message: [{text: `changed Member One's office location to "New Jersey Office" (previously the primary office)`}], + originalMessage: {accountID: member1AccountID, email: member1Email, name: 'Member One', officeID: 'OFFICE2', officeName: 'New Jersey Office'}, + }); + + resetQueue(); + }); + + it('should send every member that changed in one request, by accountID and by login', async () => { + // Given two members following the primary office + const policy: Policy = { + ...createRandomPolicy(31), + officeLocations, + employeeList: {[member1Email]: {email: member1Email}, [member2Email]: {email: member2Email}}, + }; + await seedOfficeLocationPolicy(policy); + // eslint-disable-next-line rulesdir/no-multiple-api-calls -- The API spy records the request, which the paused queue keeps from being sent. + const writeSpy = jest.spyOn(API, 'write'); + + pause(); + // When both are assigned to the company address + updateEmployeeOfficeLocation(policy, [member1AccountID, member2AccountID], CONST.POLICY.COMPANY_ADDRESS_OFFICE_ID, personalDetails, translateLocal); + await waitForBatchedUpdates(); + + // Then one request carries both members, so the logins still match them if an offline invite replaces their accountIDs + expect(writeSpy).toHaveBeenCalledTimes(1); + expect(writeSpy.mock.calls.at(0)?.[1]).toEqual({ + policyID: policy.id, + employeeAccountIDList: `${member1AccountID},${member2AccountID}`, + employeeLoginList: `${member1Email},${member2Email}`, + officeID: CONST.POLICY.COMPANY_ADDRESS_OFFICE_ID, + }); + + // And each member gets a change log + expect(Object.values(await getReportActionsFromOnyx('2'))).toHaveLength(2); + + resetQueue(); + }); + + it('should clear pending states on success', async () => { + // Given a member following the primary office + const policy: Policy = { + ...createRandomPolicy(32), + officeLocations, + employeeList: {[member1Email]: {email: member1Email}}, + }; + await seedOfficeLocationPolicy(policy); + // eslint-disable-next-line rulesdir/no-multiple-api-calls -- The API spy captures the action updates without sending a request. + const writeSpy = jest.spyOn(API, 'write').mockResolvedValue(undefined); + + // When the member is assigned to an office + updateEmployeeOfficeLocation(policy, [member1AccountID], 'OFFICE2', personalDetails, translateLocal); + + // Then success clears the pending state of both the member and the change log + const onyxData = writeSpy.mock.calls.at(0)?.[2]; + expect(onyxData?.successData?.[0]).toEqual({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, + value: {employeeList: {[member1Email]: {pendingAction: null}}}, + }); + expect(getSingleUpdateValue(onyxData?.successData?.[1]?.value)).toEqual({pendingAction: null}); + }); + + it('should restore the previous office of each member and drop the change logs on failure', async () => { + // Given one member assigned to the New Jersey office and one following the primary office + const policy: Policy = { + ...createRandomPolicy(33), + officeLocations, + employeeList: {[member1Email]: {email: member1Email, officeID: 'OFFICE2'}, [member2Email]: {email: member2Email}}, + }; + await seedOfficeLocationPolicy(policy); + // eslint-disable-next-line rulesdir/no-multiple-api-calls -- The API spy captures the action updates without sending a request. + const writeSpy = jest.spyOn(API, 'write').mockResolvedValue(undefined); + + // When both are assigned to the company address + updateEmployeeOfficeLocation(policy, [member1AccountID, member2AccountID], CONST.POLICY.COMPANY_ADDRESS_OFFICE_ID, personalDetails, translateLocal); + + // Then failure puts back the New Jersey office, removes the assignment the other member never had, and keeps + // UPDATE so dismissing the error keeps the members + const onyxData = writeSpy.mock.calls.at(0)?.[2]; + expect(onyxData?.failureData?.[0]).toMatchObject({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, + value: { + employeeList: { + [member1Email]: {officeID: 'OFFICE2', pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, + [member2Email]: {officeID: null, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, + }, + }, + }); + const failureReportActions: unknown = onyxData?.failureData?.[1]?.value; + expect(typeof failureReportActions === 'object' && failureReportActions !== null ? Object.values(failureReportActions) : undefined).toEqual([null, null]); + }); + }); }); diff --git a/tests/unit/OfficeLocationUtilsTest.ts b/tests/unit/OfficeLocationUtilsTest.ts new file mode 100644 index 000000000000..565f599aeb90 --- /dev/null +++ b/tests/unit/OfficeLocationUtilsTest.ts @@ -0,0 +1,105 @@ +import {getAssignableOfficeLocations, getMemberOfficeLocation, hasOfficeLocations} from '@libs/OfficeLocationUtils'; + +import CONST from '@src/CONST'; +import IntlStore from '@src/languages/IntlStore'; +import type {Policy} from '@src/types/onyx'; +import type {CompanyAddress, OfficeLocation} from '@src/types/onyx/Policy'; + +import createRandomPolicy from '../utils/collections/policies'; +import {translateLocal} from '../utils/TestHelper'; + +const companyAddress: CompanyAddress = {addressStreet: '1 Market St', city: 'San Francisco', state: 'CA', zipCode: '94105', country: 'US'}; +const headquarters: OfficeLocation = {name: 'Headquarters', address: {addressStreet: '88 Kearny St', city: 'San Francisco', state: 'CA', zipCode: '94108', country: 'US'}}; +const newJerseyOffice: OfficeLocation = {name: 'New Jersey Office', address: {addressStreet: '900 Asbury Ave', city: 'Ocean City', state: 'NJ', zipCode: '08226', country: 'US'}}; +const austinOffice: OfficeLocation = {name: 'Austin Office', address: {addressStreet: '500 Congress Ave', city: 'Austin', state: 'TX', zipCode: '78701', country: 'US'}}; + +const localeCompare = (a: string, b: string) => a.localeCompare(b); + +function buildPolicy(policy: Partial): Policy { + return {...createRandomPolicy(1), address: undefined, officeLocations: undefined, ...policy}; +} + +describe('OfficeLocationUtils', () => { + beforeAll(() => IntlStore.load(CONST.LOCALES.DEFAULT)); + + describe('hasOfficeLocations', () => { + it('only counts offices that are not being deleted', () => { + // Given workspaces with no offices, with an office pending deletion, and with an office + const withoutOffices = buildPolicy({address: companyAddress}); + const withDeletedOffice = buildPolicy({officeLocations: {OFFICE1: {...headquarters, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}}}); + const withOffice = buildPolicy({officeLocations: {OFFICE1: headquarters}}); + + // When checking whether members can be assigned to an office + // Then only the workspace with a remaining office qualifies, since the company address alone leaves no choice + expect(hasOfficeLocations(withoutOffices)).toBe(false); + expect(hasOfficeLocations(withDeletedOffice)).toBe(false); + expect(hasOfficeLocations(withOffice)).toBe(true); + }); + }); + + describe('getAssignableOfficeLocations', () => { + it('lists the company address first, then the primary office and the rest by name', () => { + // Given a workspace with a company address and three offices, one of them primary and one being deleted + const policy = buildPolicy({ + address: companyAddress, + officeLocations: { + OFFICE1: newJerseyOffice, + OFFICE2: {...headquarters, isDefault: true}, + OFFICE3: austinOffice, + OFFICE4: {...austinOffice, name: 'Closed Office', pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}, + }, + }); + + // When the offices members can be assigned to are listed + const officeLocations = getAssignableOfficeLocations(policy, translateLocal, localeCompare); + + // Then the company address leads under its own officeID, and only the flagged office is primary + expect(officeLocations).toEqual([ + {officeID: CONST.POLICY.COMPANY_ADDRESS_OFFICE_ID, name: 'Company address', address: '1 Market St, San Francisco, CA 94105', isPrimary: false}, + {officeID: 'OFFICE2', name: 'Headquarters', address: '88 Kearny St, San Francisco, CA 94108', isPrimary: true}, + {officeID: 'OFFICE3', name: 'Austin Office', address: '500 Congress Ave, Austin, TX 78701', isPrimary: false}, + {officeID: 'OFFICE1', name: 'New Jersey Office', address: '900 Asbury Ave, Ocean City, NJ 08226', isPrimary: false}, + ]); + }); + + it('makes the company address primary while no office is', () => { + // Given a workspace with a company address and no primary office + const policy = buildPolicy({address: companyAddress, officeLocations: {OFFICE1: headquarters}}); + + // When the offices members can be assigned to are listed + const officeLocations = getAssignableOfficeLocations(policy, translateLocal, localeCompare); + + // Then the company address is the primary office + expect(officeLocations.map(({officeID, isPrimary}) => ({officeID, isPrimary}))).toEqual([ + {officeID: CONST.POLICY.COMPANY_ADDRESS_OFFICE_ID, isPrimary: true}, + {officeID: 'OFFICE1', isPrimary: false}, + ]); + }); + + it('leaves out the company address when the workspace has none', () => { + // Given a workspace without a company address + const policy = buildPolicy({officeLocations: {OFFICE1: {...headquarters, isDefault: true}}}); + + // When the offices members can be assigned to are listed + // Then only the offices are listed + expect(getAssignableOfficeLocations(policy, translateLocal, localeCompare).map(({officeID}) => officeID)).toEqual(['OFFICE1']); + }); + }); + + describe('getMemberOfficeLocation', () => { + const policy = buildPolicy({address: companyAddress, officeLocations: {OFFICE1: {...headquarters, isDefault: true}, OFFICE2: newJerseyOffice}}); + const officeLocations = getAssignableOfficeLocations(policy, translateLocal, localeCompare); + + it.each([ + ['the office assigned to the member', 'OFFICE2', 'OFFICE2'], + ['the company address when the member is assigned to it', CONST.POLICY.COMPANY_ADDRESS_OFFICE_ID, CONST.POLICY.COMPANY_ADDRESS_OFFICE_ID], + ['the primary office when the member has no office', undefined, 'OFFICE1'], + ['the primary office when the assigned office was removed', 'OFFICE9', 'OFFICE1'], + ])('returns %s', (_description, assignedOfficeID, expectedOfficeID) => { + // Given a member with the assigned officeID + // When their office is resolved + // Then it is the expected office + expect(getMemberOfficeLocation(officeLocations, assignedOfficeID)?.officeID).toBe(expectedOfficeID); + }); + }); +}); diff --git a/tests/unit/ReportActionsUtilsTest.ts b/tests/unit/ReportActionsUtilsTest.ts index b6799c14ba70..df02fe76a1aa 100644 --- a/tests/unit/ReportActionsUtilsTest.ts +++ b/tests/unit/ReportActionsUtilsTest.ts @@ -44,6 +44,7 @@ import { getCurrencyDefaultTaxUpdateMessage, getCustomTaxNameUpdateMessage, getDeletedOfficeLocationMessage, + getUpdatedEmployeeOfficeLocationMessage, getForeignCurrencyDefaultTaxUpdateMessage, getHumanAgentAccountIDFromReportAction, getHumanAgentFirstName, @@ -4777,6 +4778,41 @@ describe('ReportActionsUtils', () => { // Then it reads the same as the message Auth stores for it expect(getDeletedOfficeLocationMessage(translateLocal, action)).toBe('removed the office location "New Jersey Office"'); }); + + it.each([ + [ + 'an office assigned to a member who followed the primary office', + {name: 'Bob Smith', email: 'bob@smith.com', officeID: 'OFFICE2', officeName: 'New Jersey Office', oldOfficeID: '', oldOfficeName: ''}, + `changed Bob Smith's office location to "New Jersey Office" (previously the primary office)`, + ], + [ + 'a member moved from the company address to an office', + {name: 'Bob Smith', email: 'bob@smith.com', officeID: 'OFFICE1', officeName: 'Headquarters', oldOfficeID: CONST.POLICY.COMPANY_ADDRESS_OFFICE_ID, oldOfficeName: ''}, + `changed Bob Smith's office location to "Headquarters" (previously the company address)`, + ], + [ + 'a cleared assignment', + {name: 'Bob Smith', email: 'bob@smith.com', officeID: '', officeName: '', oldOfficeID: 'OFFICE1', oldOfficeName: 'Headquarters'}, + `changed Bob Smith's office location to the primary office (previously "Headquarters")`, + ], + [ + 'a member without a name moved off an office that was removed since', + {email: 'bob@smith.com', officeID: CONST.POLICY.COMPANY_ADDRESS_OFFICE_ID, officeName: '', oldOfficeID: 'OFFICE9', oldOfficeName: ''}, + `changed bob@smith.com's office location to the company address (previously the primary office)`, + ], + ])('describes %s', (_description, originalMessage, expected) => { + // Given a member whose office assignment changed + const action: ReportAction = { + actionName: CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_EMPLOYEE_OFFICE_LOCATION, + reportActionID: '1', + created: '', + originalMessage: {accountID: 1, ...originalMessage}, + }; + + // When the change log is rendered + // Then it reads the same as the message Auth stores for it + expect(getUpdatedEmployeeOfficeLocationMessage(translateLocal, action)).toBe(expected); + }); }); describe('getUpdatedCardFeedLiabilityMessage', () => { From e660b23fb83459b2a31ad47cc9fa40f25ac5ec41 Mon Sep 17 00:00:00 2001 From: alberto Date: Thu, 8 Oct 2026 15:41:40 +0200 Subject: [PATCH 02/19] fix display --- src/libs/OfficeLocationUtils.ts | 5 ++-- tests/ui/WorkspaceMemberDetailsPageTest.tsx | 31 ++++++++++++++++++--- tests/ui/WorkspaceMembersTest.tsx | 21 ++++++++++++++ tests/unit/OfficeLocationUtilsTest.ts | 10 ++++--- 4 files changed, 57 insertions(+), 10 deletions(-) diff --git a/src/libs/OfficeLocationUtils.ts b/src/libs/OfficeLocationUtils.ts index 076d9113d163..ac76a93c0862 100644 --- a/src/libs/OfficeLocationUtils.ts +++ b/src/libs/OfficeLocationUtils.ts @@ -23,10 +23,11 @@ type AssignableOfficeLocation = { }; /** - * Whether the workspace has offices besides its company address, which is when its members can be assigned to one + * Whether the workspace has an office its members can be assigned to. The company address counts as one, as it does on + * the workspace overview. */ function hasOfficeLocations(policy: OnyxEntry): boolean { - return Object.values(policy?.officeLocations ?? {}).some((officeLocation) => officeLocation.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); + return hasCompanyAddress(policy) || Object.values(policy?.officeLocations ?? {}).some((officeLocation) => officeLocation.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); } /** diff --git a/tests/ui/WorkspaceMemberDetailsPageTest.tsx b/tests/ui/WorkspaceMemberDetailsPageTest.tsx index 79fa1bdac35a..623e38b0f3f9 100644 --- a/tests/ui/WorkspaceMemberDetailsPageTest.tsx +++ b/tests/ui/WorkspaceMemberDetailsPageTest.tsx @@ -237,7 +237,17 @@ describe('WorkspaceMemberDetailsPage', () => { await waitForBatchedUpdatesWithAct(); }); - const setupOfficeLocations = async ({memberOfficeID, hasOffices = true, betaEnabled = true}: {memberOfficeID?: string; hasOffices?: boolean; betaEnabled?: boolean}) => { + const setupOfficeLocations = async ({ + memberOfficeID, + hasOffices = true, + hasCompanyAddress = true, + betaEnabled = true, + }: { + memberOfficeID?: string; + hasOffices?: boolean; + hasCompanyAddress?: boolean; + betaEnabled?: boolean; + }) => { const typedPolicy = createMock(policy); const employeeList = { ...typedPolicy.employeeList, @@ -250,7 +260,7 @@ describe('WorkspaceMemberDetailsPage', () => { const companyAddress: CompanyAddress = {addressStreet: '1 Market St', city: 'San Francisco', state: 'CA', zipCode: '94105', country: 'US'}; const officeLocationsPolicy = createMock({ ...typedPolicy, - address: companyAddress, + address: hasCompanyAddress ? companyAddress : undefined, officeLocations: hasOffices ? officeLocations : undefined, employeeList, }); @@ -278,11 +288,24 @@ describe('WorkspaceMemberDetailsPage', () => { await waitForBatchedUpdatesWithAct(); }); - it('hides the office location item when the workspace has no offices besides its company address', async () => { + it('shows the company address as the office location when the workspace has no other office', async () => { // Given a workspace whose only office is its company address const unmount = await setupOfficeLocations({hasOffices: false}); - // Then there is nothing to choose between, so the office location item is hidden + // Then the office location item shows the company address, which counts as an office as it does on the workspace overview + const row = await screen.findByTestId('member-office-location-menu-item'); + expect( + within(row).getByText(TestHelper.translateLocal('workspace.people.officeLocationWithAddress', {name: 'Company address', address: '1 Market St, San Francisco, CA 94105'})), + ).toBeOnTheScreen(); + unmount(); + await waitForBatchedUpdatesWithAct(); + }); + + it('hides the office location item when the workspace has neither an office nor a company address', async () => { + // Given a workspace without any office to assign members to + const unmount = await setupOfficeLocations({hasOffices: false, hasCompanyAddress: false}); + + // Then the office location item is hidden expect(screen.queryByTestId('member-office-location-menu-item')).not.toBeOnTheScreen(); unmount(); await waitForBatchedUpdatesWithAct(); diff --git a/tests/ui/WorkspaceMembersTest.tsx b/tests/ui/WorkspaceMembersTest.tsx index 917776f7483d..2994e6b36306 100644 --- a/tests/ui/WorkspaceMembersTest.tsx +++ b/tests/ui/WorkspaceMembersTest.tsx @@ -784,6 +784,27 @@ describe('WorkspaceMembers', () => { await waitForBatchedUpdatesWithAct(); }); + it('should show the company address for every member when the workspace has no other office', async () => { + // Given a workspace whose only office is its company address + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, { + address: {addressStreet: '1 Market St', city: 'San Francisco', state: 'CA', zipCode: '94105', country: 'US'}, + officeLocations: null, + employeeList: {[userEmail]: {officeID: null}}, + }); + }); + const {unmount} = renderPage(SCREENS.WORKSPACE.MEMBERS, {policyID: policy.id}); + await waitForBatchedUpdatesWithAct(); + await screen.findByText(USER_OPTION); + + // Then the office location column shows the company address for every member, as it counts as an office + expect(screen.getByText(TestHelper.translateLocal('workspace.people.officeLocation'))).toBeOnTheScreen(); + expect(screen.getAllByText(TestHelper.translateLocal('common.companyAddress'))).toHaveLength(Object.keys(policy.employeeList).length); + + unmount(); + await waitForBatchedUpdatesWithAct(); + }); + it('should hand the selected members to the office location picker', async () => { // Given an admin who selected a member const navigateSpy = jest.spyOn(Navigation, 'navigate').mockImplementation(() => {}); diff --git a/tests/unit/OfficeLocationUtilsTest.ts b/tests/unit/OfficeLocationUtilsTest.ts index 565f599aeb90..026974a27776 100644 --- a/tests/unit/OfficeLocationUtilsTest.ts +++ b/tests/unit/OfficeLocationUtilsTest.ts @@ -23,16 +23,18 @@ describe('OfficeLocationUtils', () => { beforeAll(() => IntlStore.load(CONST.LOCALES.DEFAULT)); describe('hasOfficeLocations', () => { - it('only counts offices that are not being deleted', () => { - // Given workspaces with no offices, with an office pending deletion, and with an office - const withoutOffices = buildPolicy({address: companyAddress}); + it('counts the company address and the offices that are not being deleted', () => { + // Given workspaces with no office at all, with only an office pending deletion, with only a company address, and with an office + const withoutOffices = buildPolicy({}); const withDeletedOffice = buildPolicy({officeLocations: {OFFICE1: {...headquarters, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}}}); + const withCompanyAddress = buildPolicy({address: companyAddress}); const withOffice = buildPolicy({officeLocations: {OFFICE1: headquarters}}); // When checking whether members can be assigned to an office - // Then only the workspace with a remaining office qualifies, since the company address alone leaves no choice + // Then the company address counts as an office, the same as on the workspace overview expect(hasOfficeLocations(withoutOffices)).toBe(false); expect(hasOfficeLocations(withDeletedOffice)).toBe(false); + expect(hasOfficeLocations(withCompanyAddress)).toBe(true); expect(hasOfficeLocations(withOffice)).toBe(true); }); }); From 574305462cafb3262a38734cbc910b4a4abaa661 Mon Sep 17 00:00:00 2001 From: alberto Date: Fri, 9 Oct 2026 20:03:22 +0200 Subject: [PATCH 03/19] simplify --- .../API/parameters/UpdateEmployeeOfficeLocationParams.ts | 8 ++++---- src/libs/actions/Policy/DistanceRate.ts | 5 ++--- tests/unit/DistanceRateTest.ts | 5 ++--- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/src/libs/API/parameters/UpdateEmployeeOfficeLocationParams.ts b/src/libs/API/parameters/UpdateEmployeeOfficeLocationParams.ts index 2b6ba060a2d6..ab59403b7733 100644 --- a/src/libs/API/parameters/UpdateEmployeeOfficeLocationParams.ts +++ b/src/libs/API/parameters/UpdateEmployeeOfficeLocationParams.ts @@ -1,10 +1,10 @@ type UpdateEmployeeOfficeLocationParams = { policyID: string; - /** Comma-separated accountIDs of the employees to update. Supports individual and bulk updates. */ - employeeAccountIDList: string; - - /** Comma-separated logins of the same employees, which still match them once an offline invite replaces their accountIDs */ + /** + * Comma-separated logins of the employees to update. Supports individual and bulk updates. Logins stay the same when + * an offline invite replaces a member's temporary accountID before the request runs. + */ employeeLoginList: string; /** Office to assign, keyed in the workspace's officeLocations, or CONST.POLICY.COMPANY_ADDRESS_OFFICE_ID for the company address */ diff --git a/src/libs/actions/Policy/DistanceRate.ts b/src/libs/actions/Policy/DistanceRate.ts index f5c398224b47..67ecae7da7a8 100644 --- a/src/libs/actions/Policy/DistanceRate.ts +++ b/src/libs/actions/Policy/DistanceRate.ts @@ -863,8 +863,8 @@ type OfficeLocationMemberUpdate = { /** * Assign one or more workspace members to an office, keyed in the workspace's officeLocations, or to the company * address with CONST.POLICY.COMPANY_ADDRESS_OFFICE_ID. The single command covers both individual and bulk updates: - * employeeAccountIDList carries every affected accountID, and one optimistic member office location changelog action is - * created per member in the workspace admins room. + * employeeLoginList carries the login of every affected member, and one optimistic member office location changelog + * action is created per member in the workspace admins room. */ function updateEmployeeOfficeLocation( policy: OnyxEntry, @@ -984,7 +984,6 @@ function updateEmployeeOfficeLocation( const parameters: UpdateEmployeeOfficeLocationParams = { policyID, - employeeAccountIDList: updates.map((update) => update.accountID).join(','), employeeLoginList: updates.map((update) => update.email).join(','), officeID, }; diff --git a/tests/unit/DistanceRateTest.ts b/tests/unit/DistanceRateTest.ts index ce3eb902ee35..b1075dde9397 100644 --- a/tests/unit/DistanceRateTest.ts +++ b/tests/unit/DistanceRateTest.ts @@ -829,7 +829,7 @@ describe('DistanceRate', () => { resetQueue(); }); - it('should send every member that changed in one request, by accountID and by login', async () => { + it('should send every member that changed in one request, by login', async () => { // Given two members following the primary office const policy: Policy = { ...createRandomPolicy(31), @@ -845,11 +845,10 @@ describe('DistanceRate', () => { updateEmployeeOfficeLocation(policy, [member1AccountID, member2AccountID], CONST.POLICY.COMPANY_ADDRESS_OFFICE_ID, personalDetails, TestHelper.translateLocal); await waitForBatchedUpdates(); - // Then one request carries both members, so the logins still match them if an offline invite replaces their accountIDs + // Then one request carries both members by login, which still matches them if an offline invite replaces their accountIDs expect(writeSpy).toHaveBeenCalledTimes(1); expect(writeSpy.mock.calls.at(0)?.[1]).toEqual({ policyID: policy.id, - employeeAccountIDList: `${member1AccountID},${member2AccountID}`, employeeLoginList: `${member1Email},${member2Email}`, officeID: CONST.POLICY.COMPANY_ADDRESS_OFFICE_ID, }); From b393cf0dfc6b5609b7a7271bcb589bb5c29b7985 Mon Sep 17 00:00:00 2001 From: alberto Date: Mon, 19 Oct 2026 15:38:11 +0200 Subject: [PATCH 04/19] parrot --- src/languages/de.ts | 6 +++--- src/languages/el.ts | 8 ++++---- src/languages/es.ts | 10 +++++----- src/languages/fr.ts | 8 ++++---- src/languages/it.ts | 8 ++++---- src/languages/ja.ts | 8 ++++---- src/languages/nl.ts | 8 ++++---- src/languages/pl.ts | 4 ++-- src/languages/pt-BR.ts | 10 +++++----- src/languages/zh-hans.ts | 8 ++++---- 10 files changed, 39 insertions(+), 39 deletions(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index fea61b1e90a3..dcc8911620db 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -9765,10 +9765,10 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc setCompanyAddressAsPrimary: 'Firmenadresse als primär festlegen', removed: ({name}: {name: string}) => `hat den Bürostandort „${name}“ entfernt`, changedMemberOfficeLocation: ({displayName, officeLocation, previousOfficeLocation}: {displayName: string; officeLocation: string; previousOfficeLocation: string}) => - `hat den Bürostandort von ${displayName} geändert: ${officeLocation} (zuvor: ${previousOfficeLocation})`, + `hat den Bürostandort von ${displayName} in ${officeLocation} geändert (zuvor ${previousOfficeLocation})`, quotedName: ({name}: {name: string}) => `„${name}“`, - companyAddress: 'Firmenadresse', - primaryOffice: 'Hauptstandort', + companyAddress: 'die Firmenadresse', + primaryOffice: 'das Hauptbüro', }, workArrangement: { set: ({arrangement}: {arrangement: string}) => `Standard-Arbeitsmodell auf ${arrangement} festlegen`, diff --git a/src/languages/el.ts b/src/languages/el.ts index 1fa1a8f4d6e9..4ec0564f4ebc 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -7472,7 +7472,7 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ officeLocation: 'Τοποθεσία γραφείου', officeLocationWithAddress: ({name, address}: {name: string; address: string}) => `${name} (${address})`, changeOfficeLocation: 'Αλλαγή τοποθεσίας γραφείου', - changeWorkArrangement: 'Αλλαγή διευθέτησης εργασίας', + changeWorkArrangement: 'Αλλαγή ρύθμισης εργασίας', }, card: { getStartedIssuing: 'Ξεκινήστε εκδίδοντας την πρώτη σας εικονική ή φυσική κάρτα.', @@ -9888,10 +9888,10 @@ ${reportName}`, setCompanyAddressAsPrimary: 'ορίστε τη διεύθυνση της εταιρείας ως κύρια', removed: ({name}: {name: string}) => `αφαίρεσε την τοποθεσία γραφείου «${name}»`, changedMemberOfficeLocation: ({displayName, officeLocation, previousOfficeLocation}: {displayName: string; officeLocation: string; previousOfficeLocation: string}) => - `άλλαξε την τοποθεσία γραφείου του/της ${displayName}: ${officeLocation} (προηγουμένως: ${previousOfficeLocation})`, + `άλλαξε την τοποθεσία γραφείου του/της ${displayName} σε ${officeLocation} (προηγουμένως ${previousOfficeLocation})`, quotedName: ({name}: {name: string}) => `«${name}»`, - companyAddress: 'διεύθυνση εταιρείας', - primaryOffice: 'κύρια τοποθεσία γραφείου', + companyAddress: 'η διεύθυνση της εταιρείας', + primaryOffice: 'το κύριο γραφείο', }, workArrangement: { set: ({arrangement}: {arrangement: string}) => `ορίστε την προεπιλεγμένη εργασιακή ρύθμιση σε ${arrangement}`, diff --git a/src/languages/es.ts b/src/languages/es.ts index 3ba5d1710917..0009004a3318 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -7200,9 +7200,9 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, 'La persona miembro trabaja desde casa o viaja entre ubicaciones sin una oficina fija, por lo que no se aplican las normas sobre desplazamientos.', futureOnlyNote: 'Los cambios se aplican solo a los cálculos de kilometraje futuros. Los gastos de kilometraje existentes no se recalculan.', }, - officeLocation: 'Ubicación de oficina', + officeLocation: 'Ubicación de la oficina', officeLocationWithAddress: ({name, address}: {name: string; address: string}) => `${name} (${address})`, - changeOfficeLocation: 'Cambiar ubicación de oficina', + changeOfficeLocation: 'Cambiar ubicación de la oficina', changeWorkArrangement: 'Cambiar modalidad de trabajo', }, accounting: { @@ -9534,10 +9534,10 @@ ${reportName}`, setCompanyAddressAsPrimary: 'establecer la dirección de la empresa como principal', removed: ({name}: {name: string}) => `eliminó la ubicación de la oficina «${name}»`, changedMemberOfficeLocation: ({displayName, officeLocation, previousOfficeLocation}: {displayName: string; officeLocation: string; previousOfficeLocation: string}) => - `cambió la ubicación de oficina de ${displayName} a ${officeLocation} (antes ${previousOfficeLocation})`, + `cambió la ubicación de la oficina de ${displayName} a ${officeLocation} (previamente ${previousOfficeLocation})`, quotedName: ({name}: {name: string}) => `"${name}"`, - companyAddress: 'la dirección física de la empresa', - primaryOffice: 'la ubicación de oficina principal', + companyAddress: 'la dirección de la empresa', + primaryOffice: 'la oficina principal', }, workArrangement: { set: ({arrangement}: {arrangement: string}) => `establecer la modalidad de trabajo predeterminada en ${arrangement}`, diff --git a/src/languages/fr.ts b/src/languages/fr.ts index c1aa87593a46..1e3e4362a277 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -7357,10 +7357,10 @@ Le forfait Control commence à 9 $ par Membre actif et par mois.`, 'Le membre travaille à domicile ou se déplace entre plusieurs sites sans bureau fixe, donc les règles relatives aux trajets domicile-travail ne s’appliquent pas.', futureOnlyNote: 'Les modifications s’appliquent uniquement aux calculs de kilométrage futurs. Les dépenses de kilométrage existantes ne sont pas recalculées.', }, - officeLocation: 'Lieu de travail', + officeLocation: 'Lieu de bureau', officeLocationWithAddress: ({name, address}: {name: string; address: string}) => `${name} (${address})`, changeOfficeLocation: 'Modifier le lieu de travail', - changeWorkArrangement: 'Modifier les modalités de travail', + changeWorkArrangement: 'Modifier l’organisation du travail', }, card: { getStartedIssuing: 'Commencez par émettre votre première carte virtuelle ou physique.', @@ -9788,10 +9788,10 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e setCompanyAddressAsPrimary: 'définir l’adresse de l’entreprise comme principale', removed: ({name}: {name: string}) => `a supprimé le lieu de bureau « ${name} »`, changedMemberOfficeLocation: ({displayName, officeLocation, previousOfficeLocation}: {displayName: string; officeLocation: string; previousOfficeLocation: string}) => - `a remplacé le lieu de travail de ${displayName} par ${officeLocation} (auparavant ${previousOfficeLocation})`, + `a modifié le lieu de travail de ${displayName} en ${officeLocation} (précédemment ${previousOfficeLocation})`, quotedName: ({name}: {name: string}) => `« ${name} »`, companyAddress: 'l’adresse de l’entreprise', - primaryOffice: 'le lieu de travail principal', + primaryOffice: 'le bureau principal', }, workArrangement: { set: ({arrangement}: {arrangement: string}) => `définir l’organisation de travail par défaut sur ${arrangement}`, diff --git a/src/languages/it.ts b/src/languages/it.ts index 17f32f071cb3..724c3605f112 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -7295,10 +7295,10 @@ Il piano Control parte da 9 $ al mese per ogni membro attivo.`, 'Il membro lavora da casa o si sposta tra diverse sedi senza un ufficio fisso, quindi le regole relative al tragitto casa-lavoro non si applicano.', futureOnlyNote: 'Le modifiche si applicano solo ai calcoli chilometrici futuri. Le spese chilometriche esistenti non vengono ricalcolate.', }, - officeLocation: 'Sede dell’ufficio', + officeLocation: 'Sede ufficio', officeLocationWithAddress: ({name, address}: {name: string; address: string}) => `${name} (${address})`, changeOfficeLocation: 'Cambia sede dell’ufficio', - changeWorkArrangement: 'Cambia modalità di lavoro', + changeWorkArrangement: 'Modifica modalità di lavoro', }, card: { getStartedIssuing: 'Inizia emettendo la tua prima carta virtuale o fisica.', @@ -9712,10 +9712,10 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, setCompanyAddressAsPrimary: 'imposta l’indirizzo aziendale come principale', removed: ({name}: {name: string}) => `ha rimosso la sede "${name}"`, changedMemberOfficeLocation: ({displayName, officeLocation, previousOfficeLocation}: {displayName: string; officeLocation: string; previousOfficeLocation: string}) => - `ha impostato ${officeLocation} come sede dell’ufficio di ${displayName} (in precedenza ${previousOfficeLocation})`, + `ha cambiato la sede di ${displayName} in ${officeLocation} (in precedenza ${previousOfficeLocation})`, quotedName: ({name}: {name: string}) => `"${name}"`, companyAddress: 'l’indirizzo dell’azienda', - primaryOffice: 'la sede principale', + primaryOffice: 'l’ufficio principale', }, workArrangement: { set: ({arrangement}: {arrangement: string}) => `imposta la modalità di lavoro predefinita su ${arrangement}`, diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 714b8ac72289..35916957b545 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -7204,7 +7204,7 @@ Control プランは、アクティブメンバー1人あたり月額 $9 から optionNoRegularWorkspaceHelp: 'メンバーは在宅勤務、または決まったオフィスがなく拠点間を移動しているため、通勤規程は適用されません。', futureOnlyNote: '変更は今後の走行距離計算にのみ適用されます。既存の走行距離経費は再計算されません。', }, - officeLocation: 'オフィスの所在地', + officeLocation: 'オフィス所在地', officeLocationWithAddress: ({name, address}: {name: string; address: string}) => `${name}(${address})`, changeOfficeLocation: 'オフィスの所在地を変更', changeWorkArrangement: '勤務形態を変更', @@ -9576,10 +9576,10 @@ ${reportName}`, setCompanyAddressAsPrimary: '会社の住所を主要に設定', removed: ({name}: {name: string}) => `オフィス所在地「${name}」を削除しました`, changedMemberOfficeLocation: ({displayName, officeLocation, previousOfficeLocation}: {displayName: string; officeLocation: string; previousOfficeLocation: string}) => - `${displayName}さんのオフィスの所在地を${officeLocation}(以前は${previousOfficeLocation})に変更しました`, + `${displayName}さんのオフィス所在地を${officeLocation}(以前は${previousOfficeLocation})に変更しました`, quotedName: ({name}: {name: string}) => `「${name}」`, - companyAddress: '会社住所', - primaryOffice: 'メインのオフィス', + companyAddress: '会社の住所', + primaryOffice: '主なオフィス', }, workArrangement: { set: ({arrangement}: {arrangement: string}) => `デフォルトの勤務形態を${arrangement}に設定します`, diff --git a/src/languages/nl.ts b/src/languages/nl.ts index d75b09c8c55b..2ae1c9feb203 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -7280,7 +7280,7 @@ Het Control-abonnement begint bij $9 per actieve deelnemer per maand.`, }, officeLocation: 'Kantoorlocatie', officeLocationWithAddress: ({name, address}: {name: string; address: string}) => `${name} (${address})`, - changeOfficeLocation: 'Kantoorlocatie wijzigen', + changeOfficeLocation: 'Locatie kantoor wijzigen', changeWorkArrangement: 'Werkregeling wijzigen', }, card: { @@ -9683,10 +9683,10 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, setCompanyAddressAsPrimary: 'stel het bedrijfsadres in als primair', removed: ({name}: {name: string}) => `heeft de kantoorlocatie "${name}" verwijderd`, changedMemberOfficeLocation: ({displayName, officeLocation, previousOfficeLocation}: {displayName: string; officeLocation: string; previousOfficeLocation: string}) => - `heeft de kantoorlocatie van ${displayName} gewijzigd in ${officeLocation} (eerder ${previousOfficeLocation})`, + `heeft de kantoorlokatie van ${displayName} gewijzigd naar ${officeLocation} (voorheen ${previousOfficeLocation})`, quotedName: ({name}: {name: string}) => `‘${name}’`, - companyAddress: 'het adres van het bedrijf', - primaryOffice: 'de primaire kantoorlocatie', + companyAddress: 'het bedrijfsadres', + primaryOffice: 'het primaire kantoor', }, workArrangement: { set: ({arrangement}: {arrangement: string}) => `stel de standaard werkregeling in op ${arrangement}`, diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 0ffc3ab36d48..e06be8ae3201 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -7307,7 +7307,7 @@ Plan Control zaczyna się od 9 USD za aktywnego członka miesięcznie.`, officeLocation: 'Lokalizacja biura', officeLocationWithAddress: ({name, address}: {name: string; address: string}) => `${name} (${address})`, changeOfficeLocation: 'Zmień lokalizację biura', - changeWorkArrangement: 'Zmień ustalenia dotyczące pracy', + changeWorkArrangement: 'Zmień tryb pracy', }, card: { getStartedIssuing: 'Zacznij od wydania swojej pierwszej wirtualnej lub fizycznej karty.', @@ -9695,7 +9695,7 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, setCompanyAddressAsPrimary: 'ustaw firmowy adres jako główny', removed: ({name}: {name: string}) => `usunięto lokalizację biura „${name}”`, changedMemberOfficeLocation: ({displayName, officeLocation, previousOfficeLocation}: {displayName: string; officeLocation: string; previousOfficeLocation: string}) => - `zmienił(a) lokalizację biura użytkownika ${displayName} na ${officeLocation} (wcześniej ${previousOfficeLocation})`, + `zmienił(-a) lokalizację biura użytkownika ${displayName} na ${officeLocation} (poprzednio ${previousOfficeLocation})`, quotedName: ({name}: {name: string}) => `„${name}”`, companyAddress: 'adres firmy', primaryOffice: 'główne biuro', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 4a7ed9ede7be..5fb53276c8fa 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -7280,10 +7280,10 @@ O plano Control começa em US$ 9 por membro ativo por mês.`, optionNoRegularWorkspaceHelp: 'O membro trabalha em casa ou viaja entre locais sem um escritório fixo, portanto as regras de deslocamento não se aplicam.', futureOnlyNote: 'As alterações se aplicam apenas aos cálculos de quilometragem futuros. As despesas de quilometragem existentes não são recalculadas.', }, - officeLocation: 'Local de escritório', + officeLocation: 'Local do escritório', officeLocationWithAddress: ({name, address}: {name: string; address: string}) => `${name} (${address})`, - changeOfficeLocation: 'Alterar local de escritório', - changeWorkArrangement: 'Alterar arranjo de trabalho', + changeOfficeLocation: 'Alterar local do escritório', + changeWorkArrangement: 'Alterar regime de trabalho', }, card: { getStartedIssuing: 'Comece emitindo seu primeiro cartão virtual ou físico.', @@ -9681,10 +9681,10 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, setCompanyAddressAsPrimary: 'definir o endereço da empresa como principal', removed: ({name}: {name: string}) => `removeu o local do escritório "${name}"`, changedMemberOfficeLocation: ({displayName, officeLocation, previousOfficeLocation}: {displayName: string; officeLocation: string; previousOfficeLocation: string}) => - `alterou o local de escritório de ${displayName} para ${officeLocation} (antes ${previousOfficeLocation})`, + `alterou o local de trabalho de ${displayName} para ${officeLocation} (anteriormente ${previousOfficeLocation})`, quotedName: ({name}: {name: string}) => `"${name}"`, companyAddress: 'o endereço da empresa', - primaryOffice: 'o local de escritório principal', + primaryOffice: 'o escritório principal', }, workArrangement: { set: ({arrangement}: {arrangement: string}) => `definir o arranjo de trabalho padrão como ${arrangement}`, diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index be16e1b4a125..6cb7d6fb8782 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -7024,9 +7024,9 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM optionNoRegularWorkspaceHelp: '成员在家办公或在各地点之间出差,没有固定办公地点,因此通勤规则不适用。', futureOnlyNote: '更改仅适用于未来的里程计算,现有的里程报销将不会重新计算。', }, - officeLocation: '办公地点', + officeLocation: '办公室地点', officeLocationWithAddress: ({name, address}: {name: string; address: string}) => `${name}(${address})`, - changeOfficeLocation: '更改办公地点', + changeOfficeLocation: '更改办公室位置', changeWorkArrangement: '更改工作安排', }, card: { @@ -9326,10 +9326,10 @@ ${reportName}`, setCompanyAddressAsPrimary: '将公司地址设为主要地址', removed: ({name}: {name: string}) => `已移除办公室地点“${name}”`, changedMemberOfficeLocation: ({displayName, officeLocation, previousOfficeLocation}: {displayName: string; officeLocation: string; previousOfficeLocation: string}) => - `已将 ${displayName} 的办公地点更改为${officeLocation}(之前为${previousOfficeLocation})`, + `已将 ${displayName} 的办公地点更改为 ${officeLocation}(先前为 ${previousOfficeLocation})`, quotedName: ({name}: {name: string}) => `“${name}”`, companyAddress: '公司地址', - primaryOffice: '主要办公地点', + primaryOffice: '主办公地点', }, workArrangement: { set: ({arrangement}: {arrangement: string}) => `将默认工作安排设置为 ${arrangement}`, From 3de87a1dd7e0a71dec53cf7dfd93489741f9de8e Mon Sep 17 00:00:00 2001 From: alberto Date: Mon, 19 Oct 2026 15:53:28 +0200 Subject: [PATCH 05/19] clarify comment --- src/libs/OfficeLocationUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/OfficeLocationUtils.ts b/src/libs/OfficeLocationUtils.ts index ac76a93c0862..d72afde61b57 100644 --- a/src/libs/OfficeLocationUtils.ts +++ b/src/libs/OfficeLocationUtils.ts @@ -46,7 +46,7 @@ function getAssignableOfficeLocations(policy: OnyxEntry, translate: Loca return officeLocations; } - // The company address is the primary office while no office is + // The company address is the primary office unless an office location is set as primary const companyAddress = { officeID: CONST.POLICY.COMPANY_ADDRESS_OFFICE_ID, name: translate('common.companyAddress'), From 0ca59f05e8e999dacf10fa317f64d554e645de3b Mon Sep 17 00:00:00 2001 From: alberto Date: Mon, 19 Oct 2026 15:59:14 +0200 Subject: [PATCH 06/19] only update members that change --- ...rkspaceMembersChangeOfficeLocationPage.tsx | 16 ++++++++++---- ...kspaceMembersChangeWorkArrangementPage.tsx | 21 +++++++++++++------ ...aceMembersChangeOfficeLocationPageTest.tsx | 20 +++++++++++++++--- ...ceMembersChangeWorkArrangementPageTest.tsx | 20 +++++++++++++++--- 4 files changed, 61 insertions(+), 16 deletions(-) diff --git a/src/pages/workspace/members/WorkspaceMembersChangeOfficeLocationPage.tsx b/src/pages/workspace/members/WorkspaceMembersChangeOfficeLocationPage.tsx index b9be3abc32e1..6368e909e35c 100644 --- a/src/pages/workspace/members/WorkspaceMembersChangeOfficeLocationPage.tsx +++ b/src/pages/workspace/members/WorkspaceMembersChangeOfficeLocationPage.tsx @@ -56,10 +56,13 @@ function WorkspaceMembersChangeOfficeLocationPage({policy, personalDetails, rout const canChangeOfficeLocation = isBetaEnabled(CONST.BETAS.COMMUTER_EXCLUSIONS_ARRANGEMENTS) && isPolicyAdmin(policy, currentUserLogin) && hasOfficeLocations(policy); const officeLocations = getAssignableOfficeLocations(policy, translate, localeCompare); + const selectedMembers = (selectedAccountIDs ?? []).map((accountID) => ({ + accountID, + officeID: getMemberOfficeLocation(officeLocations, policy?.employeeList?.[personalDetails?.[accountID]?.login ?? '']?.officeID)?.officeID, + })); + // The office the selected members share starts out selected, and none does while their offices differ - const selectedMembersOfficeIDs = new Set( - (selectedAccountIDs ?? []).map((accountID) => getMemberOfficeLocation(officeLocations, policy?.employeeList?.[personalDetails?.[accountID]?.login ?? '']?.officeID)?.officeID), - ); + const selectedMembersOfficeIDs = new Set(selectedMembers.map((member) => member.officeID)); const officeID = chosenOfficeID ?? (selectedMembersOfficeIDs.size === 1 ? [...selectedMembersOfficeIDs].at(0) : undefined); const navigateBackToMembers = () => { @@ -70,7 +73,12 @@ function WorkspaceMembersChangeOfficeLocationPage({policy, personalDetails, rout if (!officeID || !selectedAccountIDs?.length) { return; } - updateEmployeeOfficeLocation(policy, selectedAccountIDs, officeID, personalDetails, translate); + + // Members already in the chosen office are left out, so a member following the primary office keeps following it + const accountIDsToUpdate = selectedMembers.filter((member) => member.officeID !== officeID).map((member) => member.accountID); + if (accountIDsToUpdate.length) { + updateEmployeeOfficeLocation(policy, accountIDsToUpdate, officeID, personalDetails, translate); + } Navigation.goBack(ROUTES.WORKSPACE_MEMBERS.getRoute(policyID), {afterTransition: clearWorkspaceMembersSelectedForBulkEdit}); }; diff --git a/src/pages/workspace/members/WorkspaceMembersChangeWorkArrangementPage.tsx b/src/pages/workspace/members/WorkspaceMembersChangeWorkArrangementPage.tsx index 6e19cec63c4c..a9d3f64a3f9d 100644 --- a/src/pages/workspace/members/WorkspaceMembersChangeWorkArrangementPage.tsx +++ b/src/pages/workspace/members/WorkspaceMembersChangeWorkArrangementPage.tsx @@ -56,12 +56,16 @@ function WorkspaceMembersChangeWorkArrangementPage({policy, personalDetails, rou isPolicyAdmin(policy, currentUserLogin) && policy?.commuterExclusions?.method === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE; - // The arrangement the selected members share starts out selected, and none does while their arrangements differ - const selectedMembersIsOffice = new Set( - (selectedAccountIDs ?? []).map((accountID) => - getEffectiveWorkArrangement(policy?.employeeList?.[personalDetails?.[accountID]?.login ?? '']?.hasOfficeWorkArrangement, policy?.commuterExclusions?.isOfficeWorkArrangement), + const selectedMembers = (selectedAccountIDs ?? []).map((accountID) => ({ + accountID, + isOffice: getEffectiveWorkArrangement( + policy?.employeeList?.[personalDetails?.[accountID]?.login ?? '']?.hasOfficeWorkArrangement, + policy?.commuterExclusions?.isOfficeWorkArrangement, ), - ); + })); + + // The arrangement the selected members share starts out selected, and none does while their arrangements differ + const selectedMembersIsOffice = new Set(selectedMembers.map((member) => member.isOffice)); const isOffice = chosenIsOffice ?? (selectedMembersIsOffice.size === 1 ? [...selectedMembersIsOffice].at(0) : undefined); const navigateBackToMembers = () => { @@ -72,7 +76,12 @@ function WorkspaceMembersChangeWorkArrangementPage({policy, personalDetails, rou if (isOffice === undefined || !selectedAccountIDs?.length) { return; } - setEmployeeWorkArrangement(policy, selectedAccountIDs, isOffice, personalDetails, translate); + + // Members already on the chosen arrangement are left out, so a member following the workspace default keeps following it + const accountIDsToUpdate = selectedMembers.filter((member) => member.isOffice !== isOffice).map((member) => member.accountID); + if (accountIDsToUpdate.length) { + setEmployeeWorkArrangement(policy, accountIDsToUpdate, isOffice, personalDetails, translate); + } Navigation.goBack(ROUTES.WORKSPACE_MEMBERS.getRoute(policyID), {afterTransition: clearWorkspaceMembersSelectedForBulkEdit}); }; diff --git a/tests/ui/WorkspaceMembersChangeOfficeLocationPageTest.tsx b/tests/ui/WorkspaceMembersChangeOfficeLocationPageTest.tsx index fbcb66f1150d..33efc372014c 100644 --- a/tests/ui/WorkspaceMembersChangeOfficeLocationPageTest.tsx +++ b/tests/ui/WorkspaceMembersChangeOfficeLocationPageTest.tsx @@ -129,7 +129,7 @@ describe('WorkspaceMembersChangeOfficeLocationPage', () => { expect(getConfirmButtonProps()?.isDisabled).toBe(true); }); - it('assigns the chosen office to every selected member and hands back the selection once the page closes', () => { + it('assigns the chosen office to the selected members outside it and hands back the selection once the page closes', () => { // Given members in different offices const policy = renderPage('OFFICE1', 'OFFICE2'); @@ -141,8 +141,22 @@ describe('WorkspaceMembersChangeOfficeLocationPage', () => { getConfirmButtonProps()?.onPress?.(); }); - // Then both members are assigned to it, and the selection is cleared after returning to the members - expect(updateEmployeeOfficeLocation).toHaveBeenCalledWith(policy, [member1AccountID, member2AccountID], 'OFFICE2', personalDetails, expect.any(Function)); + // Then only the member outside it is assigned to it, and the selection is cleared after returning to the members + expect(updateEmployeeOfficeLocation).toHaveBeenCalledWith(policy, [member1AccountID], 'OFFICE2', personalDetails, expect.any(Function)); + expect(Navigation.goBack).toHaveBeenCalledWith(ROUTES.WORKSPACE_MEMBERS.getRoute(policyID), {afterTransition: clearWorkspaceMembersSelectedForBulkEdit}); + }); + + it('keeps members following the primary office unassigned when their shared office is confirmed', () => { + // Given both members follow the primary office + renderPage(); + + // When the office they share is confirmed without choosing another one + act(() => { + getConfirmButtonProps()?.onPress?.(); + }); + + // Then nobody is assigned to it, so both keep following whichever office is primary, and the page still closes + expect(updateEmployeeOfficeLocation).not.toHaveBeenCalled(); expect(Navigation.goBack).toHaveBeenCalledWith(ROUTES.WORKSPACE_MEMBERS.getRoute(policyID), {afterTransition: clearWorkspaceMembersSelectedForBulkEdit}); }); }); diff --git a/tests/ui/WorkspaceMembersChangeWorkArrangementPageTest.tsx b/tests/ui/WorkspaceMembersChangeWorkArrangementPageTest.tsx index 22497cd88276..8c4ce3d45013 100644 --- a/tests/ui/WorkspaceMembersChangeWorkArrangementPageTest.tsx +++ b/tests/ui/WorkspaceMembersChangeWorkArrangementPageTest.tsx @@ -115,7 +115,7 @@ describe('WorkspaceMembersChangeWorkArrangementPage', () => { expect(getConfirmButtonProps()?.isDisabled).toBe(true); }); - it('applies the chosen arrangement to every selected member and hands back the selection once the page closes', () => { + it('applies the chosen arrangement to the selected members without it and hands back the selection once the page closes', () => { // Given an office-based member and a remote one const policy = renderPage(true, false); @@ -127,8 +127,22 @@ describe('WorkspaceMembersChangeWorkArrangementPage', () => { getConfirmButtonProps()?.onPress?.(); }); - // Then both members are made office-based, and the selection is cleared after returning to the members - expect(setEmployeeWorkArrangement).toHaveBeenCalledWith(policy, [member1AccountID, member2AccountID], true, personalDetails, expect.any(Function)); + // Then only the remote member is made office-based, and the selection is cleared after returning to the members + expect(setEmployeeWorkArrangement).toHaveBeenCalledWith(policy, [member2AccountID], true, personalDetails, expect.any(Function)); + expect(Navigation.goBack).toHaveBeenCalledWith(ROUTES.WORKSPACE_MEMBERS.getRoute(policyID), {afterTransition: clearWorkspaceMembersSelectedForBulkEdit}); + }); + + it('keeps members following the workspace default without an arrangement of their own when their shared arrangement is confirmed', () => { + // Given both members follow the remote workspace default + renderPage(); + + // When the arrangement they share is confirmed without choosing the other one + act(() => { + getConfirmButtonProps()?.onPress?.(); + }); + + // Then neither member gets an arrangement of their own, so both keep following the workspace default, and the page still closes + expect(setEmployeeWorkArrangement).not.toHaveBeenCalled(); expect(Navigation.goBack).toHaveBeenCalledWith(ROUTES.WORKSPACE_MEMBERS.getRoute(policyID), {afterTransition: clearWorkspaceMembersSelectedForBulkEdit}); }); }); From c3db60957b3f65ab513e3441dc6da16fb330a8e0 Mon Sep 17 00:00:00 2001 From: alberto Date: Mon, 19 Oct 2026 16:06:34 +0200 Subject: [PATCH 07/19] scope bulk selection to its workspace --- src/ONYXKEYS.ts | 2 +- src/libs/actions/Policy/Member.ts | 6 ++--- src/pages/workspace/WorkspaceMembersPage.tsx | 9 +++++--- ...rkspaceMembersChangeOfficeLocationPage.tsx | 7 ++++-- ...kspaceMembersChangeWorkArrangementPage.tsx | 7 ++++-- .../WorkspaceMembersSelectedForBulkEdit.ts | 10 ++++++++ src/types/onyx/index.ts | 2 ++ ...aceMembersChangeOfficeLocationPageTest.tsx | 23 +++++++++++++++++-- ...ceMembersChangeWorkArrangementPageTest.tsx | 23 +++++++++++++++++-- tests/ui/WorkspaceMembersTest.tsx | 8 +++---- 10 files changed, 78 insertions(+), 19 deletions(-) create mode 100644 src/types/onyx/WorkspaceMembersSelectedForBulkEdit.ts diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 06f96424cc87..ea13afaa5d68 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -1880,7 +1880,7 @@ type OnyxValuesMapping = { [ONYXKEYS.VIEWING_PUBLIC_ROOM_REPORT_ID]: string; [ONYXKEYS.MY_DOMAIN_SECURITY_GROUPS]: Record; [ONYXKEYS.RAM_ONLY_DOMAIN_MEMBERS_SELECTED_FOR_MOVE]: string[]; - [ONYXKEYS.RAM_ONLY_WORKSPACE_MEMBERS_SELECTED_FOR_BULK_EDIT]: number[]; + [ONYXKEYS.RAM_ONLY_WORKSPACE_MEMBERS_SELECTED_FOR_BULK_EDIT]: OnyxTypes.WorkspaceMembersSelectedForBulkEdit; [ONYXKEYS.VERIFY_3DS_SUBSCRIPTION]: string; [ONYXKEYS.VERIFY_3DS_SUBSCRIPTION_SOURCE]: string; [ONYXKEYS.PREFERRED_THEME]: ValueOf; diff --git a/src/libs/actions/Policy/Member.ts b/src/libs/actions/Policy/Member.ts index 0832502e7176..4c5c528f9e97 100644 --- a/src/libs/actions/Policy/Member.ts +++ b/src/libs/actions/Policy/Member.ts @@ -1531,12 +1531,12 @@ function clearImportedSpreadsheetMemberData() { Onyx.set(ONYXKEYS.IMPORTED_SPREADSHEET_MEMBER_ROLE, null); } -function setWorkspaceMembersSelectedForBulkEdit(accountIDs: number[]) { - Onyx.set(ONYXKEYS.RAM_ONLY_WORKSPACE_MEMBERS_SELECTED_FOR_BULK_EDIT, accountIDs); +function setWorkspaceMembersSelectedForBulkEdit(policyID: string, accountIDs: number[]) { + Onyx.set(ONYXKEYS.RAM_ONLY_WORKSPACE_MEMBERS_SELECTED_FOR_BULK_EDIT, {policyID, accountIDs}); } function clearWorkspaceMembersSelectedForBulkEdit() { - Onyx.set(ONYXKEYS.RAM_ONLY_WORKSPACE_MEMBERS_SELECTED_FOR_BULK_EDIT, []); + Onyx.set(ONYXKEYS.RAM_ONLY_WORKSPACE_MEMBERS_SELECTED_FOR_BULK_EDIT, null); } export { diff --git a/src/pages/workspace/WorkspaceMembersPage.tsx b/src/pages/workspace/WorkspaceMembersPage.tsx index da19ad970c3b..55935065bebe 100644 --- a/src/pages/workspace/WorkspaceMembersPage.tsx +++ b/src/pages/workspace/WorkspaceMembersPage.tsx @@ -150,9 +150,9 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers const {showLockedAccountModal} = useLockedAccountActions(); const [selectedEmployees, setSelectedEmployees] = useState([]); const {isBetaEnabled} = usePermissions(); - const [membersSelectedForBulkEditCount = 0] = useOnyx(ONYXKEYS.RAM_ONLY_WORKSPACE_MEMBERS_SELECTED_FOR_BULK_EDIT, {selector: (accountIDs) => accountIDs?.length}); + const [membersSelectedForBulkEditCount = 0] = useOnyx(ONYXKEYS.RAM_ONLY_WORKSPACE_MEMBERS_SELECTED_FOR_BULK_EDIT, {selector: (selection) => selection?.accountIDs.length}); - // A bulk office location or work arrangement change empties the members it was given once it is applied, which + // A bulk office location or work arrangement change clears the members it was given once it is applied, which // ends the selection here too const [prevMembersSelectedForBulkEditCount, setPrevMembersSelectedForBulkEditCount] = useState(membersSelectedForBulkEditCount); if (prevMembersSelectedForBulkEditCount !== membersSelectedForBulkEditCount) { @@ -628,7 +628,10 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers }; const editSelectedEmployees = (bulkEditRoute: Route) => { - setWorkspaceMembersSelectedForBulkEdit(selectedEmployees.map((login) => policyMemberEmailsToAccountIDs[login]).filter((accountID) => accountID !== undefined)); + setWorkspaceMembersSelectedForBulkEdit( + policyID, + selectedEmployees.map((login) => policyMemberEmailsToAccountIDs[login]).filter((accountID) => accountID !== undefined), + ); Navigation.navigate(bulkEditRoute); }; diff --git a/src/pages/workspace/members/WorkspaceMembersChangeOfficeLocationPage.tsx b/src/pages/workspace/members/WorkspaceMembersChangeOfficeLocationPage.tsx index 6368e909e35c..fcb3aa70551f 100644 --- a/src/pages/workspace/members/WorkspaceMembersChangeOfficeLocationPage.tsx +++ b/src/pages/workspace/members/WorkspaceMembersChangeOfficeLocationPage.tsx @@ -48,11 +48,14 @@ function WorkspaceMembersChangeOfficeLocationPage({policy, personalDetails, rout const {translate, localeCompare} = useLocalize(); const {login: currentUserLogin = ''} = useCurrentUserPersonalDetails(); const {isBetaEnabled} = usePermissions(); - const [selectedAccountIDs, selectedAccountIDsMetadata] = useOnyx(ONYXKEYS.RAM_ONLY_WORKSPACE_MEMBERS_SELECTED_FOR_BULK_EDIT); + const [selection, selectionMetadata] = useOnyx(ONYXKEYS.RAM_ONLY_WORKSPACE_MEMBERS_SELECTED_FOR_BULK_EDIT); const [chosenOfficeID, setChosenOfficeID] = useState(); + // Members picked on another workspace's member list are left out, even when they also belong to this one + const selectedAccountIDs = selection?.policyID === policyID ? selection.accountIDs : undefined; + // The selection only lives in memory, so it is gone when the page is opened from a link or after a refresh - const hasSelectedMembers = isLoadingOnyxValue(selectedAccountIDsMetadata) || !!selectedAccountIDs?.length; + const hasSelectedMembers = isLoadingOnyxValue(selectionMetadata) || !!selectedAccountIDs?.length; const canChangeOfficeLocation = isBetaEnabled(CONST.BETAS.COMMUTER_EXCLUSIONS_ARRANGEMENTS) && isPolicyAdmin(policy, currentUserLogin) && hasOfficeLocations(policy); const officeLocations = getAssignableOfficeLocations(policy, translate, localeCompare); diff --git a/src/pages/workspace/members/WorkspaceMembersChangeWorkArrangementPage.tsx b/src/pages/workspace/members/WorkspaceMembersChangeWorkArrangementPage.tsx index a9d3f64a3f9d..39ad247fc094 100644 --- a/src/pages/workspace/members/WorkspaceMembersChangeWorkArrangementPage.tsx +++ b/src/pages/workspace/members/WorkspaceMembersChangeWorkArrangementPage.tsx @@ -46,11 +46,14 @@ function WorkspaceMembersChangeWorkArrangementPage({policy, personalDetails, rou const {translate} = useLocalize(); const {login: currentUserLogin = ''} = useCurrentUserPersonalDetails(); const {isBetaEnabled} = usePermissions(); - const [selectedAccountIDs, selectedAccountIDsMetadata] = useOnyx(ONYXKEYS.RAM_ONLY_WORKSPACE_MEMBERS_SELECTED_FOR_BULK_EDIT); + const [selection, selectionMetadata] = useOnyx(ONYXKEYS.RAM_ONLY_WORKSPACE_MEMBERS_SELECTED_FOR_BULK_EDIT); const [chosenIsOffice, setChosenIsOffice] = useState(); + // Members picked on another workspace's member list are left out, even when they also belong to this one + const selectedAccountIDs = selection?.policyID === policyID ? selection.accountIDs : undefined; + // The selection only lives in memory, so it is gone when the page is opened from a link or after a refresh - const hasSelectedMembers = isLoadingOnyxValue(selectedAccountIDsMetadata) || !!selectedAccountIDs?.length; + const hasSelectedMembers = isLoadingOnyxValue(selectionMetadata) || !!selectedAccountIDs?.length; const canChangeWorkArrangement = isBetaEnabled(CONST.BETAS.COMMUTER_EXCLUSIONS_ARRANGEMENTS) && isPolicyAdmin(policy, currentUserLogin) && diff --git a/src/types/onyx/WorkspaceMembersSelectedForBulkEdit.ts b/src/types/onyx/WorkspaceMembersSelectedForBulkEdit.ts new file mode 100644 index 000000000000..5675f987fbfa --- /dev/null +++ b/src/types/onyx/WorkspaceMembersSelectedForBulkEdit.ts @@ -0,0 +1,10 @@ +/** Members picked on a workspace member list for a bulk office location or work arrangement change */ +type WorkspaceMembersSelectedForBulkEdit = { + /** The workspace whose member list the members were picked from */ + policyID: string; + + /** The account IDs of the picked members */ + accountIDs: number[]; +}; + +export default WorkspaceMembersSelectedForBulkEdit; diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index 95f6f7474ba5..090c4f585d9b 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -252,6 +252,7 @@ import type WalletOnfido from './WalletOnfido'; import type WalletStatement from './WalletStatement'; import type WalletTerms from './WalletTerms'; import type WalletTransfer from './WalletTransfer'; +import type WorkspaceMembersSelectedForBulkEdit from './WorkspaceMembersSelectedForBulkEdit'; export type { FileObject, @@ -412,6 +413,7 @@ export type { TransactionViolations, TravelSettings, WorkspaceTravelSettings, + WorkspaceMembersSelectedForBulkEdit, UserLocation, UserMetadata, UserWallet, diff --git a/tests/ui/WorkspaceMembersChangeOfficeLocationPageTest.tsx b/tests/ui/WorkspaceMembersChangeOfficeLocationPageTest.tsx index 33efc372014c..a1b268e0eb97 100644 --- a/tests/ui/WorkspaceMembersChangeOfficeLocationPageTest.tsx +++ b/tests/ui/WorkspaceMembersChangeOfficeLocationPageTest.tsx @@ -9,6 +9,7 @@ import {updateEmployeeOfficeLocation} from '@libs/actions/Policy/DistanceRate'; import {clearWorkspaceMembersSelectedForBulkEdit} from '@libs/actions/Policy/Member'; import Navigation from '@libs/Navigation/Navigation'; +import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import WorkspaceMembersChangeOfficeLocationPage from '@pages/workspace/members/WorkspaceMembersChangeOfficeLocationPage'; import ROUTES from '@src/ROUTES'; @@ -44,7 +45,7 @@ jest.mock('@libs/PolicyUtils', () => ({ ...jest.requireActual>('@libs/PolicyUtils'), isPolicyAdmin: jest.fn(() => true), })); -jest.mock('@pages/workspace/AccessOrNotFoundWrapper', () => ({__esModule: true, default: ({children}: PropsWithChildren) => children})); +jest.mock('@pages/workspace/AccessOrNotFoundWrapper', () => ({__esModule: true, default: jest.fn(({children}: PropsWithChildren) => children)})); jest.mock('@pages/workspace/withPolicyAndFullscreenLoading', () => (Component: React.ComponentType) => Component); const headquartersAndNewJerseyOffices: Record = { @@ -85,7 +86,7 @@ describe('WorkspaceMembersChangeOfficeLocationPage', () => { beforeEach(() => { jest.clearAllMocks(); - jest.mocked(useOnyx).mockReturnValue([[member1AccountID, member2AccountID], {status: 'loaded'}]); + jest.mocked(useOnyx).mockReturnValue([{policyID, accountIDs: [member1AccountID, member2AccountID]}, {status: 'loaded'}]); }); const renderPage = (member1OfficeID?: string, member2OfficeID?: string) => { @@ -159,4 +160,22 @@ describe('WorkspaceMembersChangeOfficeLocationPage', () => { expect(updateEmployeeOfficeLocation).not.toHaveBeenCalled(); expect(Navigation.goBack).toHaveBeenCalledWith(ROUTES.WORKSPACE_MEMBERS.getRoute(policyID), {afterTransition: clearWorkspaceMembersSelectedForBulkEdit}); }); + + it('leaves out members picked on another workspace', () => { + // Given members picked on another workspace's member list who also belong to this workspace + jest.mocked(useOnyx).mockReturnValue([{policyID: 'otherPolicy', accountIDs: [member1AccountID, member2AccountID]}, {status: 'loaded'}]); + renderPage('OFFICE1', 'OFFICE2'); + + // When an office is chosen and confirmed on this workspace's page + act(() => { + getSelectionListProps()?.onSelectRow?.({keyForList: 'OFFICE2'}); + }); + act(() => { + getConfirmButtonProps()?.onPress?.(); + }); + + // Then the page is blocked and nobody is assigned to the office + expect(jest.mocked(AccessOrNotFoundWrapper).mock.lastCall?.[0].shouldBeBlocked).toBe(true); + expect(updateEmployeeOfficeLocation).not.toHaveBeenCalled(); + }); }); diff --git a/tests/ui/WorkspaceMembersChangeWorkArrangementPageTest.tsx b/tests/ui/WorkspaceMembersChangeWorkArrangementPageTest.tsx index 8c4ce3d45013..d470fc64236b 100644 --- a/tests/ui/WorkspaceMembersChangeWorkArrangementPageTest.tsx +++ b/tests/ui/WorkspaceMembersChangeWorkArrangementPageTest.tsx @@ -9,6 +9,7 @@ import {setEmployeeWorkArrangement} from '@libs/actions/Policy/DistanceRate'; import {clearWorkspaceMembersSelectedForBulkEdit} from '@libs/actions/Policy/Member'; import Navigation from '@libs/Navigation/Navigation'; +import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import WorkspaceMembersChangeWorkArrangementPage from '@pages/workspace/members/WorkspaceMembersChangeWorkArrangementPage'; import CONST from '@src/CONST'; @@ -41,7 +42,7 @@ jest.mock('@libs/Navigation/Navigation', () => ({goBack: jest.fn()})); jest.mock('@libs/actions/Policy/DistanceRate', () => ({setEmployeeWorkArrangement: jest.fn()})); jest.mock('@libs/actions/Policy/Member', () => ({clearWorkspaceMembersSelectedForBulkEdit: jest.fn()})); jest.mock('@libs/PolicyUtils', () => ({isPolicyAdmin: jest.fn(() => true)})); -jest.mock('@pages/workspace/AccessOrNotFoundWrapper', () => ({__esModule: true, default: ({children}: PropsWithChildren) => children})); +jest.mock('@pages/workspace/AccessOrNotFoundWrapper', () => ({__esModule: true, default: jest.fn(({children}: PropsWithChildren) => children)})); jest.mock('@pages/workspace/withPolicyAndFullscreenLoading', () => (Component: React.ComponentType) => Component); type MockWorkArrangementSelectorProps = { @@ -71,7 +72,7 @@ describe('WorkspaceMembersChangeWorkArrangementPage', () => { beforeEach(() => { jest.clearAllMocks(); - jest.mocked(useOnyx).mockReturnValue([[member1AccountID, member2AccountID], {status: 'loaded'}]); + jest.mocked(useOnyx).mockReturnValue([{policyID, accountIDs: [member1AccountID, member2AccountID]}, {status: 'loaded'}]); }); const renderPage = (member1IsOffice?: boolean, member2IsOffice?: boolean) => { @@ -145,4 +146,22 @@ describe('WorkspaceMembersChangeWorkArrangementPage', () => { expect(setEmployeeWorkArrangement).not.toHaveBeenCalled(); expect(Navigation.goBack).toHaveBeenCalledWith(ROUTES.WORKSPACE_MEMBERS.getRoute(policyID), {afterTransition: clearWorkspaceMembersSelectedForBulkEdit}); }); + + it('leaves out members picked on another workspace', () => { + // Given members picked on another workspace's member list who also belong to this workspace + jest.mocked(useOnyx).mockReturnValue([{policyID: 'otherPolicy', accountIDs: [member1AccountID, member2AccountID]}, {status: 'loaded'}]); + renderPage(true, false); + + // When office-based is chosen and confirmed on this workspace's page + act(() => { + getSelectorProps()?.onSelect(true); + }); + act(() => { + getConfirmButtonProps()?.onPress?.(); + }); + + // Then the page is blocked and nobody's arrangement changes + expect(jest.mocked(AccessOrNotFoundWrapper).mock.lastCall?.[0].shouldBeBlocked).toBe(true); + expect(setEmployeeWorkArrangement).not.toHaveBeenCalled(); + }); }); diff --git a/tests/ui/WorkspaceMembersTest.tsx b/tests/ui/WorkspaceMembersTest.tsx index 67dc855a491c..eca101c55d40 100644 --- a/tests/ui/WorkspaceMembersTest.tsx +++ b/tests/ui/WorkspaceMembersTest.tsx @@ -872,7 +872,7 @@ describe('WorkspaceMembers', () => { await waitFor(() => { expect(navigateSpy).toHaveBeenCalledWith(ROUTES.WORKSPACE_MEMBERS_CHANGE_OFFICE_LOCATION.getRoute(policy.id)); }); - expect(await getOnyxValue(ONYXKEYS.RAM_ONLY_WORKSPACE_MEMBERS_SELECTED_FOR_BULK_EDIT)).toEqual([userAccountID]); + expect(await getOnyxValue(ONYXKEYS.RAM_ONLY_WORKSPACE_MEMBERS_SELECTED_FOR_BULK_EDIT)).toEqual({policyID: policy.id, accountIDs: [userAccountID]}); unmount(); await waitForBatchedUpdatesWithAct(); @@ -885,13 +885,13 @@ describe('WorkspaceMembers', () => { await screen.findByText(USER_OPTION); selectCheckboxByMemberName('Member'); await act(async () => { - await Onyx.set(ONYXKEYS.RAM_ONLY_WORKSPACE_MEMBERS_SELECTED_FOR_BULK_EDIT, [userAccountID]); + await Onyx.set(ONYXKEYS.RAM_ONLY_WORKSPACE_MEMBERS_SELECTED_FOR_BULK_EDIT, {policyID: policy.id, accountIDs: [userAccountID]}); }); expect(screen.getByTestId('WorkspaceMembersPage-header-dropdown-menu-button')).toBeOnTheScreen(); - // When the bulk change is applied, which empties the members handed to it + // When the bulk change is applied, which clears the members handed to it await act(async () => { - await Onyx.set(ONYXKEYS.RAM_ONLY_WORKSPACE_MEMBERS_SELECTED_FOR_BULK_EDIT, []); + await Onyx.set(ONYXKEYS.RAM_ONLY_WORKSPACE_MEMBERS_SELECTED_FOR_BULK_EDIT, null); }); // Then the members page drops its selection too From 6d8e057849b1a4016e66bbc10c921700f94fa883 Mon Sep 17 00:00:00 2001 From: alberto Date: Mon, 19 Oct 2026 16:13:45 +0200 Subject: [PATCH 08/19] remove unused type export --- src/libs/OfficeLocationUtils.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/libs/OfficeLocationUtils.ts b/src/libs/OfficeLocationUtils.ts index d72afde61b57..ef6a9b8b3adb 100644 --- a/src/libs/OfficeLocationUtils.ts +++ b/src/libs/OfficeLocationUtils.ts @@ -65,4 +65,3 @@ function getMemberOfficeLocation(officeLocations: AssignableOfficeLocation[], of } export {hasOfficeLocations, getAssignableOfficeLocations, getMemberOfficeLocation}; -export type {AssignableOfficeLocation}; From ca464f0c5b14351b395a63dd89d8f8d0e17f10fb Mon Sep 17 00:00:00 2001 From: alberto Date: Wed, 21 Oct 2026 14:36:29 +0200 Subject: [PATCH 09/19] add confirm button --- .../WorkspaceMemberOfficeLocationPage.tsx | 36 ++++++++++---- .../WorkspaceMemberOfficeLocationPageTest.tsx | 49 ++++++++++++++----- 2 files changed, 64 insertions(+), 21 deletions(-) diff --git a/src/pages/workspace/members/WorkspaceMemberOfficeLocationPage.tsx b/src/pages/workspace/members/WorkspaceMemberOfficeLocationPage.tsx index ff69a4f3a888..87d4fb3f0dca 100644 --- a/src/pages/workspace/members/WorkspaceMemberOfficeLocationPage.tsx +++ b/src/pages/workspace/members/WorkspaceMemberOfficeLocationPage.tsx @@ -1,3 +1,5 @@ +import Button from '@components/Button'; +import FixedFooter from '@components/FixedFooter'; import HeaderWithBackButtonAndTitle from '@components/Header/composed/HeaderWithBackButtonAndTitle'; import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; @@ -28,7 +30,7 @@ import type {PersonalDetailsList} from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; -import React from 'react'; +import React, {useState} from 'react'; import {View} from 'react-native'; type WorkspaceMemberOfficeLocationPageProps = Omit & @@ -49,16 +51,22 @@ function WorkspaceMemberOfficeLocationPage({policy, personalDetails, route}: Wor const canChangeOfficeLocation = isBetaEnabled(CONST.BETAS.COMMUTER_EXCLUSIONS_ARRANGEMENTS) && isPolicyAdmin(policy, currentUserLogin) && hasOfficeLocations(policy) && !!member; const officeLocations = getAssignableOfficeLocations(policy, translate, localeCompare); const currentOfficeID = getMemberOfficeLocation(officeLocations, member?.officeID)?.officeID; + const [chosenOfficeID, setChosenOfficeID] = useState(); + const officeID = chosenOfficeID ?? currentOfficeID; const navigateBackToDetails = () => { Navigation.goBack(ROUTES.WORKSPACE_MEMBER_DETAILS.getRoute(policyID, accountID)); }; - const changeOfficeLocation = ({keyForList}: ListItem) => { - if (keyForList === currentOfficeID || !canChangeOfficeLocation) { + const changeOfficeLocation = () => { + if (!officeID || !canChangeOfficeLocation) { return; } - updateEmployeeOfficeLocation(policy, [accountID], keyForList, personalDetails, translate); + + // Confirming the member's current office leaves them as they are, so a member following the primary office keeps following it + if (officeID !== currentOfficeID) { + updateEmployeeOfficeLocation(policy, [accountID], officeID, personalDetails, translate); + } navigateBackToDetails(); }; @@ -66,7 +74,7 @@ function WorkspaceMemberOfficeLocationPage({policy, personalDetails, route}: Wor text: officeLocation.name, alternateText: officeLocation.address, keyForList: officeLocation.officeID, - isSelected: officeLocation.officeID === currentOfficeID, + isSelected: officeLocation.officeID === officeID, })); return ( @@ -88,16 +96,24 @@ function WorkspaceMemberOfficeLocationPage({policy, personalDetails, route}: Wor setChosenOfficeID(keyForList)} + initiallyFocusedItemKey={officeID} /> {translate('workspace.people.workArrangementPage.futureOnlyNote')} + + + ); diff --git a/tests/ui/WorkspaceMemberOfficeLocationPageTest.tsx b/tests/ui/WorkspaceMemberOfficeLocationPageTest.tsx index eec5e1fc972a..df5156fe367a 100644 --- a/tests/ui/WorkspaceMemberOfficeLocationPageTest.tsx +++ b/tests/ui/WorkspaceMemberOfficeLocationPageTest.tsx @@ -1,5 +1,6 @@ import {act, render} from '@testing-library/react-native'; +import Button from '@components/Button'; import SelectionList from '@components/SelectionList'; import {updateEmployeeOfficeLocation} from '@libs/actions/Policy/DistanceRate'; @@ -17,6 +18,13 @@ import type {PropsWithChildren} from 'react'; import createMock from '../utils/createMock'; +jest.mock('@components/Button', () => + Object.assign( + jest.fn(() => null), + {Text: () => null, KeyboardShortcut: () => null}, + ), +); +jest.mock('@components/FixedFooter', () => jest.fn(({children}: PropsWithChildren) => children)); jest.mock('@components/Header/composed/HeaderWithBackButtonAndTitle', () => jest.fn(() => null)); jest.mock('@components/ScreenWrapper', () => jest.fn(({children}: PropsWithChildren) => children)); jest.mock('@components/SelectionList', () => jest.fn(() => null)); @@ -48,6 +56,11 @@ type MockSelectionListProps = { onSelectRow?: (item: MockOfficeLocationOption) => void; }; +type MockButtonProps = { + onPress?: () => void; + isDisabled?: boolean; +}; + type WorkspaceMemberOfficeLocationPageTestProps = React.ComponentProps & {policy: Policy; personalDetails: PersonalDetailsList}; describe('WorkspaceMemberOfficeLocationPage', () => { @@ -57,6 +70,7 @@ describe('WorkspaceMemberOfficeLocationPage', () => { const personalDetails = {[accountID]: {login: memberLogin}}; const mockedSelectionList = jest.mocked(SelectionList); const getSelectionListProps = () => mockedSelectionList.mock.lastCall?.[0] as MockSelectionListProps | undefined; + const getConfirmButtonProps = () => jest.mocked(Button).mock.lastCall?.[0] as MockButtonProps | undefined; const getSelectedOfficeIDs = () => getSelectionListProps() ?.data.filter((option) => option.isSelected) @@ -91,23 +105,24 @@ describe('WorkspaceMemberOfficeLocationPage', () => { renderPage(); // When the page lists the offices - // Then the company address and every office are offered, and the primary office is the selected one + // Then the company address and every office are offered, and the primary office is the selected one and can be confirmed expect(getSelectionListProps()?.data.map((option) => option.keyForList)).toEqual(['1', 'OFFICE1', 'OFFICE2']); expect(getSelectedOfficeIDs()).toEqual(['OFFICE1']); + expect(getConfirmButtonProps()?.isDisabled).toBe(false); }); - it('does nothing when the selected office is already the member’s', () => { - // Given a member assigned to the New Jersey office - renderPage('OFFICE2'); + it('returns to the member details without a change when the member’s office is confirmed', () => { + // Given a member who follows the primary office + renderPage(); - // When that office is selected again + // When the office they follow is confirmed without choosing another one act(() => { - getSelectionListProps()?.onSelectRow?.({keyForList: 'OFFICE2'}); + getConfirmButtonProps()?.onPress?.(); }); - // Then nothing is sent and the page stays open + // Then nothing is sent, so they keep following whichever office is primary, and the member details are shown expect(updateEmployeeOfficeLocation).not.toHaveBeenCalled(); - expect(Navigation.goBack).not.toHaveBeenCalled(); + expect(Navigation.goBack).toHaveBeenCalledWith(ROUTES.WORKSPACE_MEMBER_DETAILS.getRoute(policyID, accountID)); }); it('does nothing for a user who is not a workspace admin', () => { @@ -115,24 +130,36 @@ describe('WorkspaceMemberOfficeLocationPage', () => { jest.mocked(isPolicyAdmin).mockReturnValue(false); renderPage(); - // When they select another office + // When they choose another office and confirm act(() => { getSelectionListProps()?.onSelectRow?.({keyForList: 'OFFICE2'}); }); + act(() => { + getConfirmButtonProps()?.onPress?.(); + }); // Then nothing is sent, since only admins can change office assignments expect(updateEmployeeOfficeLocation).not.toHaveBeenCalled(); }); - it('assigns the selected office and returns to the member details', () => { + it('assigns the chosen office once it is confirmed and returns to the member details', () => { // Given a member who follows the primary office const policy = renderPage(); - // When the company address is selected + // When the company address is chosen act(() => { getSelectionListProps()?.onSelectRow?.({keyForList: '1'}); }); + // Then it is selected but nothing is sent before confirming + expect(getSelectedOfficeIDs()).toEqual(['1']); + expect(updateEmployeeOfficeLocation).not.toHaveBeenCalled(); + + // When the choice is confirmed + act(() => { + getConfirmButtonProps()?.onPress?.(); + }); + // Then the member is assigned to it and the member details are shown expect(updateEmployeeOfficeLocation).toHaveBeenCalledWith(policy, [accountID], '1', personalDetails, expect.any(Function)); expect(Navigation.goBack).toHaveBeenCalledWith(ROUTES.WORKSPACE_MEMBER_DETAILS.getRoute(policyID, accountID)); From 447eca246a671f12c086addabdc085672d6a28ba Mon Sep 17 00:00:00 2001 From: alberto Date: Wed, 21 Oct 2026 14:51:41 +0200 Subject: [PATCH 10/19] allow office deletion when not needed --- .../WorkspaceOfficeLocationEditPage.tsx | 8 +- .../WorkspaceOfficeLocationEditPageTest.tsx | 79 +++++++++++++++++++ 2 files changed, 84 insertions(+), 3 deletions(-) create mode 100644 tests/ui/WorkspaceOfficeLocationEditPageTest.tsx diff --git a/src/pages/workspace/WorkspaceOfficeLocationEditPage.tsx b/src/pages/workspace/WorkspaceOfficeLocationEditPage.tsx index a489c43e3c72..87120c26e412 100644 --- a/src/pages/workspace/WorkspaceOfficeLocationEditPage.tsx +++ b/src/pages/workspace/WorkspaceOfficeLocationEditPage.tsx @@ -39,15 +39,17 @@ function WorkspaceOfficeLocationEditPage({route}: WorkspaceOfficeLocationEditPag const officeLocations = policy?.officeLocations; const officeLocation = officeLocations?.[officeID]; - // The company address counts as an office, so only a workspace without one keeps at least one office and can't delete - // its last office. Offices being deleted and additions that failed aren't on the server, so they don't count. + // Excluding commutes by home and office needs an office, and the company address counts as one, so only a workspace + // excluding commutes that way without a company address keeps its last office. Offices being deleted and additions + // that failed aren't on the server, so they don't count. const savedOfficeLocations = Object.entries(officeLocations ?? {}).filter( ([, otherOfficeLocation]) => otherOfficeLocation.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && !(otherOfficeLocation.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD && !isEmptyObject(otherOfficeLocation.errors)), ); const isCompanyAddressShown = hasCompanyAddress(policy); - const canDelete = savedOfficeLocations.length > 1 || isCompanyAddressShown; + const isHomeAndOfficeMethod = policy?.commuterExclusions?.method === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE; + const canDelete = savedOfficeLocations.length > 1 || isCompanyAddressShown || !isHomeAndOfficeMethod; // The company address row is listed with the offices, so its label is taken too const takenNames = [ diff --git a/tests/ui/WorkspaceOfficeLocationEditPageTest.tsx b/tests/ui/WorkspaceOfficeLocationEditPageTest.tsx new file mode 100644 index 000000000000..7c8faaabb971 --- /dev/null +++ b/tests/ui/WorkspaceOfficeLocationEditPageTest.tsx @@ -0,0 +1,79 @@ +import {render} from '@testing-library/react-native'; + +import Header from '@components/Header'; + +import usePolicy from '@hooks/usePolicy'; + +import WorkspaceOfficeLocationEditPage from '@pages/workspace/WorkspaceOfficeLocationEditPage'; + +import CONST from '@src/CONST'; +import type {Policy} from '@src/types/onyx'; +import type {CompanyAddress, OfficeLocation} from '@src/types/onyx/Policy'; + +import type React from 'react'; +import type {PropsWithChildren} from 'react'; + +import createMock from '../utils/createMock'; + +jest.mock('@components/Header', () => ({__esModule: true, default: {IconButton: jest.fn(() => null)}})); +jest.mock('@components/Header/composed/HeaderWithBackButtonAndTitle', () => jest.fn(({children}: PropsWithChildren) => children)); +jest.mock('@components/ScreenWrapper', () => jest.fn(({children}: PropsWithChildren) => children)); +jest.mock('@hooks/useConfirmModal', () => jest.fn(() => ({showConfirmModal: jest.fn()}))); +jest.mock('@hooks/useLazyAsset', () => ({useMemoizedLazyExpensifyIcons: jest.fn(() => ({Trashcan: 'Trashcan'}))})); +jest.mock('@hooks/useLocalize', () => jest.fn(() => ({translate: (key: string) => key}))); +jest.mock('@hooks/usePermissions', () => jest.fn(() => ({isBetaEnabled: () => true}))); +jest.mock('@hooks/usePolicy', () => jest.fn()); +jest.mock('@libs/Navigation/Navigation', () => ({goBack: jest.fn()})); +jest.mock('@userActions/Policy/DistanceRate', () => ({deleteOfficeLocation: jest.fn(), updateOfficeLocation: jest.fn()})); +jest.mock('@pages/workspace/AccessOrNotFoundWrapper', () => ({__esModule: true, default: ({children}: PropsWithChildren) => children})); +jest.mock('@pages/workspace/WorkspaceOfficeLocationForm', () => jest.fn(() => null)); + +const companyAddress: CompanyAddress = {addressStreet: '1 Market St', city: 'San Francisco', state: 'CA', zipCode: '94105', country: 'US'}; +const headquarters: OfficeLocation = {name: 'Headquarters', address: {addressStreet: '88 Kearny St', city: 'San Francisco', state: 'CA', zipCode: '94108', country: 'US'}, isDefault: true}; +const newJerseyOffice: OfficeLocation = {name: 'New Jersey Office', address: {addressStreet: '900 Asbury Ave', city: 'Ocean City', state: 'NJ', zipCode: '08226', country: 'US'}}; +const homeAndOfficeCommuterExclusions = {method: CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE}; + +describe('WorkspaceOfficeLocationEditPage', () => { + const policyID = 'policy123'; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + const renderPage = (policy: Partial) => { + jest.mocked(usePolicy).mockReturnValue(createMock({id: policyID, ...policy})); + const props = createMock>({route: {params: {policyID, officeID: 'OFFICE1'}}}); + render(); + }; + const isDeleteShown = () => jest.mocked(Header.IconButton).mock.calls.length > 0; + + it('lets the last office be deleted while the workspace does not exclude commutes by home and office', () => { + // Given a workspace without a company address whose only office is the one being edited + renderPage({officeLocations: {OFFICE1: headquarters}}); + + // When the office is shown for editing + // Then it can be deleted, since nothing needs an office + expect(isDeleteShown()).toBe(true); + }); + + it('keeps the last office while the workspace excludes commutes by home and office', () => { + // Given a workspace without a company address that excludes commutes by home and office with its only office + renderPage({officeLocations: {OFFICE1: headquarters}, commuterExclusions: homeAndOfficeCommuterExclusions}); + + // When the office is shown for editing + // Then it can't be deleted, since excluding commutes by home and office needs an office + expect(isDeleteShown()).toBe(false); + }); + + it.each([ + ['another office', {officeLocations: {OFFICE1: headquarters, OFFICE2: newJerseyOffice}}], + ['the company address', {officeLocations: {OFFICE1: headquarters}, address: companyAddress}], + ])('lets an office be deleted while the workspace excludes commutes by home and office when %s remains', (_description, policy) => { + // Given a workspace that excludes commutes by home and office with another office left after the deletion + renderPage({...policy, commuterExclusions: homeAndOfficeCommuterExclusions}); + + // When the office is shown for editing + // Then it can be deleted, since the workspace keeps an office + expect(isDeleteShown()).toBe(true); + }); +}); From 9d25e885be499680efef2e866812e4a1e4428b93 Mon Sep 17 00:00:00 2001 From: alberto Date: Wed, 21 Oct 2026 15:03:38 +0200 Subject: [PATCH 11/19] use badge for primary office --- src/pages/workspace/WorkspaceOverviewPage.tsx | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/pages/workspace/WorkspaceOverviewPage.tsx b/src/pages/workspace/WorkspaceOverviewPage.tsx index 08e1a608ac12..87c82167b321 100644 --- a/src/pages/workspace/WorkspaceOverviewPage.tsx +++ b/src/pages/workspace/WorkspaceOverviewPage.tsx @@ -1,6 +1,7 @@ import AttachmentPicker from '@components/AttachmentPicker'; import WorkspaceAvatar from '@components/Avatar/WorkspaceAvatar'; import AvatarWithImagePicker from '@components/AvatarWithImagePicker'; +import Badge from '@components/Badge'; import Button from '@components/Button'; import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types'; @@ -789,7 +790,12 @@ function WorkspaceOverviewPage({policyDraft, policy: policyProp, route}: Workspa {formattedAddress} - {isCompanyAddressPrimary && {translate('workspace.officeLocations.primary')}} + {isCompanyAddressPrimary && ( + + )} @@ -814,7 +820,12 @@ function WorkspaceOverviewPage({policyDraft, policy: policyProp, route}: Workspa {formatAddressToString(officeLocation.address)} - {!!officeLocation.isDefault && {translate('workspace.officeLocations.primary')}} + {!!officeLocation.isDefault && ( + + )} {isEditable && } From 1c03c874d35b0ce69458ee3edad33097f4ff6397 Mon Sep 17 00:00:00 2001 From: alberto Date: Wed, 21 Oct 2026 15:59:36 +0200 Subject: [PATCH 12/19] better optimistic handling --- src/libs/actions/Policy/DistanceRate.ts | 11 ++++++++--- tests/unit/DistanceRateTest.ts | 21 +++++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/libs/actions/Policy/DistanceRate.ts b/src/libs/actions/Policy/DistanceRate.ts index 46075a7c03de..f675f27f2411 100644 --- a/src/libs/actions/Policy/DistanceRate.ts +++ b/src/libs/actions/Policy/DistanceRate.ts @@ -893,6 +893,8 @@ type OfficeLocationMemberUpdate = { name: string; /** The office the member was assigned to, used to restore it if the update fails. */ previousOfficeID: string | undefined; + /** The member may still be an optimistic invite, so changing the office must not clear its pending action. */ + previousPendingAction: PolicyEmployee['pendingAction']; /** The ID assigned to this member's optimistic changelog action. */ optimisticReportActionID: string; }; @@ -932,6 +934,7 @@ function updateEmployeeOfficeLocation( email: login, name: personalDetail?.displayName ?? login, previousOfficeID: employee.officeID, + previousPendingAction: employee.pendingAction, optimisticReportActionID: rand64(), }); } @@ -945,11 +948,13 @@ function updateEmployeeOfficeLocation( const employeeListSuccessUpdate: Record> = {}; const employeeListFailureUpdate: Record> = {}; for (const update of updates) { - employeeListOptimisticUpdate[update.email] = {officeID, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}; - employeeListSuccessUpdate[update.email] = {pendingAction: null}; + employeeListOptimisticUpdate[update.email] = {officeID, pendingAction: update.previousPendingAction ?? CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}; + if (!update.previousPendingAction) { + employeeListSuccessUpdate[update.email] = {pendingAction: null}; + } employeeListFailureUpdate[update.email] = { officeID: update.previousOfficeID ?? null, - pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + ...(!update.previousPendingAction && {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}), errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('workspace.editor.genericFailureMessage'), }; } diff --git a/tests/unit/DistanceRateTest.ts b/tests/unit/DistanceRateTest.ts index a9edb910060b..9fbc11803400 100644 --- a/tests/unit/DistanceRateTest.ts +++ b/tests/unit/DistanceRateTest.ts @@ -1031,6 +1031,27 @@ describe('DistanceRate', () => { resetQueue(); }); + it('should preserve a pending invite when changing its office offline', async () => { + // Given a newly invited member whose add action is still pending + const policy: Policy = { + ...createRandomPolicy(34), + officeLocations, + employeeList: {[member1Email]: {email: member1Email, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}}, + }; + await seedOfficeLocationPolicy(policy); + + pause(); + // When the member's office is changed before the invite finishes syncing + updateEmployeeOfficeLocation(policy, [member1AccountID], 'OFFICE2', personalDetails, TestHelper.translateLocal); + await waitForBatchedUpdates(); + + // Then the office updates while the invite remains visibly pending + const onyxPolicy = await getPolicyFromOnyx(policy.id); + expect(onyxPolicy.employeeList?.[member1Email]).toMatchObject({officeID: 'OFFICE2', pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}); + + resetQueue(); + }); + it('should send every member that changed in one request, by login', async () => { // Given two members following the primary office const policy: Policy = { From 55649184c6069c62c891ebbd130695b1cfe60566 Mon Sep 17 00:00:00 2001 From: alberto Date: Wed, 21 Oct 2026 16:12:52 +0200 Subject: [PATCH 13/19] allow people admins to make changes --- src/pages/workspace/WorkspaceMembersPage.tsx | 10 ++-------- .../members/WorkspaceMemberDetailsPage.tsx | 2 +- .../members/WorkspaceMemberOfficeLocationPage.tsx | 8 ++++++-- .../WorkspaceMembersChangeOfficeLocationPage.tsx | 5 +++-- .../WorkspaceMembersChangeWorkArrangementPage.tsx | 4 ++-- tests/ui/WorkspaceMemberOfficeLocationPageTest.tsx | 14 +++++++------- ...orkspaceMembersChangeOfficeLocationPageTest.tsx | 2 +- ...rkspaceMembersChangeWorkArrangementPageTest.tsx | 2 +- tests/ui/WorkspaceMembersTest.tsx | 10 +++++----- 9 files changed, 28 insertions(+), 29 deletions(-) diff --git a/src/pages/workspace/WorkspaceMembersPage.tsx b/src/pages/workspace/WorkspaceMembersPage.tsx index 6ab3e8dc2222..bf28aea7f0b7 100644 --- a/src/pages/workspace/WorkspaceMembersPage.tsx +++ b/src/pages/workspace/WorkspaceMembersPage.tsx @@ -704,9 +704,7 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers options.push(paymentsAdminOption); } - // Only policy admins can change office locations and work arrangements, unlike people admins who manage members - const isCurrentUserPolicyAdmin = isPolicyAdmin(policy, currentUserLogin); - if (shouldShowOfficeLocations && isCurrentUserPolicyAdmin) { + if (shouldShowOfficeLocations) { options.push({ text: translate('workspace.people.changeOfficeLocation'), value: CONST.POLICY.MEMBERS_BULK_ACTION_TYPES.CHANGE_OFFICE_LOCATION, @@ -715,11 +713,7 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers }); } - if ( - policy?.commuterExclusions?.method === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE && - isBetaEnabled(CONST.BETAS.COMMUTER_EXCLUSIONS_ARRANGEMENTS) && - isCurrentUserPolicyAdmin - ) { + if (policy?.commuterExclusions?.method === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE && isBetaEnabled(CONST.BETAS.COMMUTER_EXCLUSIONS_ARRANGEMENTS)) { options.push({ text: translate('workspace.people.changeWorkArrangement'), value: CONST.POLICY.MEMBERS_BULK_ACTION_TYPES.CHANGE_WORK_ARRANGEMENT, diff --git a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx index e839a3974445..e6b69036ae07 100644 --- a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx +++ b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx @@ -141,8 +141,8 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM const isCurrentUserAdmin = policy?.employeeList?.[currentUserLogin]?.role === CONST.POLICY.ROLE.ADMIN; const isCurrentUserOwner = policy?.owner === currentUserLogin; const shouldShowOfficeLocation = isBetaEnabled(CONST.BETAS.COMMUTER_EXCLUSIONS_ARRANGEMENTS) && hasOfficeLocations(policy); - const canChangeOfficeLocation = shouldShowOfficeLocation && isCurrentUserAdmin; const canWriteMembers = canMemberWrite(policy, currentUserLogin, CONST.POLICY.POLICY_FEATURE.MEMBERS); + const canChangeOfficeLocation = shouldShowOfficeLocation && canWriteMembers; const canManageSelectedMemberRole = canMemberAssignRole(policy, currentUserLogin, member?.role); const canRemoveSelectedMember = canWriteMembers && !isSelectedMemberOwner && !isSelectedMemberCurrentUser && canMemberManageMemberWithRole(policy, currentUserLogin, member?.role); const ownerDetails = personalDetails?.[policy?.ownerAccountID ?? CONST.DEFAULT_NUMBER_ID]; diff --git a/src/pages/workspace/members/WorkspaceMemberOfficeLocationPage.tsx b/src/pages/workspace/members/WorkspaceMemberOfficeLocationPage.tsx index 87d4fb3f0dca..8cfee0b766c6 100644 --- a/src/pages/workspace/members/WorkspaceMemberOfficeLocationPage.tsx +++ b/src/pages/workspace/members/WorkspaceMemberOfficeLocationPage.tsx @@ -17,7 +17,7 @@ import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; import {getAssignableOfficeLocations, getMemberOfficeLocation, hasOfficeLocations} from '@libs/OfficeLocationUtils'; -import {isPolicyAdmin} from '@libs/PolicyUtils'; +import {canMemberWrite} from '@libs/PolicyUtils'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading'; @@ -48,7 +48,11 @@ function WorkspaceMemberOfficeLocationPage({policy, personalDetails, route}: Wor const memberLogin = personalDetails?.[accountID]?.login ?? ''; const member = policy?.employeeList?.[memberLogin]; - const canChangeOfficeLocation = isBetaEnabled(CONST.BETAS.COMMUTER_EXCLUSIONS_ARRANGEMENTS) && isPolicyAdmin(policy, currentUserLogin) && hasOfficeLocations(policy) && !!member; + const canChangeOfficeLocation = + isBetaEnabled(CONST.BETAS.COMMUTER_EXCLUSIONS_ARRANGEMENTS) && + canMemberWrite(policy, currentUserLogin, CONST.POLICY.POLICY_FEATURE.MEMBERS) && + hasOfficeLocations(policy) && + !!member; const officeLocations = getAssignableOfficeLocations(policy, translate, localeCompare); const currentOfficeID = getMemberOfficeLocation(officeLocations, member?.officeID)?.officeID; const [chosenOfficeID, setChosenOfficeID] = useState(); diff --git a/src/pages/workspace/members/WorkspaceMembersChangeOfficeLocationPage.tsx b/src/pages/workspace/members/WorkspaceMembersChangeOfficeLocationPage.tsx index fcb3aa70551f..4f41985c3d11 100644 --- a/src/pages/workspace/members/WorkspaceMembersChangeOfficeLocationPage.tsx +++ b/src/pages/workspace/members/WorkspaceMembersChangeOfficeLocationPage.tsx @@ -19,7 +19,7 @@ import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; import {getAssignableOfficeLocations, getMemberOfficeLocation, hasOfficeLocations} from '@libs/OfficeLocationUtils'; -import {isPolicyAdmin} from '@libs/PolicyUtils'; +import {canMemberWrite} from '@libs/PolicyUtils'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading'; @@ -56,7 +56,8 @@ function WorkspaceMembersChangeOfficeLocationPage({policy, personalDetails, rout // The selection only lives in memory, so it is gone when the page is opened from a link or after a refresh const hasSelectedMembers = isLoadingOnyxValue(selectionMetadata) || !!selectedAccountIDs?.length; - const canChangeOfficeLocation = isBetaEnabled(CONST.BETAS.COMMUTER_EXCLUSIONS_ARRANGEMENTS) && isPolicyAdmin(policy, currentUserLogin) && hasOfficeLocations(policy); + const canChangeOfficeLocation = + isBetaEnabled(CONST.BETAS.COMMUTER_EXCLUSIONS_ARRANGEMENTS) && canMemberWrite(policy, currentUserLogin, CONST.POLICY.POLICY_FEATURE.MEMBERS) && hasOfficeLocations(policy); const officeLocations = getAssignableOfficeLocations(policy, translate, localeCompare); const selectedMembers = (selectedAccountIDs ?? []).map((accountID) => ({ diff --git a/src/pages/workspace/members/WorkspaceMembersChangeWorkArrangementPage.tsx b/src/pages/workspace/members/WorkspaceMembersChangeWorkArrangementPage.tsx index 39ad247fc094..2abaece372d9 100644 --- a/src/pages/workspace/members/WorkspaceMembersChangeWorkArrangementPage.tsx +++ b/src/pages/workspace/members/WorkspaceMembersChangeWorkArrangementPage.tsx @@ -17,7 +17,7 @@ import {clearWorkspaceMembersSelectedForBulkEdit} from '@libs/actions/Policy/Mem import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; -import {isPolicyAdmin} from '@libs/PolicyUtils'; +import {canMemberWrite} from '@libs/PolicyUtils'; import {getEffectiveWorkArrangement} from '@libs/WorkArrangementUtils'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; @@ -56,7 +56,7 @@ function WorkspaceMembersChangeWorkArrangementPage({policy, personalDetails, rou const hasSelectedMembers = isLoadingOnyxValue(selectionMetadata) || !!selectedAccountIDs?.length; const canChangeWorkArrangement = isBetaEnabled(CONST.BETAS.COMMUTER_EXCLUSIONS_ARRANGEMENTS) && - isPolicyAdmin(policy, currentUserLogin) && + canMemberWrite(policy, currentUserLogin, CONST.POLICY.POLICY_FEATURE.MEMBERS) && policy?.commuterExclusions?.method === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE; const selectedMembers = (selectedAccountIDs ?? []).map((accountID) => ({ diff --git a/tests/ui/WorkspaceMemberOfficeLocationPageTest.tsx b/tests/ui/WorkspaceMemberOfficeLocationPageTest.tsx index df5156fe367a..627e27cdadae 100644 --- a/tests/ui/WorkspaceMemberOfficeLocationPageTest.tsx +++ b/tests/ui/WorkspaceMemberOfficeLocationPageTest.tsx @@ -5,7 +5,7 @@ import SelectionList from '@components/SelectionList'; import {updateEmployeeOfficeLocation} from '@libs/actions/Policy/DistanceRate'; import Navigation from '@libs/Navigation/Navigation'; -import {isPolicyAdmin} from '@libs/PolicyUtils'; +import {canMemberWrite} from '@libs/PolicyUtils'; import WorkspaceMemberOfficeLocationPage from '@pages/workspace/members/WorkspaceMemberOfficeLocationPage'; @@ -38,7 +38,7 @@ jest.mock('@libs/Navigation/Navigation', () => ({goBack: jest.fn()})); jest.mock('@libs/actions/Policy/DistanceRate', () => ({updateEmployeeOfficeLocation: jest.fn()})); jest.mock('@libs/PolicyUtils', () => ({ ...jest.requireActual>('@libs/PolicyUtils'), - isPolicyAdmin: jest.fn(() => true), + canMemberWrite: jest.fn(() => true), })); jest.mock('@pages/workspace/AccessOrNotFoundWrapper', () => ({__esModule: true, default: ({children}: PropsWithChildren) => children})); jest.mock('@pages/workspace/withPolicyAndFullscreenLoading', () => (Component: React.ComponentType) => Component); @@ -78,7 +78,7 @@ describe('WorkspaceMemberOfficeLocationPage', () => { beforeEach(() => { jest.clearAllMocks(); - jest.mocked(isPolicyAdmin).mockReturnValue(true); + jest.mocked(canMemberWrite).mockReturnValue(true); }); const renderPage = (officeID?: string) => { @@ -125,9 +125,9 @@ describe('WorkspaceMemberOfficeLocationPage', () => { expect(Navigation.goBack).toHaveBeenCalledWith(ROUTES.WORKSPACE_MEMBER_DETAILS.getRoute(policyID, accountID)); }); - it('does nothing for a user who is not a workspace admin', () => { - // Given a user who manages members without being a workspace admin - jest.mocked(isPolicyAdmin).mockReturnValue(false); + it('does nothing for a user who cannot manage members', () => { + // Given a user without write access to the workspace members + jest.mocked(canMemberWrite).mockReturnValue(false); renderPage(); // When they choose another office and confirm @@ -138,7 +138,7 @@ describe('WorkspaceMemberOfficeLocationPage', () => { getConfirmButtonProps()?.onPress?.(); }); - // Then nothing is sent, since only admins can change office assignments + // Then nothing is sent, since changing office assignments needs write access to the members expect(updateEmployeeOfficeLocation).not.toHaveBeenCalled(); }); diff --git a/tests/ui/WorkspaceMembersChangeOfficeLocationPageTest.tsx b/tests/ui/WorkspaceMembersChangeOfficeLocationPageTest.tsx index a1b268e0eb97..258730384ed0 100644 --- a/tests/ui/WorkspaceMembersChangeOfficeLocationPageTest.tsx +++ b/tests/ui/WorkspaceMembersChangeOfficeLocationPageTest.tsx @@ -43,7 +43,7 @@ jest.mock('@libs/actions/Policy/DistanceRate', () => ({updateEmployeeOfficeLocat jest.mock('@libs/actions/Policy/Member', () => ({clearWorkspaceMembersSelectedForBulkEdit: jest.fn()})); jest.mock('@libs/PolicyUtils', () => ({ ...jest.requireActual>('@libs/PolicyUtils'), - isPolicyAdmin: jest.fn(() => true), + canMemberWrite: jest.fn(() => true), })); jest.mock('@pages/workspace/AccessOrNotFoundWrapper', () => ({__esModule: true, default: jest.fn(({children}: PropsWithChildren) => children)})); jest.mock('@pages/workspace/withPolicyAndFullscreenLoading', () => (Component: React.ComponentType) => Component); diff --git a/tests/ui/WorkspaceMembersChangeWorkArrangementPageTest.tsx b/tests/ui/WorkspaceMembersChangeWorkArrangementPageTest.tsx index d470fc64236b..b3e0fa5406fe 100644 --- a/tests/ui/WorkspaceMembersChangeWorkArrangementPageTest.tsx +++ b/tests/ui/WorkspaceMembersChangeWorkArrangementPageTest.tsx @@ -41,7 +41,7 @@ jest.mock('@hooks/useThemeStyles', () => jest.fn(() => new Proxy({}, {get: () => jest.mock('@libs/Navigation/Navigation', () => ({goBack: jest.fn()})); jest.mock('@libs/actions/Policy/DistanceRate', () => ({setEmployeeWorkArrangement: jest.fn()})); jest.mock('@libs/actions/Policy/Member', () => ({clearWorkspaceMembersSelectedForBulkEdit: jest.fn()})); -jest.mock('@libs/PolicyUtils', () => ({isPolicyAdmin: jest.fn(() => true)})); +jest.mock('@libs/PolicyUtils', () => ({canMemberWrite: jest.fn(() => true)})); jest.mock('@pages/workspace/AccessOrNotFoundWrapper', () => ({__esModule: true, default: jest.fn(({children}: PropsWithChildren) => children)})); jest.mock('@pages/workspace/withPolicyAndFullscreenLoading', () => (Component: React.ComponentType) => Component); diff --git a/tests/ui/WorkspaceMembersTest.tsx b/tests/ui/WorkspaceMembersTest.tsx index a909c13039f5..2c58a4456d03 100644 --- a/tests/ui/WorkspaceMembersTest.tsx +++ b/tests/ui/WorkspaceMembersTest.tsx @@ -924,8 +924,8 @@ describe('WorkspaceMembers', () => { await waitForBatchedUpdatesWithAct(); }); - it('should not offer office location or work arrangement changes to People Admins', async () => { - // Given a People Admin, who manages members but cannot change their offices or work arrangements + it('should offer office location and work arrangement changes to People Admins', async () => { + // Given a People Admin, who manages members, offices and work arrangements included await act(async () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, { role: CONST.POLICY.ROLE.PEOPLE_ADMIN, @@ -938,9 +938,9 @@ describe('WorkspaceMembers', () => { // When they open the bulk actions for a member await openBulkActionsForMember(); - // Then neither change is offered - expect(screen.queryByText(TestHelper.translateLocal('workspace.people.changeOfficeLocation'))).not.toBeOnTheScreen(); - expect(screen.queryByText(TestHelper.translateLocal('workspace.people.changeWorkArrangement'))).not.toBeOnTheScreen(); + // Then both changes are offered + expect(screen.getByText(TestHelper.translateLocal('workspace.people.changeOfficeLocation'))).toBeOnTheScreen(); + expect(screen.getByText(TestHelper.translateLocal('workspace.people.changeWorkArrangement'))).toBeOnTheScreen(); unmount(); await waitForBatchedUpdatesWithAct(); From eb55765d542fa565cf060e52bfc4ee75a5cc87ba Mon Sep 17 00:00:00 2001 From: alberto Date: Wed, 21 Oct 2026 16:15:11 +0200 Subject: [PATCH 14/19] remove memo --- src/pages/workspace/WorkspaceMembersPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/workspace/WorkspaceMembersPage.tsx b/src/pages/workspace/WorkspaceMembersPage.tsx index bf28aea7f0b7..cc68c246be4e 100644 --- a/src/pages/workspace/WorkspaceMembersPage.tsx +++ b/src/pages/workspace/WorkspaceMembersPage.tsx @@ -382,7 +382,7 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers const shouldShowCustomField2Column = isControlPolicyWithWideLayout && hasAnyCustomField2; const shouldShowOfficeLocations = isBetaEnabled(CONST.BETAS.COMMUTER_EXCLUSIONS_ARRANGEMENTS) && hasOfficeLocations(policy); const shouldShowOfficeLocationColumn = !shouldUseNarrowLayout && shouldShowOfficeLocations; - const officeLocations = useMemo(() => getAssignableOfficeLocations(policy, translate, localeCompare), [policy, translate, localeCompare]); + const officeLocations = getAssignableOfficeLocations(policy, translate, localeCompare); const shouldShowBankAccountColumn = !shouldUseNarrowLayout && !isMediumScreenWidth && isPolicyAdmin(policy) && arePaymentsEnabled(policy); // Submit workspaces have a flat role model where every member, including the owner, is an Editor. From 24b1aa69bb1735dc3c4bcddac5966ee09bf94b31 Mon Sep 17 00:00:00 2001 From: alberto Date: Wed, 21 Oct 2026 17:05:40 +0200 Subject: [PATCH 15/19] Add notification to arrangement change --- .../members/WorkspaceMembersChangeWorkArrangementPage.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/pages/workspace/members/WorkspaceMembersChangeWorkArrangementPage.tsx b/src/pages/workspace/members/WorkspaceMembersChangeWorkArrangementPage.tsx index 2abaece372d9..96f90b043d74 100644 --- a/src/pages/workspace/members/WorkspaceMembersChangeWorkArrangementPage.tsx +++ b/src/pages/workspace/members/WorkspaceMembersChangeWorkArrangementPage.tsx @@ -34,6 +34,7 @@ import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; import type {OnyxEntry} from 'react-native-onyx'; import React, {useState} from 'react'; +import {View} from 'react-native'; type WorkspaceMembersChangeWorkArrangementPageProps = Omit & PlatformStackScreenProps & { @@ -109,8 +110,10 @@ function WorkspaceMembersChangeWorkArrangementPage({policy, personalDetails, rou isOffice={isOffice} onSelect={setChosenIsOffice} /> - {translate('workspace.people.workArrangementPage.futureOnlyNote')} + + {translate('workspace.people.workArrangementPage.futureOnlyNote')} +